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:
| Concern | This mission | Consuming phase |
|---|---|---|
Half-open Role extensibility | Role("facilitator") valid without code change | #468 WP6.6 — retrospective-facilitator profile needs a role value outside the closed enum |
<role>-<character> profile-id convention | All 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 property | AgentProfile.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 field | Optional 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): implementer → implementer-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
pytestand - 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.
| WP | Title | Key files | Depends on | Parallel with |
|---|---|---|---|---|
| WP01 | Role value object + AgentProfile model | profile.py, capabilities.py | — | WP02 |
| WP02 | YAML schema + schema_models.py | agent-profile.schema.yaml, schema_models.py | WP01 | WP03 |
| WP03 | Repository + routing update | repository.py, test_profile_repository.py | WP01 | WP02, WP04 |
| WP04 | Shipped profile migration + renames | shipped/*.yaml, graph.yaml, README.md | WP01, WP02 | WP03 |
| WP05 | Test suite alignment | tests/doctrine/, tests/charter/, tests/specify_cli/ | WP01–WP04 | — |
| WP06 | Review workflow agent profile handoff | implement.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
Roleis a subclass ofstr;issubclass(Role, str)isTrue- 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 errorRole.is_known(Role.IMPLEMENTER)→True;Role.is_known(Role("custom"))→FalseRoleclass carries a docstring covering:DEFAULT_ROLE_CAPABILITIESincapabilities.pycompiles and all lookups work_coerce_rolefunction removed fromprofile.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 aliasroles; Pydantic minimum length 1AgentProfile.roleis a@propertyreturningself.roles[0]model_validator(mode="before")onAgentProfile:- If input has
rolebut notroles: promotes to{"roles": [input["role"]]}, - If input has
roles: passes through unchanged; any stalerolekey is silently ignored - If input has neither: raises
ValidationError(Pydantic handles this viamin_length=1) AgentProfile.avatar_image: str | None = Field(default=None, alias="avatar-image")TaskContext.required_roletype becomesRole | Nonewith aBeforeValidatorthat- 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
rolesas an array of strings withminItems: 1 - JSON schema accepts
roleas a string (deprecated; both remain valid in schema) - JSON schema rejects a profile with neither
rolenorroleskey - JSON schema rejects
roles: [](empty array) - JSON schema accepts optional
avatar-imagestring field AgentProfileSchemaModel(inschema_models.py) addsroles: list[str] | Nonevalidate_agent_profile_yamlaccepts 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)checksrequired_role in profile.roles_exact_id_signal(context, profile)returns:1.0ifreq == profile.profile_idorreq == profile.roles[0]0.5ifreqis inprofile.roles[1:]0.0otherwisefind_by_role(role)returns all profiles whererole in profile.rolesAgentProfileRepository.validate_hierarchy()unchanged- All existing
test_profile_repository.pytests 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 — zeroDeprecationWarningon load - Profile filenames,
profile-idfields, andnamefields match the rename map: architect-alphonso,curator-carla,designer-dagmar,implementer-ivanplanner-priti(name: "Planner Priti"),researcher-robbie(name: "Researcher Robbie")reviewer-renata,generic-agent(unchanged),human-in-charge(unchanged)java-jenny,python-pedro(already done, butrole:→roles:still needed)specializes-from: implementerupdated tospecializes-from: implementer-ivaninsrc/doctrine/graph.yamlURNs updated (7 renames); labels updated for planner + researcherREADME.mdtable reflects new filenames and IDstests/doctrine/test_shipped_profiles.pyEXPECTED_PROFILE_IDSset 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: implementer → specializes-from: implementer-ivan in java-jenny.agent.yaml and python-pedro.agent.yaml in the same commit as the implementer.agent.yaml → implementer-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(.valuedoes not exist onstrsubclass) - No test uses
Role(StrEnum)import path - Fixture profiles using old
role:scalar are either updated toroles: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
| Artifact | Path |
|---|---|
| Spec | kitty-specs/profile-roles-as-value-object-01KPRJRY/spec.md |
| Research | kitty-specs/profile-roles-as-value-object-01KPRJRY/research.md |
| Data model | kitty-specs/profile-roles-as-value-object-01KPRJRY/data-model.md |
| Plan | kitty-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