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)

FieldTypeRequiredNotes
activation_contextdict[str, str]yesKeys mission_type and action are recognised; either or both may be absent (= wildcard)
doctrine_pack_idstryesOne of: project, built-in, or a configured org-pack name
artifact_idstryesMust 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_type slot: entry.activation_context.get("mission_type") is absent, "generic", "any", or == current_mission_type.
  • action slot: same with action.
  • 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

FailureBehaviour
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 DoctrineServicepydantic.ValidationError at parse time (Literal validation)
Empty activations: listNo stanzas emitted; no error
activations: absent from charterTreated 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.py continues to pass — it never asserted on context-scoped stanzas.
  • governance.yaml adds an activations: 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_fields
  • tests/architectural/test_activation_registry_schema.py::test_activation_context_mission_type_vocabulary_is_closed
  • tests/architectural/test_activation_registry_schema.py::test_activation_context_action_vocabulary_is_closed
  • tests/architectural/test_activation_registry_schema.py::test_activation_entry_validates_membership_of_vocabulary
  • tests/architectural/test_trigger_registry_coverage.py::test_every_declared_trigger_is_in_the_registered_set
  • tests/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

FacadeRe-exported symbols
charter/profiles.pyAgentProfile, AgentProfileRepository, Role, DEFAULT_ROLE_CAPABILITIES
charter/mission_steps.pyMissionStep, MissionStepContract, MissionStepContractRepository
charter/drg.pyDRGEdge, DRGGraph, DRGNode, Relation, NodeKind, load_graph, merge_layers, resolve_context, ResolvedContext
charter/primitives.pyPrimitiveExecutionContext, execute_with_glossary
charter/resolution.pyResolutionResult, ResolutionTier
charter/versioning.pycheck_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

FailureBehaviour
Facade module missing a symbol the runtime expectsImportError 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-exportsFacade 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 allowlisttest_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 allowlistSame 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-exportAdd the symbol to the facade's __all__ AND import block. The boundary test stays green.
Facade introduces logic beyond re-exportArchitectural 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 to src/specify_cli/, not to src/charter/. Facades and direct doctrine imports coexist within src/charter/ without contradiction.
  • The 8 layer-rule tests in tests/architectural/test_layer_rules.py continue to pass — the ADR layering of kernel ← doctrine ← charter ← specify_cli is 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 their doctrine.* 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)

FieldTypeRequiredDefault
mission_typeLiteral["software-dev", "documentation", "research", "plan"]yes
template_set`str \None`no
selected_<kind> (8 fields)list[str]no[]
available_toolslist[str]no[]
activationslist[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 contain software-dev-default content when mission_type != "software-dev".
  • mission_type: str — equal to the resolved meta.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

FailureBehaviour
Profile file exists but top-level mission_type mismatches directorytest_profile_yaml_declares_its_mission_type fails (architectural gate). Implementation MUST detect at load time and raise.
meta.json missing mission_type keyResolver hard-fails with "meta.json missing mission_type key"
Profile file YAML invalidPydantic ValidationError; loader propagates
Profile declares unknown selected_<kind> IDSame as project-level selection: resolver hard-fails at render time
Profile and project charter declare conflicting template_setProject wins (project overrides profile for template_set); a warning is emitted
Profile declares mission_type outside the closed Literalpydantic.ValidationError at load time
meta.json mission_type = "software-dev" but no profile file existsHard-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-default is no longer the silent fallback for non-software missions. This is the intent of FR-011 and journey 4.
  • Pre-mission projects whose meta.json declares mission_type: software-dev continue to work — the new software-dev profile ships with content matching today's software-dev-default selections.
  • Pre-mission projects whose meta.json is missing or carries an unknown mission_type will hard-fail. Migration note: operators must add a mission_type to 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.py continues to pass because every fixture mission has mission_type: software-dev and the new software-dev profile 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_mission
  • tests/missions/test_mission_type_profile_resolution.py::test_resolve_governance_hard_fails_for_unknown_mission_type
  • tests/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.yamlOrgCharterPolicy)

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

FailureBehaviour
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 kindPydantic 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 rowPer _apply_selection_row precedence: the prefixed key wins, the unprefixed key is silently ignored (matching existing selected_directives vs directives semantic).
Empty list set explicitlyTreated as "no selection"; omitted from governance.yaml.
Field absentDefaults 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.yaml produced by the new extractor with all new fields empty is byte-identical to a governance.yaml produced by the old extractor (NFR-005). Guaranteed by extending _OPTIONAL_EMPTY_OMIT_KEYS with the 5 new keys.
  • An org-charter.yaml that pre-dates this mission and lacks any of the new required_<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.py continues 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_field
  • tests/architectural/test_artifact_selection_completeness.py::test_every_doctrine_kind_has_an_org_required_field
  • tests/architectural/test_artifact_selection_completeness.py::test_selection_and_required_field_names_are_consistent