Implementation Plan: Profile Roles as Value Object

Mission: profile-roles-as-value-object-01KPRJRY Branch: doctrine/profile_reinforcement | Date: 2026-04-21 Spec: spec.md


Summary

Replace the scalar Role(StrEnum) field on AgentProfile with a roles: list[Role] list where Role is a half-open str subclass: static well-known constants plus runtime-extensible custom strings. All shipped profiles are migrated to the new list syntax and renamed to carry character names in their profile-id. A permanent backward-compat shim promotes the old scalar role: key to a single-element list with a DeprecationWarning. An optional avatar_image field is added for issue #647 forward-compatibility.


Technical Context

Language/Version: Python 3.11+ Primary Dependencies: Pydantic v2, ruamel.yaml, jsonschema Storage: Filesystem — YAML files, JSON schema Testing: pytest; pytest -m "fast and doctrine" for the fast slice Target Platform: Library (imported by the spec-kitty runtime) Performance Goals: Profile load time for 20 shipped profiles ≤ 5% regression vs. baseline Constraints: No new runtime dependencies; no changes to main until the doctrine/profile_reinforcement branch merges


Epic Alignment (#461 / #466 / #468)

This mission is a prerequisite for Phase 4 and Phase 6 of EPIC #461. Key cross-phase contracts established here:

ConcernThis missionConsuming phase
Half-open Role extensibilityRole("facilitator") valid without code change#468 WP6.6 — retrospective-facilitator profile needs a role value outside the closed enum
<role>-<character> profile-id conventionAll shipped ids follow <role>-<character> (e.g. python-pedro)#466 WP4.3 — spec-kitty ask pedro "..." resolves by matching the character-name suffix; naming is the stable contract
Computed role propertyAgentProfile.role returns roles[0]; no callers need to update#466 WP4.1 — ProfileInvocationExecutor can read .role on any profile without awaiting a separate refactor
avatar_image fieldOptional path string on AgentProfile#647 Phase 1 — dashboard renders the avatar; our field is the missing data-model link

Atomic constraint on WP04 (specializes-from rename): implementerimplementer-ivan must be committed atomically: the rename of implementer.agent.yaml and the update of specializes-from: implementer-ivan in java-jenny.agent.yaml and python-pedro.agent.yaml must land in the same commit. validate_hierarchy() rejects dangling specializes-from references — a split commit would cause CI to fail on the intermediate state. Phase 4's ProfileInvocationExecutor will traverse the specialisation hierarchy; a stale reference at any point would corrupt context inheritance.


Charter Check

Governance directives in scope:

the spec — roles list, computed role property, deprecation warning content all specified precisely

no drive-by refactoring

mypy (or ruff check) before handoff

or alongside the implementation

  • DIRECTIVE_010 (Specification Fidelity): Implementation must faithfully reflect
  • DIRECTIVE_024 (Locality of Change): Each WP touches only its declared file set;
  • DIRECTIVE_030 (Test and Typecheck Quality Gate): All WPs must pass pytest and
  • DIRECTIVE_034 (Test-First Development): Tests for each WP are written before

No charter conflicts detected.


Project Structure

Documentation (this feature)

kitty-specs/profile-roles-as-value-object-01KPRJRY/
├── spec.md           ✓ complete
├── research.md       ✓ complete
├── data-model.md     ✓ complete
├── plan.md           ← this file
└── tasks.md          (generated by /spec-kitty.tasks)

Source Code (relevant paths)

> Note: WP numbering below reflects the final tasks.md decomposition, where plan WP01+WP02 > were merged into a single tasks WP01, shifting subsequent numbers by one.

src/doctrine/agent_profiles/
├── profile.py                  WP01 (Role value object + AgentProfile model)
├── capabilities.py             WP01
├── repository.py               WP03
├── schema_models.py            WP02
├── validation.py               WP02 (minor — schema cache bust)
├── __init__.py                 WP01 (export list if changed)
└── shipped/
    ├── *.agent.yaml            WP04 (all 11 files)
    └── README.md               WP04

src/doctrine/schemas/
└── agent-profile.schema.yaml   WP02

src/doctrine/
└── graph.yaml                  WP04

tests/doctrine/
├── test_shipped_profiles.py    WP04, WP05
├── test_service.py             WP05
└── test_profile_repository.py  WP03 (new + existing pattern cleanup), WP05

tests/charter/
└── test_catalog.py             WP05

tests/specify_cli/status/
└── test_wp_metadata.py         WP05

src/specify_cli/missions/software-dev/command-templates/
├── implement.md                WP06
└── review.md                   WP06

Work Package Overview

> WP01 merges the original plan's WP01 (Role VO) and WP02 (AgentProfile model) for cohesion. > WP06 was added post-planning to fix implement/review template field names and handoff guidance.

WPTitleKey filesDepends onParallel with
WP01Role value object + AgentProfile modelprofile.py, capabilities.pyWP02
WP02YAML schema + schema_models.pyagent-profile.schema.yaml, schema_models.pyWP01WP03
WP03Repository + routing updaterepository.py, test_profile_repository.pyWP01WP02, WP04
WP04Shipped profile migration + renamesshipped/*.yaml, graph.yaml, README.mdWP01, WP02WP03
WP05Test suite alignmenttests/doctrine/, tests/charter/, tests/specify_cli/WP01–WP04
WP06Review workflow agent profile handoffimplement.md, review.md (templates)WP01–WP05

WP01 — Role Half-Open Value Object

Files: src/doctrine/agent_profiles/profile.py, src/doctrine/agent_profiles/capabilities.py

Acceptance criteria:

Role.DESIGNER, Role.PLANNER, Role.RESEARCHER, Role.CURATOR, Role.MANAGER

1. Why str subclass and not StrEnum (open extensibility — custom roles require no code change; Phase 6 WP6.6 requires Role("facilitator") for the retrospective-facilitator profile) 2. The is_known() classmethod as the way to distinguish well-known from custom roles 3. Forward-compatibility note: description field and YAML-registry loading are intentional future extension points; do not add them without a new spec

model validator; Role(value) replaces the enum try/except)

json.dumps/json.loads, custom role accepted without warning

  • Role is a subclass of str; issubclass(Role, str) is True
  • Class-level constants exist: Role.IMPLEMENTER, Role.REVIEWER, Role.ARCHITECT,
  • Each constant compares equal to its string value: Role.IMPLEMENTER == "implementer"
  • Role("my-custom-role") constructs without error
  • Role.is_known(Role.IMPLEMENTER)True; Role.is_known(Role("custom"))False
  • Role class carries a docstring covering:
  • DEFAULT_ROLE_CAPABILITIES in capabilities.py compiles and all lookups work
  • _coerce_role function removed from profile.py (scalar coercion moved to WP02
  • Unit tests cover: construction, equality, is_known, round-trip serialisation via

Subtasks: 1. Write Role(str) class with __new__, _KNOWN frozenset, is_known classmethod, and class-level constant assignments 2. Delete Role(StrEnum) class and _coerce_role function 3. Update capabilities.py — no structural change needed; verify tests pass 4. Write tests/doctrine/test_role_value_object.py


WP02 — AgentProfile Model Update

Files: src/doctrine/agent_profiles/profile.py

Acceptance criteria:

emits DeprecationWarning with message matching "Profile '<id>': the scalar 'role:' field is deprecated. Replace with: roles: [<value>]" (uses profile-id from the input dict for the message)

calls Role(value.lower()) for strings

avatar absent; neither role nor roles raises; both keys → roles wins

  • AgentProfile.roles: list[Role] exists; YAML alias roles; Pydantic minimum length 1
  • AgentProfile.role is a @property returning self.roles[0]
  • model_validator(mode="before") on AgentProfile:
  • If input has role but not roles: promotes to {"roles": [input["role"]]},
  • If input has roles: passes through unchanged; any stale role key is silently ignored
  • If input has neither: raises ValidationError (Pydantic handles this via min_length=1)
  • AgentProfile.avatar_image: str | None = Field(default=None, alias="avatar-image")
  • TaskContext.required_role type becomes Role | None with a BeforeValidator that
  • Tests: scalar coercion emits warning + loads correctly; multi-role list; avatar present;

Subtasks: 1. Add model_validator(mode="before") static method _coerce_scalar_role 2. Replace role: Annotated[Role | str, BeforeValidator(_coerce_role)] with roles: list[Role] = Field(min_length=1) 3. Add @property role(self) -> Role 4. Add avatar_image field 5. Update TaskContext.required_role annotation 6. Write tests covering all acceptance criteria


WP03 — YAML Schema + schema_models.py

Files: src/doctrine/schemas/agent-profile.schema.yaml, src/doctrine/agent_profiles/schema_models.py

Acceptance criteria:

and avatar_image: str | None = Field(default=None, alias="avatar-image")

  • JSON schema accepts roles as an array of strings with minItems: 1
  • JSON schema accepts role as a string (deprecated; both remain valid in schema)
  • JSON schema rejects a profile with neither role nor roles key
  • JSON schema rejects roles: [] (empty array)
  • JSON schema accepts optional avatar-image string field
  • AgentProfileSchemaModel (in schema_models.py) adds roles: list[str] | None
  • validate_agent_profile_yaml accepts both new and legacy forms
  • Tests: schema validation for each valid/invalid case above

Subtasks: 1. Add roles to schema properties: {type: array, items: {type: string}, minItems: 1} 2. Add avatar-image to schema properties: {type: string} 3. Add oneOf constraint or if/then so profiles must have at least one of role/roles 4. Update schema_models.py to add roles and avatar_image fields 5. Bust the @lru_cache on _load_agent_profile_schema by incrementing the schema version comment (the cache is per-process, but clearing it in tests matters) 6. Write schema validation tests


WP04 — Repository + Routing Update

Files: src/doctrine/agent_profiles/repository.py

Acceptance criteria:

or profile.profile_id == required_role; removes the old isinstance(p.role, Role) branch

1.0 signal for primary role; find_by_role with multi-role profile

  • _filter_candidates_by_role(candidates, required_role) checks required_role in profile.roles
  • _exact_id_signal(context, profile) returns:
  • 1.0 if req == profile.profile_id or req == profile.roles[0]
  • 0.5 if req is in profile.roles[1:]
  • 0.0 otherwise
  • find_by_role(role) returns all profiles where role in profile.roles
  • AgentProfileRepository.validate_hierarchy() unchanged
  • All existing test_profile_repository.py tests pass
  • New tests: secondary-role inclusion in filter; 0.5 signal for secondary role;

Subtasks: 1. Rewrite _filter_candidates_by_role to use profile.roles list 2. Rewrite _exact_id_signal with primary/secondary scoring 3. Rewrite find_by_role to check role in profile.roles 4. Remove dead isinstance(p.role, Role) / isinstance(p.role, str) branches 5. Write new tests


WP05 — Shipped Profile Migration + Renames

Files: src/doctrine/agent_profiles/shipped/*.agent.yaml, src/doctrine/graph.yaml, src/doctrine/agent_profiles/shipped/README.md, tests/doctrine/test_shipped_profiles.py

Acceptance criteria:

java-jenny.agent.yaml and python-pedro.agent.yaml

all parametrize entries updated; test suite passes with zero failures

  • All 11 shipped profiles use roles: [...] list syntax — zero DeprecationWarning on load
  • Profile filenames, profile-id fields, and name fields match the rename map:
  • architect-alphonso, curator-carla, designer-dagmar, implementer-ivan
  • planner-priti (name: "Planner Priti"), researcher-robbie (name: "Researcher Robbie")
  • reviewer-renata, generic-agent (unchanged), human-in-charge (unchanged)
  • java-jenny, python-pedro (already done, but role:roles: still needed)
  • specializes-from: implementer updated to specializes-from: implementer-ivan in
  • src/doctrine/graph.yaml URNs updated (7 renames); labels updated for planner + researcher
  • README.md table reflects new filenames and IDs
  • tests/doctrine/test_shipped_profiles.py EXPECTED_PROFILE_IDS set reflects new IDs;

Subtasks: 1. git mv each renamed YAML file (7 files) 2. Update profile-id and role:roles: [...] in each renamed file 3. Update name in planner-priti.agent.yaml and researcher-robbie.agent.yaml 4. [ATOMIC] Update specializes-from: implementerspecializes-from: implementer-ivan in java-jenny.agent.yaml and python-pedro.agent.yaml in the same commit as the implementer.agent.yamlimplementer-ivan.agent.yaml rename. Do not split across commits — validate_hierarchy() will reject the dangling reference on any intermediate state. 5. Update role:roles: in generic-agent.agent.yaml and human-in-charge.agent.yaml 6. Update graph.yaml: 7 URN renames + label corrections 7. Update README.md 8. Update test_shipped_profiles.py


WP06 — Test Suite Alignment

Files: tests/doctrine/test_service.py, tests/doctrine/test_profile_repository.py, tests/charter/test_catalog.py, tests/specify_cli/status/test_wp_metadata.py

Acceptance criteria:

intentionally as-is (to exercise the coercion path); intent documented with a comment

  • pytest tests/doctrine/ tests/charter/ tests/specify_cli/ → zero failures
  • No test calls .role.value (.value does not exist on str subclass)
  • No test uses Role(StrEnum) import path
  • Fixture profiles using old role: scalar are either updated to roles: or left

Subtasks: 1. Audit all four test files for .role.value → replace with direct str() comparison 2. Audit isinstance(x, Role) assertions — valid but may need updating 3. Update fixture dicts: "role": "python-pedro""roles": ["implementer"] where the scalar form is not intentionally testing the coercion path 4. Verify full test suite passes


Artifacts

ArtifactPath
Speckitty-specs/profile-roles-as-value-object-01KPRJRY/spec.md
Researchkitty-specs/profile-roles-as-value-object-01KPRJRY/research.md
Data modelkitty-specs/profile-roles-as-value-object-01KPRJRY/data-model.md
Plankitty-specs/profile-roles-as-value-object-01KPRJRY/plan.md

Branch Contract

  • Current branch: doctrine/profile_reinforcement
  • Base branch for worktrees: doctrine/profile_reinforcement
  • Merge target: doctrine/profile_reinforcement

Next step: /spec-kitty.tasks