Contracts

occurrence-artifact.schema.yaml

JSON Schema (in YAML) for per-WP occurrence-classification artifacts.

Applies to kitty-specs/<mission_slug>/occurrences/WP1.<n>.yaml

Plan reference: D-5 in plan.md

Spec requirement: FR-015

$schema: "https://json-schema.org/draft/2020-12/schema" $id: "spec-kitty://phase1-excision/occurrence-artifact/v1" title: Occurrence-Classification Artifact (per-WP) type: object required:

additionalProperties: false properties: wp_id: type: string pattern: "^WP1\\.[1-3]$" description: Work-package identifier. mission_slug: type: string const: "excise-doctrine-curation-and-inline-references-01KP54J6" requires_merged: type: array items: type: string pattern: "^WP1\\.[1-3]$" description: Preceding WP IDs that must be merged to main before this WP's verifier is allowed to pass. categories: type: array minItems: 1 items: $ref: "#/$defs/OccurrenceCategory" permitted_exceptions: type: array items: $ref: "#/$defs/PermittedException" verifier_command: type: string description: > Shell command to invoke the verifier for this artifact. Canonical: python scripts/verify_occurrences.py kitty-specs/<slug>/occurrences/<wp_id>.yaml.

  • wp_id
  • mission_slug
  • requires_merged
  • categories
  • permitted_exceptions
  • verifier_command

$defs: OccurrenceCategory: type: object required:

additionalProperties: false properties: name: type: string enum:

strings: type: array minItems: 1 items: type: string include_globs: type: array items: type: string description: Repo-root-relative glob patterns in scope for this category. exclude_globs: type: array items: type: string expected_final_count: type: integer minimum: 0 description: Number of hits that should remain at WP completion. Almost always 0. to_change: type: array items: $ref: "#/$defs/FileHit"

  • name
  • strings
  • include_globs
  • exclude_globs
  • expected_final_count
  • to_change
  • import_path
  • symbol_name
  • yaml_key
  • filesystem_path_literal
  • cli_command_name
  • docstring_or_comment
  • template_reference
  • test_identifier

FileHit: type: object required:

additionalProperties: false properties: file: type: string description: Repo-root-relative path. line: type: integer minimum: 1 snippet: type: string description: The matched text for the category's strings. action: type: string enum: [delete, replace, rewrite] description: What the WP does to this occurrence.

  • file
  • line
  • snippet
  • action

PermittedException: type: object required:

additionalProperties: false properties: pattern: type: string description: Glob or literal path/string the verifier must ignore. reason: type: string owner_wp: type: string pattern: "^WP1\\.[1-3]$"

  • pattern
  • reason
  • owner_wp

removed-cli-surface.md

Contract: Removed CLI Surface

Introduced in: WP1.1 Plan reference: plan.md WP1.1 Spec reference: FR-003, Success Criterion 3


Commands removed

The following Typer subcommands and their Typer app are removed in WP1.1. Any invocation after Phase 1 ships must return the standard Typer "unknown command" error with no shim text.

CommandPrevious purposePost-Phase-1 behavior
spec-kitty doctrine curateLaunch interactive curation interview for _proposed/ artifactsUnknown command
spec-kitty doctrine statusPrint proposed vs shipped counts per kindUnknown command
spec-kitty doctrine promotePromote one artifact by ID from _proposed/ to shippedUnknown command
spec-kitty doctrine resetClear in-flight curation session stateUnknown command
spec-kitty doctrine (parent)Typer app entry pointUnknown command — parent group is unregistered

Python API removed

SymbolModuleAction
doctrine_module.app (Typer app)src/specify_cli/cli/commands/doctrine.pyDelete file
app.add_typer(doctrine_module.app, name="doctrine")src/specify_cli/cli/commands/__init__.pyDelete registration line
ProposedArtifactsrc/doctrine/curation/engine.pyDelete module
discover_proposed()src/doctrine/curation/engine.pyDelete module
promote_artifact_to_shipped()src/doctrine/curation/engine.pyDelete module
CurationSession, load_session(), clear_session()src/doctrine/curation/state.pyDelete module
run_curate_session(), promote_single(), get_status_counts()src/doctrine/curation/workflow.pyDelete module
All exports of src/specify_cli/validators/doctrine_curation.pyDelete module
load_doctrine_catalog(include_proposed=...) parametersrc/charter/catalog.pyRemove parameter (WP1.3)

Tests removed

PathAction
tests/doctrine/curation/ (entire directory)Delete in WP1.1
tests/cross_cutting/test_doctrine_curation_unit.pyDelete in WP1.1

Documentation / template references removed

All occurrences of the removed command names in SOURCE documentation must be removed or rewritten. Agent copy directories are out of scope (they re-flow on the next spec-kitty upgrade).

In-scope directories:

  • src/specify_cli/missions/*/command-templates/
  • src/specify_cli/skills/ (if any skill references doctrine curate/promote)
  • src/doctrine/*/README.md
  • src/charter/README.md (the compiler/resolver table entries need updates once WP1.3 removes the resolver)
  • docs/ (if any explanation/how-to references these commands)

Integration test (WP1.1)

# tests/specify_cli/cli/test_doctrine_cli_removed.py (NEW, WP1.1)
from typer.testing import CliRunner
from specify_cli.cli.app import app

def test_doctrine_curate_is_unknown_command():
    runner = CliRunner()
    result = runner.invoke(app, ["doctrine", "curate"])
    assert result.exit_code != 0
    # Typer's unknown-command error references the command name
    assert "doctrine" in (result.output + str(result.exception)).lower()

def test_doctrine_parent_group_is_unregistered():
    runner = CliRunner()
    result = runner.invoke(app, ["doctrine", "--help"])
    assert result.exit_code != 0

This test lives on main after WP1.1 merges and remains there — it's the durable regression gate against reintroduction.

Out of scope

  • Deprecation warnings, shims, aliases, redirects — none. Per spec C-001.
  • Migration of user-local curation session state (.kittify/charter/context-state.json has NO relation to curation state; there is no user data to migrate).

resolve-transitive-refs.contract.md

Contract: resolve_transitive_refs()

Location: src/doctrine/drg/query.py Introduced in: WP03 Replaces: src/charter/reference_resolver.py :: resolve_references_transitively() (deleted in same WP) Plan reference: D-2 in plan.md

> Amendment 2026-04-14: This contract was rewritten after an internal review caught that the > earlier draft used fabricated API surfaces. The live DRG API uses DRGGraph (not MergedGraph), > a typed Relation enum with values requires/suggests/applies/scope/vocabulary/instantiates/replaces/delegates_to > (not uses/references), and the shipped traversal primitive is > walk_edges(graph, start_urns, relations, max_depth). The contract below is the corrected version.


Purpose

Walk the Doctrine Reference Graph (DRG) from a set of starting URNs and return the transitive closure of reachable artifacts, grouped by NodeKind. Functionally equivalent to the legacy resolve_references_transitively() in bucketed return shape so that callers (src/charter/resolver.py, src/charter/compiler.py, and their src/specify_cli/charter/* twins) swap one import line and adapt one call-site pattern.

Relationship to existing DRG primitives

  • doctrine.drg.models.DRGGraph — top-level graph document (shipped or merged). Input type.
  • doctrine.drg.models.NodeKind — enum of 9 node kinds.
  • doctrine.drg.models.Relation — enum of 8 typed relations.
  • doctrine.drg.loader.load_graph(path)DRGGraph.
  • doctrine.drg.loader.merge_layers(shipped, project)DRGGraph — shipped ∪ project overlay. Returns DRGGraph, not a separate MergedGraph type.
  • doctrine.drg.validator.assert_valid(graph: DRGGraph) — rejects dangling edges, duplicate edges, requires cycles. Must have been called before resolve_transitive_refs().
  • doctrine.drg.query.walk_edges(graph, start_urns, relations, max_depth)set[str] — the generic BFS primitive. resolve_transitive_refs() is a thin bucketing wrapper on top of walk_edges.

URN addressing

The DRG uses URNs (<kind>:<id>) throughout — e.g. directive:001-architectural-integrity-standard, tactic:situational-assessment, action:software-dev/implement. Start URNs, visited URNs, and graph nodes are all URN-typed. Legacy ResolvedReferenceGraph used bare IDs (no kind: prefix); the new function bucket-groups by NodeKind and returns bare IDs in each per-kind list so callers need minimal adaptation.

Signature

from dataclasses import dataclass, field
from doctrine.drg.models import DRGGraph, Relation


@dataclass(frozen=True)
class ResolveTransitiveRefsResult:
    """Transitive closure of doctrine artifacts reachable from a set of starting URNs,
    bucketed by NodeKind.

    Field values are bare IDs (URN without the "<kind>:" prefix), preserving the
    legacy ResolvedReferenceGraph field shape for caller compatibility.
    """

    directives: list[str] = field(default_factory=list)
    tactics: list[str] = field(default_factory=list)
    paradigms: list[str] = field(default_factory=list)
    styleguides: list[str] = field(default_factory=list)
    toolguides: list[str] = field(default_factory=list)
    procedures: list[str] = field(default_factory=list)
    agent_profiles: list[str] = field(default_factory=list)
    # Edges whose target URN was not found in the graph (source_urn, target_urn).
    # Always [] when the input graph has passed assert_valid(), since the validator
    # rejects dangling targets at load time.
    unresolved: list[tuple[str, str]] = field(default_factory=list)

    @property
    def is_complete(self) -> bool:
        """True when all referenced artifacts were resolved (symmetric with legacy)."""
        return len(self.unresolved) == 0


def resolve_transitive_refs(
    graph: DRGGraph,
    *,
    start_urns: set[str],
    relations: set[Relation],
    max_depth: int | None = None,
) -> ResolveTransitiveRefsResult:
    """Walk `relations` edges from `start_urns` in `graph`, bucketing reachable nodes by NodeKind.

    A thin wrapper over :func:`walk_edges` that groups the flat result set by
    :class:`doctrine.drg.models.NodeKind` and strips the URN prefix from each
    per-kind list.  Designed as a behavior-equivalent replacement for the legacy
    :func:`charter.reference_resolver.resolve_references_transitively` during
    the Phase 1 cutover.

    Args:
        graph: The DRG graph to walk.  The graph MUST already have passed
            :func:`doctrine.drg.validator.assert_valid`; this function does not
            re-validate.
        start_urns: Seed URNs (e.g. ``"directive:001-architectural-integrity-standard"``).
            URNs whose ``<kind>:<id>`` prefix is not present in the graph are
            recorded in the returned ``unresolved`` field rather than raising.
        relations: Set of :class:`Relation` values to follow.  For legacy
            parity in the charter compiler / resolver, callers pass
            ``{Relation.REQUIRES, Relation.SUGGESTS}``; see R-2 below.
        max_depth: Forwarded to :func:`walk_edges`.  ``None`` = transitive closure.

    Returns:
        :class:`ResolveTransitiveRefsResult` with per-kind bucketed bare IDs.
        Each per-kind list is sorted lexicographically for deterministic output.

    Raises:
        Nothing.  Cycles in `requires` edges are already rejected by
        :func:`assert_valid` at load time.  Cycles in other relation kinds
        are handled by the BFS visited-set in :func:`walk_edges` (no-op
        convergence).
    """

Traversal semantics

  • Uses walk_edges(graph, start_urns, relations, max_depth) verbatim — no custom DFS.
  • BFS with a visited set (cycle-safe convergence by construction of walk_edges).
  • Cycle behavior: requires cycles are rejected at load time by assert_valid. Cycles in suggests / applies / other relations are benign under BFS-with-visited-set. The function does not raise DoctrineResolutionCycleError; the legacy resolver's cycle-raising semantics are relocated to the DRG validator layer.
  • Result bucketing: after walk_edges returns a flat set[str] of URNs, iterate and look up each URN's node in graph (via graph.get_node). Dispatch by NodeKind into the corresponding list. Strip the <kind>: prefix when writing into the bucket.
  • Unresolved: any URN returned by walk_edges that cannot be looked up in graph (shouldn't happen post-assert_valid, but defensively tracked) is appended to unresolved as (source_urn_representative, target_urn). For a validated graph, unresolved is always [].
  • Determinism: sort each per-kind list lexicographically before returning.

Behavioral-equivalence contract

For legacy parity during the cutover (T013/T015 of WP03), callers use {Relation.REQUIRES, Relation.SUGGESTS} — these are the two relation kinds that the Phase 0 migration extractor used when translating inline tactic_refs / paradigm_refs into DRG edges. The equivalence test in tests/doctrine/drg/test_resolve_transitive_refs.py must prove:

# For every shipped directive that carried inline tactic_refs on pre-WP02 main
legacy = resolve_references_transitively([directive_id], doctrine_service)
drg = resolve_transitive_refs(
    graph,
    start_urns={f"directive:{directive_id}"},
    relations={Relation.REQUIRES, Relation.SUGGESTS},
)

assert sorted(legacy.directives) == sorted(drg.directives)
assert sorted(legacy.tactics) == sorted(drg.tactics)
assert sorted(legacy.styleguides) == sorted(drg.styleguides)
assert sorted(legacy.toolguides) == sorted(drg.toolguides)
assert sorted(legacy.procedures) == sorted(drg.procedures)
assert legacy.is_complete == drg.is_complete

The relation set that preserves parity ({REQUIRES, SUGGESTS}) is the authoritative answer from Phase 0's migration extractor. If equivalence fails for any shipped directive, the failure mode is either:

  • The migration extractor did not map every inline reference to a requires or suggests edge (Phase 0 calibration gap — escalate per research.md R-2), OR
  • The caller chose the wrong relation set (fix the caller, not the helper).

Caller-side wiring

Before (pre-WP03, still in src/ during WP02)

# src/charter/resolver.py
from charter.reference_resolver import resolve_references_transitively
...
graph = resolve_references_transitively(starting_directive_ids, doctrine_service)
# graph.directives, graph.tactics, ..., graph.unresolved

After (WP03, T015)

# src/charter/resolver.py
from pathlib import Path
from charter.catalog import resolve_doctrine_root
from doctrine.drg.loader import load_graph, merge_layers
from doctrine.drg.models import Relation
from doctrine.drg.query import resolve_transitive_refs
from doctrine.drg.validator import assert_valid

...

def _load_validated_graph(repo_root: Path):
    """Helper: load shipped+project merged DRG and validate."""
    doctrine_root = resolve_doctrine_root()
    shipped = load_graph(doctrine_root / "graph.yaml")
    project_graph_path = repo_root / ".kittify" / "doctrine" / "graph.yaml"
    project = load_graph(project_graph_path) if project_graph_path.exists() else None
    merged = merge_layers(shipped, project)
    assert_valid(merged)
    return merged


# At each former call site of resolve_references_transitively:
graph_obj = _load_validated_graph(repo_root)
start_urns = {f"directive:{d}" for d in starting_directive_ids}
graph = resolve_transitive_refs(
    graph_obj,
    start_urns=start_urns,
    relations={Relation.REQUIRES, Relation.SUGGESTS},
)
# graph.directives, graph.tactics, ..., graph.unresolved — same field shape as legacy

The _load_validated_graph helper goes into src/charter/_drg_helpers.py (new module) so resolver.py and compiler.py share it. Same pattern in src/specify_cli/charter/_drg_helpers.py.

Non-goals

  • Not a replacement for resolve_context(). That function is driven by an action URN and has its own 4-step algorithm (scope → requires → suggests → vocabulary). It stays as-is.
  • Not a replacement for walk_edges(). That function is the base primitive; resolve_transitive_refs() is the bucketing sugar.
  • Not a DRG redesign. DRGGraph, NodeKind, Relation, merge_layers, assert_valid are unchanged.
  • Not a new cycle-detection mechanism. Cycle detection lives in assert_valid() for requires edges; other relations are allowed to have cycles (BFS converges).

Testing

tests/doctrine/drg/test_resolve_transitive_refs.py must cover:

1. Deterministic output: same input → same output; lists are lexicographically sorted. 2. Empty start set: returns an empty ResolveTransitiveRefsResult. 3. Unknown starting URN: recorded in unresolved as (start_urn, start_urn) or similar; does not raise. 4. Edge-kind filter: a fixture with {REQUIRES: A→B, SUGGESTS: A→C, SCOPE: A→D} starting from {A} with relations={REQUIRES} returns only B (not C or D). 5. Bucketing by kind: given visited URNs of mixed kinds, each ID lands in the correct per-kind list; URN prefix is stripped. 6. Behavioral equivalence: for every shipped directive with inline tactic_refs: on pre-WP02 state (captured as a golden fixture set), the legacy resolve_references_transitively and the new resolve_transitive_refs({REQUIRES, SUGGESTS}) produce identical bucket-by-bucket output. 7. max_depth forwarding: a fixture where max_depth=1 excludes depth-2 nodes; confirms the argument is forwarded correctly.

The legacy tests (tests/charter/test_reference_resolver.py, tests/doctrine/test_cycle_detection.py, tests/doctrine/test_shipped_doctrine_cycle_free.py) are deleted or rehomed only after the new suite is green and manually confirmed to subsume their coverage (per plan D-4, user-adjustment #3). Rehome plan for those tests is in WP03's T018 subtask.

validator-rejection-error.schema.json

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "spec-kitty://phase1-excision/validator-rejection-error/v1", "title": "InlineReferenceRejectedError", "description": "Structured error raised by per-kind doctrine validators when a shipped or project-local artifact YAML contains a forbidden inline reference field. Introduced in WP1.3.", "type": "object", "required": ["file_path", "forbidden_field", "artifact_kind", "migration_hint"], "additionalProperties": false, "properties": { "file_path": { "type": "string", "description": "Absolute path of the offending YAML file." }, "forbidden_field": { "type": "string", "enum": ["tactic_refs", "paradigm_refs", "applies_to"] }, "artifact_kind": { "type": "string", "enum": [ "directive", "tactic", "procedure", "paradigm", "styleguide", "toolguide", "agent_profile" ] }, "migration_hint": { "type": "string", "description": "Fixed text pointing at the graph-edge migration pattern.", "pattern": "^Remove .+ from YAML; add edge \\{source: .+, target: .+, relation: requires\\} to src/doctrine/graph.yaml$" } }, "examples": [ { "file_path": "/path/to/project/.kittify/doctrine/directives/my-directive.directive.yaml", "forbidden_field": "tactic_refs", "artifact_kind": "directive", "migration_hint": "Remove tactic_refs from YAML; add edge {source: directive:my-directive, target: tactic:my-tactic, relation: requires} to src/doctrine/graph.yaml" } ] }