Repo-Coupling Audit of Built-In Doctrine
Rule being audited — review-gates.md, "Shippable doctrine":
built-in doctrine under src/doctrine/**/built-in/ MUST be valid and actionable
in a consumer repository that activated the pack but has no access to the
spec-kitty source tree, CI, or tooling.
Operator ruling, 2026-07-28. Built-in doctrine is repository-agnostic.
Doctrine that pertains only to this repository belongs in a committed
non-built-in tier. This is a customer-facing bug class, not hygiene — the
asset doctrine kind was created as its direct consequence, and WP-transition
gates hit the same class before that.
This page is the classification that follow-on missions should work from, so each of them inherits a decision instead of re-deriving one (and getting it differently).
1. Counts, and why the raw number is a trap
The documented quick-check, run on this branch:
$ grep -rEn 'scripts/|\.github/|src/specify_cli|tests/' src/doctrine/*/built-in/ | wc -l
84
$ grep -rlE 'scripts/|\.github/|src/specify_cli|tests/' src/doctrine/*/built-in/ | wc -l
23
84 hits across 23 files — and most are not defects. review-gates.md says
so explicitly: "Prose that merely describes an internal practice … is fine; a
path presented as a live gate or resolvable artifact is not." Reporting 84 as a
defect count is the first mistake available here.
| Class | Hits | Files | Disposition |
|---|---|---|---|
| (a) presented as a live gate or resolvable artifact | 52 | 7 | the bug — reword or relocate |
| (b) prose describing an internal practice | 31 | 15 | leave alone |
| (b′) regex false positive | 1 | 1 | leave alone |
| (c) legitimately repo-local artefact | 0 | 0 | — |
Class (c) being empty is the finding. Everything under
src/doctrine/**/built-in/ ships. "It is fine, it is repo-local" is never an
available defence in that tree — if an artefact is genuinely repo-local it is in
the wrong directory by definition.
2. Class (a) — the offenders
Four are relocations, not rewords: strip the repo-local paths and almost nothing consumer-meaningful remains.
procedures/built-in/post-merge-arch-gate-adjudication.procedure.yaml(8 hits) — the procedure steps themselves arePWHEADLESS=1 pytest tests/architectural/ -q,pytest tests/integration/,pytest tests/git/. Not prose:steps[].descriptionwith fenced commands the actor is told to run. A consumer activating this procedure cannot execute step 1.toolguides/built-in/TERMINOLOGY_GUARD.md(6 hits) — the whole artefact is a runbook forpytest tests/architectural/test_no_legacy_terminology.py, including a## Commandsection. No consumer-resolvable content at all. (Its manifestterminology-guard.toolguide.yamlreturns 0 hits under this pattern — its three aresrc/doctrine/prefixes, so they belong to the §4 class, not this one. The 52/7 total is correct precisely because the manifest contributes nothing here.)tactics/built-in/architectural-gate-non-vacuity.tactic.yaml(12 hits) — names one of our test files as "the current live exemplar", ships a literal_ALLOWLISTcontainingsrc/specify_cli/git/protection_policy.py, and prescribes an action item against our repository.styleguides/built-in/tiered-standards.styleguide.yaml(10 hits) — the tier registry is the payload: a map from our package paths to rigour tiers. A consumer receives rules for packages they do not have, and none for the ones they do.
Three are rewords:
tactics/built-in/frozen-baseline-shrink-only-ratchet.tactic.yaml— statestests/architectural/_baselines.yamlas the canonical location, for the consumer.glossary_packs/built-in/spec-kitty-core.glossary-pack.yaml— definitions resolve terms by pointing atsrc/specify_cli/...::symbol.tactics/built-in/code-patterns/chain-of-responsibility-rule-pipeline.tactic.yaml—Examples in tree:entries presented throughreferencesas lookups.
Judgement-call boundary, recorded honestly. Three artefacts sit on the
(a)/(b) line — the two above plus
secure-regex-catastrophic-backtracking — all of the form "concrete provenance
citation of our own tree". They are split on whether the path is offered as a
lookup (a) or as history (b). A reviewer could reasonably move any of them,
shifting the (a) count by up to 12 hits. The four MAJOR entries are not in
doubt.
3. Class (b) — do not "fix" these
Generic tests/** globs in seven agent profiles'
specialization-context.file-patterns; generic commands
(ruff check src/ tests/, uv run pytest tests/ -q); code illustrations in
testing-principles.styleguide.yaml; a path-syntax illustration in
POWERSHELL_SYNTAX.md. tests/ is a universal convention, not our path.
(b′) is occurrence-classification-workflow.tactic.yaml, whose text is
"CLI commands, user-facing strings, tests/fixtures, logs/telemetry" — a
category list the regex reads as a path.
4. The quick-check has a blind spot larger than what it finds
review-gates.md greps src/specify_cli only. Widening to the other source
roots:
$ grep -rEn 'src/doctrine/|src/mission_runtime|src/charter/|src/runtime/|src/glossary/' \
src/doctrine/*/built-in/ | wc -l
114
Mostly guide_path: and references: fields carrying the source-tree prefix
src/doctrine/…. The content ships; the prefix does not — an installed
consumer has doctrine/ in site-packages, never src/doctrine/. Same defect
class, larger volume, and invisible to the check written to catch it.
This class is a mechanical prefix strip and should be one bulk-edit mission under the occurrence-classification guardrail — deliberately not mixed into the reference slice.
5. A gate currently REQUIRES one of these references
The load-bearing constraint for any cleanup, and the reason a naive fix cannot merge.
tests/architectural/test_no_dead_doctrine_paths.py:456 asserts exact
equality on a one-element list:
excluded = sorted((site.path, site.text) for site in scan.forbidding_mentions)
assert excluded == [
("src/doctrine/agent_profiles/built-in/doctrine-daphne.agent.yaml",
"src/doctrine/graph.yaml")
], "A2's effect set moved -- widening it needs a reason: ..."
Verified by mutation on this branch — removing the repo-local path from
doctrine-daphne.agent.yaml, i.e. doing exactly what the ruling asks:
FAILED test_forbidding_mention_would_false_red_without_its_discriminator
assert [] == [('src/doctrine/agent_profiles/built-in/doctrine-daphne.agent.yaml',
'src/doctrine/graph.yaml')]
The shippable-doctrine rule and this gate contradict each other, and the gate wins. It does not merely tolerate the repo-local reference — it requires it, by file path and by matched text. Any daphne cleanup must update this gate in the same commit or it cannot merge.
A second instance of the same shape: Gate C
(test_every_built_in_doctrine_cross_link_resolves) requires every relative
markdown link under src/doctrine/ to resolve on our disk, which cements
links that escape the package entirely (e.g.
src/doctrine/templates/diagrams/README.md → ../../../../docs/architecture/…).
Every one of those is dangling in a consumer repo, and the gate's contract is
that they must never be removed.
6. What remains repo-local in doctrine-daphne after the 2026-07-28 reword
PR #3007 reworded two named paths out of the profile. Three residues survive:
- The shipped-pack census ("16 built-in profiles carry 50
operating-proceduresdeclarations; exactly 1 is backed by an edge…") — a measurement of our pack, frozen into shipped doctrine. In a consumer repo with its own org pack the numbers are simply false, and the actionable rule ("treat a named operating procedure as unwired until the edge is confirmed") does not need them. This survived the reword because it names no path. src/doctrine/graph.yaml— a source-tree path, invisible to the documented grep, and pinned by the gate in §5.- PR-history narration — the
applies-edge story is spec-kitty provenance; the universal half is "no traversal followsapplies".
Everything else in the profile is genuinely universal and should stay exactly
where it is: the canonical path shape and its silent-non-load consequence, the
frozen-legacy references: rule, the per-kind fragment model, the
doctrine-offers / charter-activates / runtime-consumes split, the inbound-edge
reachability rule, and the golden-count ledger duty. The slice is a split, not
a relocation.
7. Where a relocated artefact can actually go
Established while auditing; load-bearing for every follow-on.
- The project tier cannot host
agent_profileorasset.DoctrineService._PROJECT_KIND_DIRScovers four kinds (directive/tactic/styleguide/procedure), and project-tier artefacts reach the DRG only through synthesis, where_KIND_TO_NODE_KINDmaps three —assetandagent_profileresolve toNone, so the caller skips the node and its edges. Relocating a profile there yields an artefact that loads, validates, and is not a graph node — the defect class this programme exists to close. .kittify/doctrine/is already a parallel surface. Of its 14 committed files, 5 are.mdwritten by the retrospective synthesizer (the rest are.provenance/andoverlays/YAML); the repositories glob*.<kind>.yaml.DoctrineServicereads none of them. Onlyoverlays/is consumed, by the calibration walker.- The org-pack tier does admit assets.
_ORG_DRG_KIND_ALIASESincludes"assets", the pack validator validates any pack'sassets/tree, and org packs register from a committed in-repolocal_pathresolved against the repo root. This is the only seam that admits an asset today. - There is no asset resolution or install path at all.
DoctrineServiceexposes nine repositories and assets is not one; resolution returns asset ids, never paths. The single shipped asset is consumed by a test that hard-codes its repo-relative path andimportlib-loads it. Until that closes, "share via the pack system" means "share a filesystem convention".
8. Recommended order
- Fix the quick-check (§4) so follow-ons stop inheriting the blind spot.
- ADR: destination tier — in-repo org pack vs a separate internal pack
(#3023). Both mean register a non-
built-inpack root; they differ only on location. ADR 2026-07-26-2 §6 already records the post-extraction layout, so this is an amendment. - Resolve the gate contradiction (§5) — highest priority, because the gate currently enforces the bug.
- The daphne split (§6) as the reference slice, landing the gate change in lockstep.
- The four MAJOR relocations (§2), individually.
- The prefix class (§4) as one bulk-edit mission.
Sequence by severity, not by grep count. And split
test_no_dead_doctrine_paths.py before extracting anything: its Gate A is a
src/-wide CLI gate wearing a doctrine name, so moving the module wholesale
would silently drop real coverage of spec-kitty's own code.