Contracts
activation-registry.md
Contract — Activation Registry
> Mission: charter-mediated-doctrine-selection-01KRTZCA > Companions: selection-schema.md, mission-type-profile.md
The activation registry is the context-scoped mode activation surface — a list of (activation_context, doctrine_pack_id, artifact_id, artifact_kind?) tuples scoping artifact activation to specific (mission_type, action) contexts. It lives on the charter (project, org pack, or mission-type profile).
Input Contract
Operator-facing YAML shape
Within a fenced YAML block in charter.md, or within org-charter.yaml, or within governance-profile.yaml:
activations:
- activation_context:
action: write_comment
doctrine_pack_id: project
artifact_id: caveman-comments
artifact_kind: styleguide # optional disambiguator
- activation_context:
mission_type: software-dev
action: implement
doctrine_pack_id: built-in
artifact_id: python-conventions
artifact_kind: styleguide
Pydantic shape (charter.activations.ActivationEntry)
| Field | Type | Required | Notes |
|---|---|---|---|
activation_context | dict[str, str] | yes | Keys mission_type and action are recognised; either or both may be absent (= wildcard) |
doctrine_pack_id | str | yes | One of: project, built-in, or a configured org-pack name |
artifact_id | str | yes | Must exist in the named pack at resolve time |
artifact_kind | `str \ | None` | no |
Vocabulary closures
activation_context.mission_type MUST be a member of:
{"software-dev", "documentation", "research", "plan", "any", "generic"}
activation_context.action MUST be a member of:
{
"specify", "plan", "tasks", "implement", "review", "merge", "accept",
"charter.interview", "charter.generate", "charter.context"
}
Vocabulary lives in charter.activations.ALLOWED_MISSION_TYPES and charter.activations.ALLOWED_ACTIONS. Single source of truth.
Wildcards
any and generic in either slot are treated as wildcards (match anything). Absence of the key is equivalent to wildcard.
Output Contract
Resolver call
from charter.activations import resolve_for_context, ActivationEntry
matches = resolve_for_context(
entries, # list[ActivationEntry] (merged from all 3 sources)
mission_type="software-dev",
action="implement",
)
# matches: list[ActivationEntry] — subset of entries whose contexts match
Matching semantics:
mission_typeslot:entry.activation_context.get("mission_type")is absent,"generic","any", or== current_mission_type.actionslot: same withaction.- Both slots must match for the entry to be included.
Rendered prompt stanza (FR-007)
Per matched entry, the renderer emits one line into the implement-prompt governance payload:
> When you <action> in a <mission_type> mission, run spec-kitty charter context --include <artifact_kind>:<artifact_id> and apply the returned rule.
When artifact_kind is absent, the resolver looks up the artifact's kind via DoctrineService and includes it; if the artifact doesn't exist, see Failure Modes.
When mission_type is wildcard, the stanza reads "When you <action>, run ...". Same for action wildcard.
Failure Modes
| Failure | Behaviour |
|---|---|
activation_context.mission_type = "dev" (typo, not in vocabulary) | pydantic.ValidationError at parse time (test_activation_entry_validates_membership_of_vocabulary) |
activation_context.action = "compile" (not in vocabulary) | pydantic.ValidationError at parse time |
doctrine_pack_id = "missing-pack" (not in config.yaml) | Resolver hard-fails with "pack missing-pack not configured" (FR-015) |
artifact_id = "does-not-exist" (no such artifact in the named pack) | Resolver hard-fails with "artifact does-not-exist not found in pack <pack-id>" |
Two entries with identical contexts targeting the same (mission_type, action) | Policy: concatenate — emit one stanza per matched entry in declaration order. The operator may tighten one context to disambiguate. |
artifact_kind set to a kind not in DoctrineService | pydantic.ValidationError at parse time (Literal validation) |
Empty activations: list | No stanzas emitted; no error |
activations: absent from charter | Treated as empty list; no stanzas emitted |
Backward Compatibility Guarantee
- Charters that pre-date this mission and lack an
activations:block parse unchanged. No context-scoped stanzas emitted. - Org packs that pre-date this mission and lack
activations:produce no activations. - The 23-test ATDD suite at
tests/specify_cli/next/test_wp_prompt_governance_contract.pycontinues to pass — it never asserted on context-scoped stanzas. governance.yamladds anactivations:block only when non-empty; otherwise omitted (NFR-005 byte-stability).
Architectural Test Gates
tests/architectural/test_activation_registry_schema.py::test_activation_entry_schema_exists_and_carries_required_fieldstests/architectural/test_activation_registry_schema.py::test_activation_context_mission_type_vocabulary_is_closedtests/architectural/test_activation_registry_schema.py::test_activation_context_action_vocabulary_is_closedtests/architectural/test_activation_registry_schema.py::test_activation_entry_validates_membership_of_vocabularytests/architectural/test_trigger_registry_coverage.py::test_every_declared_trigger_is_in_the_registered_settests/architectural/test_trigger_registry_coverage.py::test_registered_triggers_constant_is_a_frozenset_for_immutability
Note on Trigger Registry vs Activation Vocabulary
See data-model.md §7 for the canonical two-vocabulary definition, the union formula, the mandatory src/charter/activations.py runtime re-export, and the cross-check architectural test that pins them together.
charter-facade-modules.md
Contract — Charter Facade Modules
> Mission: charter-mediated-doctrine-selection-01KRTZCA > Companions: selection-schema.md, activation-registry.md, mission-type-profile.md
Six new modules under src/charter/ that re-export the doctrine surfaces today's runtime imports directly. The facades exist to satisfy the runtime → charter → doctrine boundary (C-001) without forcing the runtime to learn new APIs.
Input Contract
File-system layout
src/charter/
profiles.py (NEW)
mission_steps.py (NEW)
drg.py (NEW)
primitives.py (NEW)
resolution.py (NEW)
versioning.py (NEW)
Module shape (uniform across all six)
Each facade module is a re-export-only Python file:
"""<one-line purpose>.
This module is the charter-layer proxy for runtime callers that historically
imported from doctrine.<sub> directly. The runtime → charter → doctrine
boundary (ADR 2026-03-27-1, tightened by mission
charter-mediated-doctrine-selection-01KRTZCA) requires runtime modules
under src/specify_cli/ to reach doctrine artifacts only through such
charter facades.
"""
from doctrine.<sub> import (
<SymbolA>,
<SymbolB>,
...
)
__all__ = [
"<SymbolA>",
"<SymbolB>",
...
]
No additional logic. No thin wrappers. No type aliases. Pure re-exports.
Symbol tables
| Facade | Re-exported symbols |
|---|---|
charter/profiles.py | AgentProfile, AgentProfileRepository, Role, DEFAULT_ROLE_CAPABILITIES |
charter/mission_steps.py | MissionStep, MissionStepContract, MissionStepContractRepository |
charter/drg.py | DRGEdge, DRGGraph, DRGNode, Relation, NodeKind, load_graph, merge_layers, resolve_context, ResolvedContext |
charter/primitives.py | PrimitiveExecutionContext, execute_with_glossary |
charter/resolution.py | ResolutionResult, ResolutionTier |
charter/versioning.py | check_bundle_compatibility, get_bundle_schema_version |
Output Contract
What the runtime calls
Before:
# src/specify_cli/invocation/registry.py
from doctrine.agent_profiles.profile import AgentProfile
from doctrine.agent_profiles.repository import AgentProfileRepository
After:
# src/specify_cli/invocation/registry.py
from charter.profiles import AgentProfile, AgentProfileRepository
The symbol is the same object (charter.profiles.AgentProfile is doctrine.agent_profiles.profile.AgentProfile). Type annotations, isinstance() checks, and subclass relationships behave identically.
Boundary ratchet allowlist update
Each migration removes one entry from tests/architectural/test_runtime_charter_doctrine_boundary.py::_BASELINE_ALLOWLIST. The test fails on stale allowlist entries (entries whose underlying import has been migrated), so the allowlist stays honest.
Final state: at most 2 documented exceptions in the allowlist (C-004 cap), with the rationale documented in the test docstring.
Failure Modes
| Failure | Behaviour |
|---|---|
| Facade module missing a symbol the runtime expects | ImportError at runtime caller load time. Add the missing symbol to the facade's __all__ + import block. |
| Doctrine renames or removes a symbol the facade re-exports | Facade ImportError at module load. Adjust the doctrine import or fork a stable surface in charter. |
Runtime caller adds a new from doctrine.* import outside the allowlist | test_runtime_charter_doctrine_boundary.py::test_runtime_has_no_new_direct_doctrine_imports fails with the file name + fix recipe. |
| Runtime caller migrates but forgets to update the allowlist | Same test fails: "Stale boundary allowlist entries" — the message tells the maintainer to remove the entry. |
| Runtime caller imports a doctrine symbol the facade doesn't re-export | Add the symbol to the facade's __all__ AND import block. The boundary test stays green. |
| Facade introduces logic beyond re-export | Architectural smell — facades are by-design re-exports. Implementation in WP03 review MUST reject any non-re-export logic landing in a facade module. |
Backward Compatibility Guarantee
- Pre-migration runtime callers continue to work —
doctrine.<sub>modules remain importable and the boundary test allows current direct imports via the baseline allowlist. - Migration is per-file. WP07 migrates the 13 baseline files in sequence; each migration is independently verifiable (per-file tests + the boundary ratchet).
- Charter internal modules that today import
from doctrine.*(e.g.charter.context,charter.template_resolver) are unaffected. The boundary rule applies only tosrc/specify_cli/, not tosrc/charter/. Facades and direct doctrine imports coexist withinsrc/charter/without contradiction. - The 8 layer-rule tests in
tests/architectural/test_layer_rules.pycontinue to pass — the ADR layering ofkernel ← doctrine ← charter ← specify_cliis preserved (charter is allowed to import doctrine; facades re-export from there).
Architectural Test Gates
tests/architectural/test_runtime_charter_doctrine_boundary.py::test_runtime_has_no_new_direct_doctrine_imports(boundary ratchet)tests/architectural/test_layer_rules.py— 8/8 (layer dependency rules)
Optional test (recommended for WP03):
tests/architectural/test_charter_facades_reexport_doctrine.py(NEW): asserts each facade module's__all__symbols resolve to objects equal to theirdoctrine.*counterparts. Pinning this prevents a future PR from silently replacing a facade re-export with a custom wrapper.
Note on the SchemaUtilities Exception
bulk_edit/occurrence_map.py consumes SchemaUtilities from doctrine.shared.schema_utils. Per the boundary audit, this is promoted to kernel/schema_utils.py rather than routed through charter — SchemaUtilities is a generic schema helper that belongs in the lowest layer.
The migration sequence:
1. Add src/kernel/schema_utils.py re-exporting / hosting SchemaUtilities. 2. Update src/specify_cli/bulk_edit/occurrence_map.py to from kernel.schema_utils import SchemaUtilities. 3. Remove src/specify_cli/bulk_edit/occurrence_map.py from the boundary allowlist. 4. (Optional follow-up) Remove the doctrine.shared.schema_utils re-export, leaving kernel/ as the canonical home.
Step 4 is not in scope for this mission; step 1-3 are sufficient to drop the file from the allowlist.
Addendum (2026-06-11, append-only)
MissionStep is retired from the charter/mission_steps.py facade __all__ contract: the last src/ consumer (mission_step_contracts/executor.py) was correctly retyped to MissionStepContractStep during the typing debt pass, leaving the facade entry unimported (dead-symbol gate). The symbol remains available as an explicit PEP 484 re-export (import ... as ...) for direct importers (one test consumer). The live symbol table is tests/architectural/test_charter_facades_reexport_doctrine.py::_FACADE_TABLE.
mission-type-profile.md
Contract — Mission-Type Governance Profile
> Mission: charter-mediated-doctrine-selection-01KRTZCA > Companions: selection-schema.md, activation-registry.md
A mission-type profile is a shipped doctrine-side YAML file at src/doctrine/missions/<type>/governance-profile.yaml declaring the default selections and activations for missions of that type. The charter resolver picks the matching profile based on meta.json mission_type, then unions its declarations with project + org selections.
Input Contract
On-disk shape
File path: src/doctrine/missions/<mission_type>/governance-profile.yaml
Required for every canonical mission type: software-dev, documentation, research, plan.
mission_type: documentation # REQUIRED — must match parent directory name
template_set: documentation-default # optional
selected_directives: []
selected_tactics: []
selected_styleguides: []
selected_toolguides: []
selected_paradigms: []
selected_procedures: []
selected_agent_profiles: []
selected_mission_step_contracts: []
available_tools: []
activations: [] # list[ActivationEntry] — see activation-registry.md
Pydantic shape (charter.mission_type_profiles.MissionTypeProfile)
| Field | Type | Required | Default |
|---|---|---|---|
mission_type | Literal["software-dev", "documentation", "research", "plan"] | yes | — |
template_set | `str \ | None` | no |
selected_<kind> (8 fields) | list[str] | no | [] |
available_tools | list[str] | no | [] |
activations | list[ActivationEntry] | no | [] |
Pydantic model_config = ConfigDict(extra="forbid").
Loader call
from charter.mission_type_profiles import load_profile
profile = load_profile("documentation") # MissionTypeProfile | None
Returns None if the file does not exist.
Resolver call
from charter.mission_type_profiles import resolve_governance
payload = resolve_governance(repo_root, feature_dir)
# payload.text -> rendered governance text for the mission
# payload.mission_type -> the resolved mission_type
The resolver:
1. Reads feature_dir / "meta.json" and extracts mission_type. 2. Calls load_profile(mission_type). 3. If profile is None AND the project charter has no declarations of its own, hard-fails with a message naming the unknown mission_type. 4. Otherwise unions the profile's selected_<kind> and activations with the project's + org's, then renders.
Output Contract
Successful resolution
A GovernancePayload (or CharterContextResult) carrying:
text: str— the rendered governance section the implement prompt embeds. MUST NOT containsoftware-dev-defaultcontent whenmission_type != "software-dev".mission_type: str— equal to the resolvedmeta.json mission_type.
Hard-fail on unknown mission type (FR-011)
>>> resolve_governance(repo_root, feature_with_meta_mission_type="totally-made-up")
Traceback (most recent call last):
...
UnknownMissionTypeError: No governance profile found for mission_type
'totally-made-up' and project charter declared no overrides. Either
add src/doctrine/missions/totally-made-up/governance-profile.yaml or
declare selected_* fields in .kittify/charter/charter.md.
Exact exception class is implementation detail (ValueError subclass acceptable); message MUST contain the unknown mission_type value verbatim (pinned by test_resolve_governance_hard_fails_for_unknown_mission_type).
Failure Modes
| Failure | Behaviour |
|---|---|
Profile file exists but top-level mission_type mismatches directory | test_profile_yaml_declares_its_mission_type fails (architectural gate). Implementation MUST detect at load time and raise. |
meta.json missing mission_type key | Resolver hard-fails with "meta.json missing mission_type key" |
| Profile file YAML invalid | Pydantic ValidationError; loader propagates |
Profile declares unknown selected_<kind> ID | Same as project-level selection: resolver hard-fails at render time |
Profile and project charter declare conflicting template_set | Project wins (project overrides profile for template_set); a warning is emitted |
Profile declares mission_type outside the closed Literal | pydantic.ValidationError at load time |
meta.json mission_type = "software-dev" but no profile file exists | Hard-fail per FR-011 (no silent fallback). Mission is required to ship all 4 profiles, so this represents a regression. |
Backward Compatibility Guarantee
- Behavioural change from today:
software-dev-defaultis no longer the silent fallback for non-software missions. This is the intent of FR-011 and journey 4. - Pre-mission projects whose
meta.jsondeclaresmission_type: software-devcontinue to work — the newsoftware-devprofile ships with content matching today'ssoftware-dev-defaultselections. - Pre-mission projects whose
meta.jsonis missing or carries an unknownmission_typewill hard-fail. Migration note: operators must add amission_typeto existing missions. This is owned by the WP08 / WP09 user-doc work. - The 23-test ATDD suite at
tests/specify_cli/next/test_wp_prompt_governance_contract.pycontinues to pass because every fixture mission hasmission_type: software-devand the newsoftware-devprofile mirrors the prior behaviour.
Architectural Test Gates
tests/missions/test_mission_type_profile_resolution.py::test_mission_type_ships_governance_profile_yaml(parametrised × 4)tests/missions/test_mission_type_profile_resolution.py::test_load_profile_returns_mission_type_profile(parametrised × 4)tests/missions/test_mission_type_profile_resolution.py::test_resolve_governance_picks_documentation_profile_for_documentation_missiontests/missions/test_mission_type_profile_resolution.py::test_resolve_governance_hard_fails_for_unknown_mission_typetests/missions/test_mission_type_profile_resolution.py::test_profile_yaml_declares_its_mission_type(parametrised × 4)
14 parametrised assertions total — all must pass.
selection-schema.md
Contract — Selection Schema
> Mission: charter-mediated-doctrine-selection-01KRTZCA > Companions: activation-registry.md, mission-type-profile.md, charter-facade-modules.md
The selection schema is the global mode activation surface — a list of artifact IDs per artifact kind that the charter declares as always-active. It lives at two layers: the project charter (DoctrineSelectionConfig) and the org charter (OrgCharterPolicy).
Input Contract
Project-level (charter.md → DoctrineSelectionConfig)
Operator-facing surface: a fenced YAML block in .kittify/charter/charter.md:
selected_directives: [<id>, ...] # already supported
selected_tactics: [<id>, ...] # already supported
selected_paradigms: [<id>, ...] # already supported
selected_styleguides: [<id>, ...] # NEW (FR-001)
selected_toolguides: [<id>, ...] # NEW
selected_procedures: [<id>, ...] # NEW
selected_agent_profiles: [<id>, ...] # NEW
selected_mission_step_contracts: [<id>, ...] # NEW
available_tools: [<tool>, ...] # already supported
template_set: <name> # already supported
Parser surface: charter.extractor.Extractor._apply_selection_row. Each new field is read by extending the existing _get_list_value calls.
Org-level (org-charter.yaml → OrgCharterPolicy)
Operator-facing surface: within a doctrine pack's org-charter.yaml:
schema_version: "1"
org_name: <pack-name>
required_directives: [<id>, ...] # already supported
required_tactics: [<id>, ...] # NEW
required_paradigms: [<id>, ...] # NEW
required_styleguides: [<id>, ...] # NEW
required_toolguides: [<id>, ...] # NEW
required_procedures: [<id>, ...] # NEW
required_agent_profiles: [<id>, ...] # NEW
required_mission_step_contracts: [<id>, ...] # NEW
Parser surface: specify_cli.doctrine.org_charter.load_org_charter_policy.
Output Contract
Project-level
governance.yaml round-trips every non-empty selected_<kind> list with values verbatim:
doctrine:
selected_styleguides:
- caveman-comments
Empty lists are omitted from governance.yaml per the _OPTIONAL_EMPTY_OMIT_KEYS allow-list (NFR-005 backward compatibility).
Org-level
apply_org_charter_to_interview(interview_data, repo_root) unions every required_<kind> from the merged org policy into interview_data.selected_<kind>. Non-destructive — existing entries preserved, duplicates dropped.
Return value: list[str] of human-readable messages (one per kind with new entries), example:
> "Pre-selected 1 styleguide(s) from org charter required_styleguides."
Resolver-level (FR-005)
charter.context.build_charter_context(repo_root, action=..., profile=...) produces a payload whose text carries, for each globally-selected artifact:
- The artifact ID
- Either the artifact body inline OR (on token-budget overflow) a fetch + when-doing stanza naming the artifact ID
Org-distributed artifacts additionally carry provenance: either source: org or the pack name in the rendered section.
Failure Modes
| Failure | Behaviour |
|---|---|
Charter selects an unknown ID (selected_styleguides: [does-not-exist]) | Resolver hard-fails with a message naming the unknown ID and the kind. Matches _validate_paradigm_selection semantic from prior work. |
Org pack declares required_<kind> for a non-existent kind | Pydantic rejects with extra="forbid" validation error at parse time. |
Selection field type mismatch (e.g. selected_styleguides: "caveman" as scalar) | Pydantic coerces strings via _get_list_value (comma-split fallback for charter-md path); strict type error for org-charter.yaml. |
selected_<kind> and <kind> (without prefix) both set in the same row | Per _apply_selection_row precedence: the prefixed key wins, the unprefixed key is silently ignored (matching existing selected_directives vs directives semantic). |
| Empty list set explicitly | Treated as "no selection"; omitted from governance.yaml. |
| Field absent | Defaults to []; no behaviour difference from explicit empty. |
Backward Compatibility Guarantee
- A charter that pre-dates this mission and lacks any of the new
selected_<kind>fields parses unchanged. Defaults make every new field empty. - A
governance.yamlproduced by the new extractor with all new fields empty is byte-identical to agovernance.yamlproduced by the old extractor (NFR-005). Guaranteed by extending_OPTIONAL_EMPTY_OMIT_KEYSwith the 5 new keys. - An
org-charter.yamlthat pre-dates this mission and lacks any of the newrequired_<kind>fields parses unchanged. Org policy merge produces the same output. - The 23-test ATDD suite at
tests/specify_cli/next/test_wp_prompt_governance_contract.pycontinues to pass without modification.
Architectural Test Gates
Every change to this contract MUST keep the following tests green:
tests/architectural/test_artifact_selection_completeness.py::test_every_doctrine_kind_has_a_charter_selected_fieldtests/architectural/test_artifact_selection_completeness.py::test_every_doctrine_kind_has_an_org_required_fieldtests/architectural/test_artifact_selection_completeness.py::test_selection_and_required_field_names_are_consistent