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.
| Command | Previous purpose | Post-Phase-1 behavior |
|---|---|---|
spec-kitty doctrine curate | Launch interactive curation interview for _proposed/ artifacts | Unknown command |
spec-kitty doctrine status | Print proposed vs shipped counts per kind | Unknown command |
spec-kitty doctrine promote | Promote one artifact by ID from _proposed/ to shipped | Unknown command |
spec-kitty doctrine reset | Clear in-flight curation session state | Unknown command |
spec-kitty doctrine (parent) | Typer app entry point | Unknown command — parent group is unregistered |
Python API removed
| Symbol | Module | Action |
|---|---|---|
doctrine_module.app (Typer app) | src/specify_cli/cli/commands/doctrine.py | Delete file |
app.add_typer(doctrine_module.app, name="doctrine") | src/specify_cli/cli/commands/__init__.py | Delete registration line |
ProposedArtifact | src/doctrine/curation/engine.py | Delete module |
discover_proposed() | src/doctrine/curation/engine.py | Delete module |
promote_artifact_to_shipped() | src/doctrine/curation/engine.py | Delete module |
CurationSession, load_session(), clear_session() | src/doctrine/curation/state.py | Delete module |
run_curate_session(), promote_single(), get_status_counts() | src/doctrine/curation/workflow.py | Delete module |
All exports of src/specify_cli/validators/doctrine_curation.py | — | Delete module |
load_doctrine_catalog(include_proposed=...) parameter | src/charter/catalog.py | Remove parameter (WP1.3) |
Tests removed
| Path | Action |
|---|---|
tests/doctrine/curation/ (entire directory) | Delete in WP1.1 |
tests/cross_cutting/test_doctrine_curation_unit.py | Delete 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.mdsrc/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.jsonhas 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. ReturnsDRGGraph, not a separateMergedGraphtype.doctrine.drg.validator.assert_valid(graph: DRGGraph)— rejects dangling edges, duplicate edges,requirescycles. Must have been called beforeresolve_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 ofwalk_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:
requirescycles are rejected at load time byassert_valid. Cycles insuggests/applies/ other relations are benign under BFS-with-visited-set. The function does not raiseDoctrineResolutionCycleError; the legacy resolver's cycle-raising semantics are relocated to the DRG validator layer. - Result bucketing: after
walk_edgesreturns a flatset[str]of URNs, iterate and look up each URN's node ingraph(viagraph.get_node). Dispatch byNodeKindinto the corresponding list. Strip the<kind>:prefix when writing into the bucket. - Unresolved: any URN returned by
walk_edgesthat cannot be looked up ingraph(shouldn't happen post-assert_valid, but defensively tracked) is appended tounresolvedas(source_urn_representative, target_urn). For a validated graph,unresolvedis 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
requiresorsuggestsedge (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_validare unchanged. - Not a new cycle-detection mechanism. Cycle detection lives in
assert_valid()forrequiresedges; 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" } ] }