Contracts

activation-delivery.md

Contract — Activation Authority, Delivery Rail, and Reachability

Requirements: FR-009 – FR-014, FR-015, FR-016, FR-017, FR-018 · Criteria: SC-001, SC-002, SC-005, SC-006, SC-007 · Constraints: C-001, C-007, C-008, C-009, NFR-003, NFR-006


1. Activation authority

#Obligation
V-1charter.yaml is the single activation authority. .kittify/config.yaml points at it via charter: — the key that already ships. charter_file: is not the field name and must not be introduced.
V-2The activated_* mirror is removed from config.yaml only after _charter_activated_urns (tests/doctrine/drg/migration/test_extractor_projection.py) is repointed at the resolved source. Removing it first makes that gate's floor assertion fail and its stray guard vacuously true.
V-3An absent activated_<kind> key resolves to [] at every boundary. The three-state contract (absence ⇒ all built-ins) is retired by migration (FR-018).
V-4GovernanceResolution is populated from PackContext / resolve_config_activated_roots. Reading a store directly creates a fifth reader and is a defect.
V-5Identifier normalization between store form (025-boy-scout-rule) and selector form (directive:DIRECTIVE_025) happens at one boundary, lands as a separate declared change, and is excluded from SC-005 (C-009).

SC-007 acceptance: a divergent-mirror fixture where the two stores disagree — the only case that proves which store won. A no-op migration must fail it.


2. Delivery rail

@dataclass
class _ActionDoctrineBundle:
    directive_ids: list[str]
    tactic_ids: list[str]
    styleguide_ids: list[str]
    toolguide_ids: list[str]
    procedure_ids: list[str]      # NEW — its absence made all 18 activated procedures undeliverable
    asset_ids: list[str]          # NEW — per D4; delivered, not activation-gated

Revised 2026-07-28 after the post-plan squad. Two changes are load-bearing: B-1 is now a total gate function (the old equality made asset_ids = [] the conforming implementation), and delivery is modelled per channel (there are two, and both under-deliver).

#Obligation
B-1For a named channel and a named (action, mission_type), the delivered id set for each kind equals gate(kind) ∩ channel_reachable, where gate is a total function over NodeKindactivated(kind) for activation-eligible kinds, ALL for delivered-but-ungated kinds. gate is a column of the same NodeKind-keyed table as the slot, not an enumerated exception. Stating this as activated ∩ reachable is a defect: activated(asset) is by construction, so a uniform reading ships asset_ids = [] forever and passes.
B-1aBecause gate is total, TEMPLATE's exclusion carries a stated reason rather than being ASSET's untreated twin.
B-2Every id the bundle resolves appears in the rendered output. Fixing resolver.py's four [] literals is insufficient: _render_text never reads those fields and the styleguide/toolguide drop is in _render_bootstrap_text.
B-3Delivery happens on every load, not only when first_load is true. This is a control-flow change — the compact returns fire before the bundle is computed.
B-4Every NodeKind in _ACTION_BUNDLE_SLOT_BY_KIND maps to a slot or to a recorded verdict. A bare None is indistinguishable from an oversight.
B-5No truncation. The cap is a defect, not a budget mechanism; budget pressure uses the existing BUDGET_DEFAULT = 32_000 degradation-to-fetch-stanza path. Cardinality carries a shrink-only ceiling of 90 (NFR-003).
B-6Actions outside BOOTSTRAP_ACTIONS (currently {specify, plan, implement, review}) are explicitly ruled in or out. Today they return compact unconditionally.
B-7Activation resolution errors propagate. prompt_builder's except Exception: pass is replaced; the pattern to copy sits immediately above it.

Assets are a third category (D4): delivered through inbound requires/suggests edges from reachable sources, never through an activation list — they remain excluded from _NON_AUGMENTATION_ELIGIBLE_KINDS. The verdict is documented at the slot table.

Caller grain

#Obligation
B-8agent/workflow.py:738 and agent/workflow_executor.py:459 supply the mission-type grain.
B-9prompt_builder is not in scope for grain — it already forwards correctly via build_with_scope. Only its exception policy changes. Removing scope_router.py:71's forwarding currently breaks no test; coverage is added, not assumed.

3. Reachability

There are two delivery channels, each with its own traversal, its own named set, and its own delivery obligation. Reach is never claimed without delivery (C-008).

#Obligation
R-1Action channel reachability is computed by calling doctrine.drg.query.resolve_context. Reimplementing the walk is forbidden — every hand-rolled BFS in this mission's history produced a wrong number, including one that reached the first revision of the plan.
R-2Two named action-channel sets are asserted: d=1 (compact, the steady state, the stricter measure) and d=2 (bootstrap). The measured spread between them is 7 nodes, not the 49 first claimed.
R-3Profile channel reachability is a separately named walk_edges over {requires, specializes_from} from activated agent profiles. It is not a resolve_context seed set: resolve_context step 1 walks scope edges only, and agent_profile nodes have 97 outbound requires, 4 specializes_from, and zero outbound scope — so seeding profiles into it contributes exactly 0 artefacts at every depth.
R-3aThe profile channel carries a delivery obligation (FR-020), not only a measure. Today _render_profile_directives / _render_profile_tactics are the only profile render paths, so a profile-resolved procedure, styleguide, toolguide or asset reaches nobody. procedure:onboard-external-agent-to-pack is the live instance.
R-3bprofile is `str \
R-4Assertions are set membership, not cardinality. A failure names the artefact.
R-5_ACTIVATED_BUT_UNREACHABLE measures edge incidence despite its name; it is renamed in the same work package that lands the real set.
R-6Cascade is not evidence of reachability (C-008). Cascade walks outbound from the activation seed, so any inbound edge satisfies it — including one from an unreachable source.

FR-015 wiring table

Each row must carry the proposed source's own reachability, measured, not asserted:

artefactproposed inbound sourcesource action-reachable?disposition
(enumerated during implementation)measured valuewire / defer

C-007's two-part test decides membership: the relationship is attested in the artefact's own text and the proposed source is itself reachable (or the edge is a scope edge from an action node). Everything failing the second half is deferred to the operator interview.

In scope by consequence of D4: the common-docs cluster. asset:common-docs-structural-lint has four inbound requires edges and all four sources are unreachable — a strongly-connected island no action scopes. Delivering assets without wiring it ships the delivery path while the only shipped asset still fails to arrive.

Destination: edges land in _CURATED_ARTIFACT_EDGES, which mission B2 retires. The handoff is recorded so B2 does not inherit an unknown migration.


4. Reference block

#Obligation
F-1Every emitted pointer resolves. Today all ten are dead — .kittify/charter/_LIBRARY/ does not exist.
F-2Selection is distributed across kinds. _filter_references_for_action is a no-op for every doctrine kind (213 → 213), so filtering is not the fix.
F-3Emitted sets differ across at least two actions.
F-4Both cap sites are addressed: context.py:1169 (live) and :1531 in _render_bootstrap.
F-5_render_bootstrap is deleted — it is called from nowhere in src/, only from tests/charter/test_context.py:815, making it an instance of this mission's own thesis. A live caller found during implementation is a finding, not a reason to keep it.
F-6SC-006 carries a non-vacuity floor — a stated minimum emitted per action for software-dev. Without it the criterion passes over an empty set, which is the current state.

No test pins either cap today (proven by mutation), so F-1 through F-4 need new coverage rather than an adjusted assertion.

asset-resolution.md

Contract — Asset Resolution and Operator Surface

Requirements: FR-003, FR-004, FR-005, FR-006, FR-007, FR-008 · Criteria: SC-003, SC-008 Constraints: C-001, C-002

Library contract

# src/doctrine/assets/repository.py
class AssetRepository(BaseDoctrineRepository[AssetManifest]):
    _schema = AssetManifest
    _glob = "*.asset.yaml"

    def _project_scan(self, root: Path) -> Iterable[Path]: ...   # rglob — base is non-recursive
    def resolve_path(self, asset_id: str) -> Path: ...           # typed error on miss or escape
    def source_path(self, asset_id: str) -> Path: ...            # which manifest declared it

# src/doctrine/service.py
class DoctrineService:
    @property
    def assets(self) -> AssetRepository: ...

Obligations

#Obligation
A-1Resolution merges built-in, organisation and project tiers; the more specific tier wins and the shadowed tier is reported.
A-2Built-in blob paths anchor at the parent of <root>/assets/built-in; org and project paths anchor at the directory itself.
A-3Overlay discovery recurses — an org-pack manifest at assets/<pack>/x.asset.yaml is found.
A-4Containment is enforced by doctrine.drg.org_pack_config.resolve_relative_path_within_root. Traversal and symlink escapes raise a typed error (NFR-006). The specify_cli validator helper must not be imported (C-001).
A-5The project-tier asset directory comes from the single hoisted kind mapping (IC-02), so scaffold and resolver cannot disagree.
A-6Resolution never returns a bare identifier where a caller expects a path, and never returns an empty result where a caller reads it as success.

Operator contract

spec-kitty doctrine asset list [--json]
spec-kitty doctrine asset path <asset-id> [--json]
spec-kitty doctrine new --kind asset <name>          # scaffold parity with `validate`
#Obligation
A-7path prints a resolvable filesystem path and exits 0; an unknown id exits non-zero with the id named.
A-8Both visible paths appear in docs/api/cli-commands.md — a new Typer path trips REF-MISSING otherwise.
A-9No automatic install. Assets are resolved from package data; nothing is written into a consumer repository (C-002).

Acceptance shape

SC-003 runs against a built wheel in a clean environment, with the repository root absent from resolution inputs. In-repo the dev-layout fallback in resolve_doctrine_root() means the criterion cannot fail, so an in-repo test proves nothing.

FR-008 is the proof by first user: tests/docs/test_docs_structural_lint.py:50-53 stops reaching through _REPO_ROOT and goes through the resolution surface.

SC-008 is doc-as-test: the published how-to's commands execute against a fresh project. Today docs/doctrine/create-a-doctrine-artifact.md contains "asset" zero times while review-gates.md cites it as the asset how-to, and doctrine-kinds.md claims no built-in assets exist.

Interaction with delivery

Per D4 assets are also delivered in the action bundle — see activation-delivery.md. Resolution alone would leave assets reachable only by someone who already knows the identifier, which is the dead end this mission exists to close.

writer-registry.md

Contract — Graph Writer Registry

Requirements: FR-001, FR-002 · Criteria: SC-004, SC-009 · Constraints: C-001, C-006, C-010

Revised 2026-07-28 after the post-plan squad proved the single-Protocol form unbuildable.

Contract

Every site that persists DRGNode / DRGEdge / DRGGraph state is a registry member. The completeness tests iterate the registry; they carry no hand-written list of writers.

The registry has three shapes, because the five members are three different kinds of thing.

# Hosted in src/specify_cli/ — the top layer. See "Hosting" below.

class MappingWriter(Protocol):
    name: str
    def node_to_mapping(self, node: DRGNode) -> dict[str, object]: ...
    def edge_to_mapping(self, edge: DRGEdge) -> dict[str, object]: ...

class DocumentWriter(Protocol):
    name: str
    def document_to_mapping(self, graph: DRGGraph) -> dict[str, object]: ...

class ModelBridge(Protocol):
    name: str
    def bridge(self, fragment_edge: object, /, **ctx: object) -> DRGEdge | None: ...
MemberShapeNote
extractor._node_to_dict / _edge_to_dictMappingWriterThe derived reference implementation
rewrite_opposed_by._node_to_dict / _edge_to_dictMappingWriterAlready guarded — needs membership, not a new test
project_drg._serialize_graphMappingWriter after extractionCurrently (DRGGraph) -> str with the dicts built inline in two loops. Extracting them into _node_to_dict/_edge_to_dict is a prerequisite task, not a registry join
_dump_graph_documentDocumentWriterOwns the five document-level keys; delegates node/edge to the extractor pair. This is also what makes W-4 meaningful
_bridge_org_edge_to_drg_edgeModelBridgeConstructs a DRGEdge from an org-fragment edge. Its input is not a DRGEdge and its output is not a mapping — its defect is model→model field coverage, not serialization

Obligations

#Obligation
W-1For every MappingWriter and a fully-populated instance: set(w.edge_to_mapping(e)) == set(DRGEdge.model_fields) - _FIELDS_WITHHELD_FROM_GRAPH_OUTPUT. Same for nodes.
W-1aEmpty values are the hole W-1 alone does not close. _render_for_yaml returns None for None and for an empty list, and _model_to_dict drops the key — so a novel `impacts: str \
W-2For every DocumentWriter: emitted keys equal set(DRGGraph.model_fields) less any declared withholding.
W-3For every ModelBridge: every DRGEdge / DRGNode field the fragment schema can express is set on the minted model.
W-4DRGGraph declares model_config = ConfigDict(extra="forbid"), matching DRGNode and DRGEdge. This is a consumer-facing read-path break — an org-pack graph document with an unknown top-level key goes from silently-accepted to a hard load failure. It needs a typed error and a named diagnostic, and it should not ride the lane that lands first and alone.
W-5Failures name the member and the missing field, never a bare count mismatch.

Hosting

The registry lives in src/specify_cli/, the top layer.

A Final[tuple[...]] in doctrine naming charter and specify_cli members reds tests/architectural/test_layer_rules.py:282 and :293; charter reds :311. Only the top layer can statically hold all five members.

src/charter/drg.py already exists (532 lines, 24-entry __all__) and rewrite_opposed_by.py:97 already imports DRGEdge, DRGGraph, DRGNode, NodeKind, Relation through it. No facade is created; the derived helper is added to an existing export surface.

Import-time self-registration is the wrong escape. It makes membership depend on import order, which re-opens the exact "a writer that never joins is invisible" gap this contract concedes below.

Acceptance test shape (SC-004, SC-009)

The mutation is the fixture, not a manual edit (C-006). Verified buildable: DRGNode/DRGEdge are not frozen (only extra="forbid"), attribute injection and extra kwargs are both rejected, so subclassing is the only route — and DRGGraph does not re-validate or coerce, so a mutated node/edge survives into a graph and reaches the document writer.

def test_every_mapping_writer_preserves_an_unknown_field():
    edge = _EdgeWithNovelField(...)          # subclass; every field populated (W-1)
    for writer in MAPPING_WRITERS:
        emitted = set(writer.edge_to_mapping(edge))
        assert emitted == _expected_keys(), f"{writer.name} dropped {_expected_keys() - emitted}"

B1 extends this test rather than re-deriving it.

Known gap, recorded

A writer that never joins the registry is invisible to it. The compensating control — an AST gate failing on dict-literal construction whose keys are a subset of model_fields, outside the derived helper — is deferred and currently has no owner or issue number, which the plan flags as needing a home. Note that the repository already runs precisely this mechanism for kind maps (tests/doctrine/drg/test_kind_mapping_totality.py) with an audited exemption list and tests proving the exemption does real work — so the reason originally given for rejecting it is refuted by prior art in this same repo.

Non-obligations

completeness.

  • The registry does not change when writers run, or the shape of the files they produce beyond field