Context and Problem Statement
Spec Kitty resolves per-mission-type behaviour — governance, templates, gates,
step contracts — through two parallel trees and three competing governance
surfaces, with a hardcoded software-dev bias woven through the core loop.
The two trees:
src/doctrine/missions/<type>/— the canonical catalogue. It is already the source of truth:MissionTemplateRepository.default_missions_root()(doctrine/missions/repository.py:98-110) resolves here, andtemplate/manager.py:88copies it into the deployed.kittify/missions/.src/specify_cli/missions/<type>/— derived copies that several core readers still bind to directly (dossierManifestRegistry.load_manifest,dossier/manifest.py:163-193;_packaged_missions_dir,mission.py; template resolution,core/project_resolver.py). The two trees have already drifted with no parity guard (see #2628) — and the drift runs specify_cli-ahead: thespecify_clicopies carry live entries (e.g.runtime.charter-lint.decay→lint-report.json, ablocking: falseflag) that the doctrine copies lack. The doctrine-sideexpected-artifacts.yamlreader (repository.py:304) exists but is dead (tests-only).
The three governance surfaces per mission type:
mission_types/<type>.yamlgovernance_refs— inert (no runtime reader; only CLI display) and dangling:software-dev.yaml:11-12referencesDIR-010/DIR-011, which resolve to nothing (real ids areDIRECTIVE_0NN).missions/<type>/governance-profile.yamlselected_*— live viaprompt_builder.py:346, correctly keyed offmeta.json mission_type, hard-fails rather than falling back — but ships empty for all four types.missions/<type>/actions/<action>/index.yaml— live, populated, action-grained, generating DRGscopeedges consumed bycharter context --action.
The concrete defect this creates: the action-scoped path infers mission type as
(template_set or "software-dev-default").removesuffix("-default") in
_load_action_doctrine_bundle (charter/context.py:865, reached from both
build_charter_context:252 and build_charter_context_json:3254). A
documentation, research, or plan mission that never set template_set silently
loads software-dev doctrine. This is exactly the failure #883 exists to close:
non-software missions inheriting test-first/implementation/code-review doctrine by
default. A second incarnation of the same default lives in
specify_cli/mission.py:575 (get_mission_type returns software-dev when
meta.json omits the key), and it feeds the dossier path — so the leak has
two surfaces, and one of them (dossier) sits behind a package boundary the charter
layer may not import.
Underlying all of this is a north-star intent the team has now made explicit: the
src/specify_cli/missions/ tree should eventually be deleted, and
software-dev should become an ordinary built-in doctrine mission type on
equal footing with documentation, research, and plan — feeding step
contracts, gates, and templates from the doctrine, through the charter, into
the execution loop, with no software-dev-default special-casing and no
hardcoded core knowledge of software-dev.
This ADR records the architecture that gets us there and the decision to land mission-type governance as its first proven slice (issue #883). Its claims and work breakdown were pressure-tested by an adversarial four-lens second-opinion review before acceptance; the review's corrections are folded in below.
Decision Drivers
- Single canonical authority (DIRECTIVE_044). Three governance surfaces and two mission trees violate single-source; a fix that adds a fourth surface or a "keep the trees in sync" guard entrenches the split instead of removing it.
- Close the software-dev-default leak by construction (DIRECTIVE_043) — on every surface that carries it, not only the prompt path.
- Layered, mission-aware governance (issue #883, epic #461, #832). Governance
must resolve as
project_charter ⊕ shipped_mission_type ⊕ project_override, keyed offmeta.json mission_type, overridable in #832's shipped → org → project order. - Extend, do not invent (#2628 lesson). Reuse existing doctrine machinery rather than parallel declaration/merge mechanisms — while being honest about where "reuse" still requires real adapter work.
- A widening seam, not a governance dead-end. Doctrine offers, charter activates and customises, core (an FSM "semi-prepared for states & transitions as config") consumes. The resolution seam #883 builds must be the general path templates, gates, and step contracts flow through next.
- The doctrine path goes live; the
specify_clipath is removed — a transparent swap. The dead doctrine read-paths become the live source of truth and the derivedspecify_cli/missions/copies are deleted, with behaviour preserved so the user never notices the swap. Preservation is verified by transitional parity scaffolding that is added at the start of the migration and deleted before merge — not by a surviving parity gate. Enduring verification is behavioural, at the doctrine-module and integration level. - No code kept solely to avoid test churn. Tests are expected to change substantially; shims, compat wrappers, and signatures preserved only to keep old callers green are anti-patterns here. The suite is updated to assert the new behaviour rather than the code contorted to protect the old suite.
- The
MissionTypedoctrine artefact becomes load-bearing. It is enhanced to be the single doctrine answer to "what is this mission type, what steps does it contain, what gates are checked?" — its steps (action sequence / step contracts), its gates (expected-artifacts), and its governance all resolve through it, doctrine → charter → core. It stops being a mostly-inert descriptor.
Considered Options
- Populate
governance_refs(the 2026-07-04 triage's suggestion). - Fill
governance-profile.yamlonly, leaving the action-scoped leak in place. - A unified
doctrine → charter → coreresolution seam — one charter-mediated resolver keyed offmeta.json, feeding both consumers; governance as the first slot of a bundle whose later slots are templates/gates/step contracts;governance_refsretired; the leak closed;software-devdemoted to a peer — chosen, delivered as slice 1 of aspecify_cli/missionsretirement epic. - Full dual-tree merge now.
Decision Outcome
Chosen option: "A unified doctrine → charter → core resolution seam"
(Option 3), delivered as slice 1 of a specify_cli/missions retirement epic.
Decisions recorded
- One charter-mediated resolver.
charter.mission_type_profiles.resolve_mission_type_context(repo_root, *, mission_type=None, feature_dir=None) -> ResolvedMissionTypeis the single entry point for per-mission-type resolution. It resolves the type from an explicit argument →feature_dir/meta.json→ hard error for an unknown type. It never infers the type fromtemplate_setand never defaults tosoftware-dev. It subsumes three functions already keyed by mission type (resolve_action_sequence:266,resolve_mission_type_governance:325,load_profile:175); their live callers (including the 5 FSM-path callers ofresolve_action_sequence) are migrated onto the resolver. A former function is kept only where it is genuinely the right long-term API, never as a wrapper retained solely to avoid editing callers. Their two distinct hard-fail policies — action-sequence validates against the activation set with no escape hatch; governance tolerates an unknown type when a project override exists (:392) — are preserved as explicit branches, not flattened. - Known-type-with-empty-grain is legitimate, not an error.
planships no action indices and an empty type-grain today; a known mission type resolving to an empty governance set is valid. Only an unknown mission type hard-fails. - A bundle sourced from the load-bearing
MissionTypeartefact.ResolvedMissionTypecarries populatedgovernance,action_sequence,expected_artifacts(gates), andstep_contracts(steps) slots in slice 1; thetemplate_setslot is populated in a later slice. All slots are sourced from the doctrineMissionTypeartefact and the files it references (a siblinggovernance-profile.yaml, the action indices, the step contracts, and expected-artifacts), never from thespecify_clicopies. The bundle is how the artefact becomes load-bearing at the core. (Operator decision Q3: slice 1 covers governance + gates + steps; templates follow.) - Ordered, structured output. Governance resolves to a structured
ResolvedGovernancewhose selections are ordered (list-backed with an explicit, tested sort — not sets), so rendering is deterministic. Determinism is a correctness property of the resolver (verified by a doctrine-module test), not a reason to freeze a byte-snapshot of the old path forever. - Both live consumers converge on the resolver. The rewire targets the live
code:
prompt_builder.py:346(Surface B) and_load_action_doctrine_bundle(context.py:865, reached viabuild_charter_context:252andbuild_charter_context_json:3254). The dead_render_action_scoped:1500/_append_action_doctrine_lines:1451pair and its lone test are deleted, not threaded. - Close the leak by threading the real mission type — with per-entry behaviour.
mission_type/feature_diris threaded into the two livebuild_charter_context*entry points; the ~40 test callers are updated to the new signature (not shielded by a compat overload kept solely to avoid editing them). Behaviour is defined per entry, not as a blanket hard-fail: the prompt path suppliesfeature_dir; planning-from-root (charter/context.py:90) requires an explicit--mission-type; the genuinely mission-less callers (dispatchexecutor.py:270, andworkflow.py:675, which already degrades to"Governance: unavailable") get a defined neutral/degrade path, never a silent software-dev load. - A single mission-type canonicalizer (
WP-CANON), across the package boundary. The type key is read raw in at least two places —mission_type_profiles.py:380andspecify_cli/mission.py:575(the latter silently defaulting tosoftware-dev). Becausecharter/may not importspecify_cli(layer rule,test_layer_rules.py), the canonicalizer lives where both may consume it, andget_mission_type's software-dev default is removed. Until this lands, the leak is closed on the prompt/action-doctrine path but remains live on the dossier path; the ADR does not claim otherwise. - The inert
governance_refsfield is replaced, not merely dropped. The hand-authored, danglinggovernance_refs(models.py:186, CLI displaymission_type.py:1486,1505, danglingsoftware-dev.yaml:11-12) is removed — but the intent it gestured at (a mission type declaring its governance) is fulfilled properly by the load-bearing artefact: governance resolves through the charter-mediated resolver keyed by the type, DRG-resolvable, no danglers. (Operator decision Q1: theMissionTypeartefact references the live siblinggovernance-profile.yamlfor type-grain governance — the schema'd, hard-failing surface the resolver already reads — rather than absorbing governance as a field; governance-profile.yaml is not retired.) This removes the inert field and its authored content updates the tests that asserted it (test_mission_type_repository.py:44,test_charter_mission_type_commands.py:242,247,test_activation_filtered_drg.pydocstring, thedrg.py:169comment) to the new behaviour — the tests move with the behaviour, they are not preserved to keep the field alive. - Two governance grains, unioned, URN-normalized disjointness.
actions/<action>/index.yamlis the canonical action-grain (already live + DRG scope edges, config-stem ids);governance-profile.yaml selected_*is the canonical type-grain (and the project-override target). The resolver unions and de-dupes them; a guard forbids the same artifact in both grains, after normalizing both grains to canonical URNs (string equality across003-…vsDIRECTIVE_003vs a URN gives false assurance). This is "two mechanisms with a canonical-URN disjointness guard," not a claim of a single authored surface. - Per-type project override — via the overlay stack, with the real adapter cost
named. The override lives at
.kittify/doctrine/mission_types/<type>/governance-profile.yamland rides the existingdoctrine/base.pybuiltin → org → project overlay loader (base.py:213-311) with its field-merge andDoctrineLayerCollisionWarning. This is not free:base.py:249keys overlays on anidfield and skips files without one, andgovernance-profile.yamlkeys onmission_type. (Operator decision Q2: ride the overlay stack.)WP-OVERRIDEadds anidtoMissionTypeProfile(a coordinatedextra="forbid"change across the built-in files) plus aBaseDoctrineRepository[MissionTypeProfile]subclass, so the override flows through the same builtin → org → project loader every other doctrine artefact uses — giving #832 org-layer support and collision auditing for free once the adapter exists. A bespoke second field-merge in the resolver was rejected to avoid a duplicate merge site.WP-OVERRIDEowns the adapter and ships its own precedence + collision test. - Governance is a sibling of the state list, not a property of it. Keyed off
the same
mission_typethe FSM uses foraction_sequence(decision.py:601,runtime_bridge_composition.py:186), never attached to individual state definitions — so the two config axes stay separable when full "states as config" lands. software-devbecomes a peer doctrine type with no new authoring; its effective governance is preserved so the user does not notice the swap — verified by transitional parity scaffolding (deleted before merge), not locked by a surviving gate. Only documentation, research, and plan gain content. The survivingmeta.json-less fallback tosoftware-devinmission.pyis scoped to template-file selection, not governance, afterWP-CANON.
Corollaries (scope boundaries)
- #883 delivers slice 1, not the whole retirement. In scope:
WP-CANON, the resolver seam, the leak fix,governance_refsretirement, the per-type override (ridden through the overlay stack), authored governance for documentation/research/plan, step-contract resolution through the artefact (Q3), one gated reader migration for gates (see below), and the enforcement gates. Out of scope, left as clean seams: template resolution (template_setslot), enumeration /mission-runtime, removing thesoftware-devfallback inmission.py:466,469, and finally deleting the copy step (template/manager.py:88) and thespecify_cli/missions/tree. - The dossier migration is a gated, isolated swap (operator decision). The dead
doctrine reader goes live and the
specify_clicopies are removed, user-invisibly. It runs as a detachable lane, non-blocking for the enforcement gate: (1) reconcile the driftedexpected-artifacts.yamlupward into the doctrine tree (porting theruntime.charter-lint.decay/lint-report/blockingentries); (2) add a transitional dossier content-parity test proving software-dev's resolved required-artifact set is unchanged across the swap — this scaffold is deleted in this lane's final commit, not merged; (3) build theConfigResult→ExpectedArtifactManifestadapter (repository.get_expected_artifactsreturnsConfigResult, not the model the six consumer sites expect, and there is nofrom_dicttoday) with cache preservation; (4) flip the reader; (5) delete thespecify_clicopies. Never delete before the parity scaffold is green; delete the scaffold once the swap is proven. The enduring dossier-behaviour assertions live as doctrine-module + integration tests against the now-canonical doctrine tree. If reconciliation reveals deep drift, only the final flip defers to slice 2. - The mission-instance addendum layer is specified but not built (a future
meta.json governance_addendumas the highest field-merge layer) — deferred, YAGNI. - The dual-tree split is on the deprecation path, not entrenched. No new
content is added to
specify_cli/missions/; and critically no surviving "keep in sync" or parity ratchet is introduced — a permanent parity gate would entrench the very split this swap removes. Parity is proven transitionally and the scaffold is deleted; the copies are removed, leaving one source. - Testing posture (operator-mandated). Parity/snapshot tests are transitional migration scaffolds — added at the start of a swap, deleted before merge. Enduring tests verify behaviour and shift down to doctrine-module unit tests + integration-level checks (e.g. "a documentation mission resolves documentation governance and gates, not software-dev doctrine"), not byte pins on the removed path. No code — wrapper, shim, or preserved signature — is kept solely to avoid updating tests; the suite is expected to change substantially and is updated to the new behaviour.
Work-package spine (as refined by the second-opinion review)
Four lanes; ~10–12 WPs (Q3 adds the steps lane). Lane A: WP-TIDY (remove the
inert governance_refs + dangling refs; behaviour-preserving; first). Lane B
(critical path): WP-CANON (single mission-type canonicalizer) → WP-SEAM
(resolver + ordered ResolvedGovernance + MissionType-artefact-sourced bundle +
Surface B rewire; a transitional byte-parity scaffold guards the swap and is
deleted in this lane's final commit) → WP-LEAK (rewire the live Surface A
bundle + delete the dead pair + split template_set; red-first behavioural test
first) → WP-OVERRIDE (per-type override ridden through the overlay stack: id
on MissionTypeProfile + BaseDoctrineRepository subclass + precedence/collision
test). Lane C (parallel authoring): WP-CONTENT-DOC / -RESEARCH / -PLAN — split
×3 because "author 3 sets" hides 6–8 net-new DRG-resolvable artifacts
(Divio-type, plain-language, accessibility, publication, freshness-SLA styleguides;
citation discipline; and the already-referenced-but-missing spike-timebox-policy
procedure). Lane D (detachable, non-blocking): WP-GATES-RECONCILE →
WP-GATES-MIGRATE (gates/dossier swap; transitional dossier-parity scaffold,
deleted at the end of the lane) and WP-STEPS-MIGRATE (route step-contract
resolution through the artefact bundle and off any specify_cli path; same
transitional-parity-then-delete discipline). Join: WP-ENFORCE — the enduring
behavioural non-leakage/non-vacuity assertions, authored as doctrine-module +
integration tests, not a surviving parity ratchet. Transitional parity scaffolds
are added at each swap's start and deleted before merge; enduring tests verify
behaviour and move down to the doctrine module + integration level. Tests are a
first-class, per-WP change surface — and the suite is expected to change
substantially rather than be shielded by compat shims.
Consequences
Positive
- The software-dev-default leak becomes impossible by construction on the
action-doctrine path in slice 1, and on the dossier path once
WP-CANON+ the gated migration land. - Governance collapses from three surfaces + a dead field to two URN-normalized grains resolved through one function, overridable via the established overlay stack.
- After slice 1 the core is measurably less dependent on
specify_cli/missions/(the dossier gate reader is migrated behind a parity gate and the derivedexpected-artifacts.yamlcopies are deleted), and a provendoctrine → charter → corepath exists for later slices to widen to templates and gates by filling a reserved slot and repointing one reader each. software-devloses its special status with no behavioural change.
Negative
- The rerouting risks reordering/de-duping software-dev directives; because the existing content-contract suite is substring-based, the swap is guarded by transitional byte- and dossier-parity scaffolds (deleted before merge), with enduring behaviour verified by doctrine-module + integration tests — real test work, and a deliberate rewrite of the suite rather than compat-shielding it.
- The per-type override and the dossier migration each carry named adapter cost
(overlay
idhandling;ConfigResult→ExpectedArtifactManifest); neither is the "free repoint" a naive reading would assume. - Slice 1 populates governance, gates, and steps; the
template_setslot is declared on the artefact but populated in a later slice.
Neutral
- This ADR does not migrate templates or the remaining
specify_cli/missions/readers (enumeration,mission-runtime, the copy step), and does not delete the tree; those are explicit later slices of the retirement epic.
Confirmation
Two kinds of check confirm the mission — and only the first kind survives it.
Enduring (behavioural, doctrine-module + integration): (1) each non-software
mission's unioned (type ⊕ action) resolved set is disjoint from a curated,
URN-normalized software-dev-only denylist, with a non-vacuity twin proving
software-dev does resolve that set (exercised through a shared action name so it
cannot pass vacuously); (2) an unknown mission_type hard-fails on every
resolution path while a known type with an empty grain (e.g. plan) resolves
empty without error; (3) the doctrine MissionType artefact is the sole source of
type/steps/gates/governance and every authored governance id resolves in the DRG;
(4) an integration check exercises a real documentation/research/plan mission and
observes domain-appropriate governance and gates, no software-dev doctrine.
Transitional (parity scaffolds, deleted before merge): software-dev's rendered
governance and its resolved dossier required-artifact set are proven unchanged
across each swap (the lint-report entries reconciled upward first); these scaffolds
verify the swap is user-invisible and are then removed, so no parity ratchet
survives to re-entrench the split. The mission is not confirmed by keeping the old
substring content-suite frozen; that suite is rewritten to the new behaviour.
Decisions resolved with the operator (formerly open)
- Q1 — Governance declaration shape: the
MissionTypeartefact references a siblinggovernance-profile.yamlfor type-grain governance (the live, schema'd, hard-failing surface the resolver already reads), unioned with the action-grainactions/<action>/index.yaml. governance-profile.yaml is kept, not absorbed intomission_types/<type>.yaml. Lower churn; the override rides the profile. - Q2 — Overlay adapter form: ride the
doctrine/base.pyoverlay stack.WP-OVERRIDEadds anidtoMissionTypeProfile+ aBaseDoctrineRepositorysubclass so per-type overrides get builtin → org → project ordering and collision warnings from the shared loader (and #832 org-layer support) rather than a bespoke second merge site. - Q3 — Artefact depth in slice 1: governance + gates + steps. Step-contract
resolution routes through the artefact bundle in slice 1 (
WP-STEPS-MIGRATE); onlytemplate_set(templates) defers to a later slice. - Q4 — ADR meta: Accepted, epic altitude (this record; one ADR governs the retirement arc, with #883 as slice 1).
Genuinely still open (settled inside the named WPs, not architecture-level): the
exact denylist membership for the non-leakage test, and the precise per-entry
degrade behaviour for the mission-less build_charter_context callers.
Pros and Cons of the Options
Option 1 — Populate governance_refs
Pros: matches the field's apparent intent; smallest edit.
Cons: inert (no runtime reader); cannot carry the tactics/styleguides/
procedures/paradigms non-software governance is made of; changes graph.yaml by
nothing, so the freshness gate gives false assurance. Leaves the leak and the
split untouched.
Option 2 — Fill governance-profile.yaml only
Pros: ships non-software content on the live prompt path with no new plumbing.
Cons: leaves the action-scoped and dossier-path leaks in place — the headline acceptance criterion stays unmet — and does nothing toward the north star.
Option 3 — Unified doctrine → charter → core seam (CHOSEN)
Pros: closes the leak on the action path (and the dossier path via WP-CANON +
the gated migration); collapses three surfaces + a dead field to two
URN-normalized grains + one resolver; reuses the overlay stack for layering
(adapter cost named, not hidden); demotes software-dev to a peer with no
behavioural change; leaves a proven, widening seam for later slices.
Cons: larger than a content-only change; centralises resolution (mitigated by a new ordered byte-snapshot gate + dossier parity gate); names real adapter work on the override and dossier surfaces; forward-shapes reserved bundle slots.
Option 4 — Full dual-tree merge now
Pros: reaches the end-state in one mission.
Cons: unbounded blast radius across every specify_cli/missions/ reader in a
single change; high regression risk against software-dev; violates the slice
discipline that lets each reader migration be independently verified.
More Information
- Technical story: #883 (slice 1) under epics #461, #901; related #832, #2628.
- This ADR was refined by an adversarial four-lens second-opinion review
(test-strategy, architecture-refutation, implementer-reality, slice-discipline);
the corrections — dead-code leak anchors, the cross-boundary
mission.py:575default, the non-free overlay adapter, the substring-not-byte content suite, the drifted expected-artifacts trees, and theplanempty-grain policy — are folded into the decisions above. - Governing layer-merge contract reused: 2026-05-16-1 — Doctrine-Layer Merge Semantics.
- Adjacent resolution-seam precedent: 2026-07-08-1 — MissionResolver Port, 2026-06-26-1 — Single-Authority Seam + Call-Site Gate.
- Load-bearing anchors: seam
charter/mission_type_profiles.py:175,266,325,392,440; live consumersruntime/next/prompt_builder.py:346,charter/context.py:252,865,3254; dead pair to deletecharter/context.py:1451,1500; canonicalizer targetscharter/mission_type_profiles.py:380,specify_cli/mission.py:575; overlay stackdoctrine/base.py:213,249,289-311; FSM readsdecision.py:601,runtime_bridge_composition.py:186; retirement targets dossierdossier/manifest.py:163-193→ dead doctrine readerdoctrine/missions/repository.py:304, copy steptemplate/manager.py:88, hardcoded software-devmission.py:466,469; retired fieldmodels.py:186,mission_type.py:1486,1505; dangling refssoftware-dev.yaml:11-12; content suite (substring, not byte)tests/specify_cli/next/test_wp_prompt_governance_contract.py.