Status: Accepted
Date: 2026-07-26
Deciders: Operator (Stijn Dejongh), who identified the drift directly: "the doctrine schema and assets predate the DRG canonical system; the yaml files often contain references to other artefacts that are to be migrated to DRG edges/relationships." Recorded during the #2935 doctrine-authoring session, after the finding invalidated an in-flight "fix".
Technical Story: raised while authoring the test-quality doctrine series
(#2935) on PR
#2936; triggered by an
asset-support finding on #2918.
Continues the excision begun by mission excise-doctrine-curation-and-inline-references-01KP54J6.
Sibling ADRs: 2026-07-18-1
(the authoring-authority + extractor-retirement pattern this ADR applies to the doctrine layer),
2026-07-22-1 (content-vs-relationship
principle), 2026-07-21-1 (tension relations).
Context and Problem Statement
The doctrine artifact schemas (src/doctrine/schemas/*.schema.yaml) and the assets
layout predate the Doctrine Relationship Graph (DRG). They were designed when an artefact
declared its own relationships inline, in its own YAML body. The DRG later introduced typed,
traversable edges as the way relationships are modelled. Both surfaces are still live, and
the doctrine layer therefore has two authorities for one concept.
Surface A — inline references: blocks. 139 built-in artifact YAMLs carry a
references: list of {type, id, when?} entries. Four schemas (directive, paradigm,
procedure, tactic) bless this shape via a <kind>_reference definition whose type
property carries a hand-copied enum of doctrine kinds.
Surface B — DRG edges. 14 per-kind *.graph.yaml fragments under src/doctrine/
holding ~774 typed edges over the Relation vocabulary
(requires/suggests/scope/refines/rejects/in_tension_with/…).
Crucially, B is not independent of A: every fragment is stamped
generated_by: drg-migration-v1, and drg/migration/extractor.py derives edges by walking
the inline references: blocks (the reference passes at lines ~387–636), supplemented by
drg/migration/hand_authored_overlay.py for edges that have no inline source. So the inline
blocks remain the de-facto authoring surface for most of the graph, and the graph is largely a
build output of the surface it was meant to replace.
A partial excision already landed: mission
excise-doctrine-curation-and-inline-references-01KP54J6 hard-rejects the scalar inline
ref fields (tactic_refs, styleguide_refs, …) with InlineReferenceRejectedError. The
structured references: block survived that pass — the residue this ADR addresses.
Four consequences of the split were observed empirically, not theorised:
- The duplicated kind enum has already drifted. All four schema copies omit
assetandglossary_pack; thetacticcopy has drifted further still, additionally missingagent_profileandmission_step_contract. SoArtifactKind.ASSET— a first-class kind with shipped built-in artifacts — cannot be named from an artefact body at all. On #2918 this read as an "asset support is not end-to-end" bug. It is not: it is the legacy surface correctly refusing to grow. Widening the enum would have entrenched the second authority, and was abandoned mid-change once the operator named the drift. - The migration instruction the system prints is unfollowable.
InlineReferenceRejectedErrortells operators to "add edge {source, target, relation: requires} tosrc/doctrine/graph.yaml" — but mission #2680 sharded that monolith into per-kind fragments andsrc/doctrine/graph.yamlno longer exists. 18 files repeat the stale path —.pymodels, errors and exceptions, four READMEs, a shipped tactic, and the brownfield paradigm's own notes. The one piece of guidance pointing from the legacy surface to the canonical one points at nothing. (An earlier revision said "ten"; measured, it is 18. Note some mentions are deliberate — this ADR and thedoctrine-daphneprofile name the path in order to forbid it — so a naive grep gate would false-red, and.kittify/doctrine/graph.yamlis a live project-tier path that must not be swept in.) - The inline shape cannot express the modern vocabulary.
references:carries onlytype/id/when— there is no relation field. Every relation richer than "related to" (refines,rejects,in_tension_with,reconciles_tension) is inexpressible inline and must be hand-authored as an edge. Measured on the shipped graph:suggests332,requires259,scope157,rejects8,instantiates8,specializes_from4,reconciles_tension3,in_tension_with2,applies1, andrefines0 — first-class since #2079, never yet exercised in a built-in fragment. - Relationship semantics get flattened on the way in. Because an inline entry has no
relation, the extractor must infer one per kind (e.g.
_relation_for_procedure_reference). The authored intent and the resulting edge type are decided in different files.
Decision Drivers
- Single canonical authority (charter governing principle) — one owning source per surface. Two relationship authorities is the exact smell the principle names, and "reconcile/extend the existing authority" means extending the DRG, not the residue.
- Content vs relationship (ADR 2026-07-22-1) — an artefact's YAML body owns its content; relationships between artefacts are edges. The inline block is a body field doing a relationship's job.
- No legacy compat branches in resolvers (ADR 2026-07-01-1) — the answer to a legacy surface is retirement on a migration path, not indefinite parity.
- Authoring-authority + extractor retirement (ADR 2026-07-18-1) — the doctrine layer faces structurally the same call the charter layer already made: stop deriving canonical structure from a legacy authoring surface, and retire the extractor that does it.
- Intent dominates over capability — fix the tooling to serve the intended model; do not relabel artefacts, or widen a deprecated schema, to fit what currently works.
Considered Options
- Option 1 (chosen) — Pin DRG edges as the sole relationship authority; fix the stale migration hint; and migrate all 559 relationship entries now, retiring all edge production behind a structured-diff proof (see the Amendment).
- Option 2 — Keep both surfaces, and bring the inline surface to full parity (widen the
four kind enums, add a
relation:field toreferences:, keep deriving the graph). - Option 3 (initially chosen, then withdrawn) — Pin the direction and freeze the inline surface, but defer the migration and the pass retirement to follow-up work.
- Option 4 — Declare the inline blocks canonical and treat the fragments as pure build output (invert the other way).
Decision Outcome
Chosen option: 1.
The end state this ADR pins:
- DRG edges are the single canonical authority for artefact→artefact relationships.
Relation semantics live in
doctrine.drg.models.Relation. No schema, body field, or sidecar may declare a second relationship vocabulary. - The inline
references:block stops carrying artefact relationships. Every one of the 559 relationship-bearing entries migrates to a hand-authored edge in the per-kind*.graph.yamlfragment, in this mission. The field survives only for raw non-artefact material (14 entries), and is closed to growth in every case: no new artefact adds a relationship entry. - The four
<kind>_reference.typeenums are frozen, not fixed. A kind that cannot be named inline is correct behaviour, not a defect. Specifically: do not addasset(or any kind) to those enums — the pull request that tries is repeating the mistake this ADR exists to stop. Assets are referenced viarequires/suggestsedges only. - The migration hint must point at the real layout.
InlineReferenceRejectedErrorand the ~10 sites repeatingsrc/doctrine/graph.yamlare corrected to the sharded per-kind fragment path (src/doctrine/<kind>.graph.yaml). Guidance that cannot be followed is worse than none. - The extractor's reference passes are retired in this mission, following ADR 2026-07-18-1: migrate each file's relationship entries into hand-authored edges (preserving the per-kind inferred relation explicitly, so authored intent and edge type stop living in different files), then delete the reference-extraction passes so fragments are authored, not derived. Sequenced so the proof comes first: commit the baseline manifest → migrate → assert the structured diff → prove pass deletion by observed RED (in-process ablation).
- New doctrine artefacts are edge-native from birth. An artefact authored today declares zero inline references and all its relationships as edges.
Scope: the migration happens now, in this mission
An earlier revision of this ADR deferred the migration of the inline blocks to follow-up work, on the grounds that it is wide and needs its own idempotency proof. That deferral was withdrawn by the operator, and the reasoning is worth recording because it generalises:
We are working on the
3.2.6-unreleasable issues, and have been extreme boyscouting for over a week. We will continue this practice and philosophy until 3.2.6 is stable, and we covered as many of the existing "ticking timebombs" as we can. Similar to how we stopped greenwashing, we desperately need to stop deferring structural issues.
The deferral was also a weak argument on its own terms: "it needs a proof first" is a reason to build the proof, not to postpone.
Measured composition (derived, never hand-counted — run
PYTHONPATH=src python scripts/doctrine/inline_reference_inventory.py; the FR-015 gate imports
that module rather than restating a literal). 171 files carry 761 entries across six inline
surfaces, in three dispositions:
| Disposition | Entries | Surfaces |
|---|---|---|
| MIGRATE — denotes an artefact→artefact relationship | 559 | references 414 · steps[].references 15 · context-sources.directives 67 · directive_refs 34 · tactic-references 29 |
| GOVERNANCE — zero DRG edges; seeds the charter closure. Do not migrate | 188 | directive-references 68 · context-sources.{additional,doctrine-layers,tactics} 120 |
| RAW_MATERIAL — path strings to non-artefact files. Keep | 14 | enumerated (file, path) allowlist |
An earlier revision of this ADR said 414, in six places. That figure was the top-level
references: row alone — correct as far as it went, mistaken as a total. It missed 15
step-level entries (in 7 tactic files, 4 of which carry no top-level block at all, so a
file-driven migration would never have opened them) and it missed five sibling surfaces entirely,
including directive_refs, which mints 34 real edges and is schema-blessed in 12 paradigms.
The GOVERNANCE row is the load-bearing correction. directive-references produces no DRG
edges but is the seed set for the entire charter governance closure, so sweeping it would empty
every profile-routed prompt's directives while byte-identical fragments, golden counts, and a
zero-structured-entries gate all stayed green. That is this ADR's own defect class — silence —
reproduced by its remediation, and it is why the scope must name surfaces rather than say "all
inline relationships".
The references: field is not deleted outright. 14 entries point at raw reference material —
src/doctrine/skills/README.md, docs/adr/..., Divio templates, an action index.yaml. Those
denote no artefact relationship, produce no edge today (_resolve_path_ref fails closed on
them by design, NFR-003), and the doctrine README explicitly sanctions carrying raw reference
material. Forcing them into edges would invent relationships that do not exist — the same
category error as widening the kind enum.
The end state is therefore not "no references: blocks" but the sharper invariant:
Zero MIGRATE-class entries across all five migrating surfaces. A
references:list may contain only plain path strings to non-artefact material. The 188 GOVERNANCE entries are untouched.
This is gateable, and it is what the confirmation criteria below assert.
Amendment (2026-07-26) — three decisions taken after this ADR was first accepted
This ADR was Accepted before three operator rulings that change its scope and its proof. They are recorded here because this ADR, not a spec table, is the citable record (DIRECTIVE_003), and an implementer reading the original text would have built the wrong proof against a scope 26% too small.
- Full retirement, not reference passes only.
generate_graphhas nine steps; the reference passes are one. The others produce ~215 further edges — 157scopefrom action indexes, 21 mission-type projection, 8 template instantiation, 13 from a second hardcoded Python registry (_CURATED_ARTIFACT_EDGES, holding all fourspecializes_from), plus a calibrator that synthesisesscopeedges from the inequality|review| ≥ 0.80 × |implement|. All 774 edges become authored. Node discovery stays derived; the extractor becomes a node minter plus validator. Both Python edge registries are absorbed and deleted — otherwise the surface count holds flat at 11 while claiming consolidation. - The proof is a structured diff, not byte-identity. Byte-identity was the original invariant
and it is withdrawn: preserving the 68 procedure rationales and the 219 reference labels breaks
it by design, and a byte proof would therefore pressure the migrator into deleting authored
content to make the proof pass. The invariant is now "the regenerated graph differs from the
committed pre-migration baseline manifest only by the enumerated, ledgered deviations". Two
further corrections ride along: the baseline must be a committed manifest written before any
YAML is edited (otherwise "regenerate and compare" degenerates into
regenerate(tree) == regenerate(tree), a tautology of determinism blind to content loss), and pass deletion is proven by an observed RED via in-process ablation, because as a git sequence it is unachievable — the extractor must be alive as the migration oracle, and once the authored tier carries the edges the deletion is a no-op again. - Labels move to
aliaseson artefacts, notDRGEdge.label. Measured: the 219 label occurrences collapse to 102 distinct artefacts, and the 8 targets carrying more than one label are genuine synonym sets. They were never edge metadata — they are artefact names repeated at every pointer.aliases: list[str]on the artefact deduplicates them, merges the 8 disagreements into legitimate alias lists, enables grep now and semantic search later, and removes aDRGEdgeschema change with a six-consumer ripple.
Sibling: ADR 2026-07-26-3 adds
Relation.IMPACTS and is_symmetric to the same edge model. is_symmetric is expanded by the
generator, so the authored tier holds one edge and the cache holds both directions — consistent
with the source-plus-cache split this ADR's amendment relies on.
Consequences
Positive
- One authority for relationships; the drift class (four hand-copied enums) stops mattering because the surface they guard is closed rather than widened.
- The
#2918"asset support" finding is correctly reclassified from bug to the deprecated surface working as intended, which retires an entire strand of would-be remediation. - Edge-native authoring gets the full relation vocabulary — including
refines, which the inline shape could never express. - Operators receive a migration hint that names a file that exists.
Negative
- Authoring relationships is now a two-file edit (artefact body + graph fragment), which is less locally-obvious than an inline list. Accepted: it is the cost of typed relations.
- The migration touches 139 artefact files and the generated fragments in one change — a large mechanical diff inside an already-wide PR. Mitigated by the structured-diff invariant plus the inventory gate, which together make the migration verifiable by assertion rather than by reviewing 559 individual edge moves.
- The 14 surviving raw-material path entries mean
references:does not disappear entirely, so a reader must know the distinction between "a path to a doc" and "a relationship". The gate encodes it, and this ADR explains it.
Neutral
hand_authored_overlay.pybecomes the primary authoring path rather than a supplement; its name will read oddly once extraction retires and should be revisited then.- The
Relationvocabulary itself is untouched by this ADR.
Immediate application to #2935 (the mission that surfaced this)
- The artefact-series proposal's edge-based design is correct by construction — it already
routes asset references through
requires/suggestsedges rather than atype: assetbody field. No redesign needed. - The
DIRECTIVE_041intent split must not add inline references.041is itself one of the legacy carriers (5 structured entries), so those five migrate to edges with the rest; the newDIRECTIVE_041 --refines--> paradigm:tests-as-scaffold-not-frictionlink is authored as an edge from the start. Note the ordering constraint: because editing041is its own reviewed change, do its migration with the intent split rather than as an anonymous line in the bulk pass. - That edge is the first
refinesedge in any built-in fragment (histogram above: 0). Round-trip verification through the org→DRG bridge on regeneration is therefore a real risk item, not a formality — #2079 fixed a silentrefines→appliesdowngrade, and nothing built-in has exercised the fix.
Confirmation
- Zero structured
{type, id}reference entries remain anywhere undersrc/doctrine/, and zero remaining path-string entries resolve to a built-in artefact via_resolve_path_ref. The only surviving entries are raw non-artefact paths. Enforced by a gate, not by inspection. - The regenerated graph differs from the committed pre-migration baseline manifest only by
the enumerated, ledgered deviations (preserved rationales and labels; any relation changed by
review). Every other node, edge, relation,
whenandreasonis unchanged. Completeness is asserted separately by the inventory gate — the diff proves preservation, not completion. - The four
<kind>_reference.typeenums are byte-identical to their pre-ADR state (no kind added), and a comment in each schema marks the definition frozen with a pointer here. - No source site tells an operator to edit
src/doctrine/graph.yaml; the rejection-hint regex and its schema fixture name the per-kind fragment path. - During the migration, at every increment: regenerating fragments after migrating a file produces a zero diff, proving the hand-authored edge preserved the previously-inferred relation before any extraction pass is deleted.
Pros and Cons of the Options
Option 1 — Pin edges as canonical and complete the migration now (chosen)
Pros: ends the two-authority condition outright rather than documenting it; the doctrine
layer has exactly one relationship authority when the PR lands, so no reader has to hold
"which surface is live?" in their head. The structured-diff invariant plus the inventory gate make
a 559-entry mechanical migration cheaper to verify than to review — assertions cover every
entry. Deleting the extraction passes removes the mechanism that keeps re-deriving the legacy
surface, so the class cannot regrow. Also reclassifies the #2918 finding and unblocks #2935.
Cons: a large mechanical diff inside an already-wide PR; relationship authoring becomes a
two-file edit; references: survives for raw material, so the field does not vanish entirely.
Option 2 — Full parity for the inline surface
Pros: locally-obvious authoring; no migration debt; the #2918 finding becomes a real fix.
Cons: entrenches two authorities permanently and grows the one that is deprecated;
requires adding a relation: field, i.e. re-implementing the Relation vocabulary in schema
form — a second authority for edge semantics. Directly contradicts single-canonical-authority
and ADR 2026-07-01-1. Rejected.
Option 3 — Pin the direction, defer the migration (initially chosen, then withdrawn)
Pros: narrow change now; the wide mechanical migration sits behind its own proof and its own review; the P0 and the #2935 series land sooner. Cons — and these carried the decision: it leaves two readable surfaces live and the extraction passes still actively deriving the deprecated one, so the class keeps regenerating itself while the ADR asserts it is closed. "Frozen" without enforcement is the same posture that produced nine dead files and the #2918 layout error. The stated blocker — "it needs an idempotency proof first" — is an argument for building the proof, which turned out to be a single byte-comparison, not a research project.
Withdrawn by the operator on the explicit ground that deferring structural work is the same
failure mode as greenwashing a test: it converts a known defect into an invisible one and
relies on a future maintainer having the same context. While 3.2.6 is unreleasable, the
standing posture is to clear ticking timebombs in the mission that finds them.
Option 4 — Declare inline blocks canonical; fragments are pure build output
Pros: matches how much of the graph is actually produced today; single authoring site.
Cons: the inline shape provably cannot express the modern relation vocabulary
(refines/rejects/tension), so it would cap the DRG at its weakest historical expressiveness
and require inferring relations forever. Rejected as the wrong direction of travel.
More Information
- Inline-ref excision precedent: mission
excise-doctrine-curation-and-inline-references-01KP54J6; negative fixtures intests/doctrine/test_inline_ref_rejection.py. - Extraction passes:
src/doctrine/drg/migration/extractor.py; overlay:src/doctrine/drg/migration/hand_authored_overlay.py; fragment sharding: mission #2680. - Relation vocabulary:
src/doctrine/drg/models.py::Relation. - Test-quality series proposal:
docs/plans/doctrine/test_quality/doctrine-artefact-series-proposal.md.