Status: Accepted
Date: 2026-07-26
Deciders: Operator (Stijn Dejongh), who named both the convention and its future form:
"I would expect it to be assets/built-in/audiences, as per type/pack/<name> repository
convention. After extract of our doctrine packs to a secondary repository (future scope),
that would become <pack id>/type/name."
Technical Story: surfaced while triaging an asset-discovery finding on #2918 during the #2935 doctrine-authoring session (PR #2936). Sibling ADR 2026-07-26-1 covers the relationship axis of the same "doctrine layer predates its own canonical model" problem; this one covers the physical layout axis.
Context and Problem Statement
Doctrine artefacts resolve from a three-part path: the type (plural kind directory), the
pack (provenance layer), and the artefact name, with an optional category grouping
in between. The shipped built-in tree follows it — tactics/built-in/testing/,
toolguides/built-in/system_tools/, styleguides/built-in/writing/ — and both resolvers
depend on it:
- the per-kind repositories scan only
<type>/built-in(_default_builtin_dir()→Path(__file__).parent / "built-in"); - DRG node discovery (
_discover_built_in_artifact_nodes) scans<plural>/built-inandrglobs beneath it, so categories inside the pack layer are found automatically.
Nothing enforces the layout. An artefact placed outside <type>/<pack>/ is not rejected —
it is simply never loaded, never registered as a DRG node, and therefore never resolvable and
never a legal edge target. The failure mode is silent invisibility, which is the worst
available outcome: the file looks authored, passes review, and does nothing.
Three classes of evidence, all found by inspection rather than by any gate:
- #2918 inverted pack and category. It ships assets at
assets/audiences/built-in/*.asset.yamlinstead ofassets/built-in/audiences/. Because the base directory is fixed atassets/built-in, those artefacts are invisible to node discovery. This was initially diagnosed as an extractor bug ("the extractor missesassets/<subdir>/built-in/**") and a widening of the scan was drafted. That diagnosis was wrong: the extractor is correct and the directory is misplaced. - Nine pre-existing artefacts sit outside any pack layer, all confirmed dead (nothing loads
them, by id or by path —
tests/doctrine/paradigms/conftest.pybuilds its own copy in a tmp dir):- three byte-identical duplicates of live built-ins —
tactics/tdd-red-green-refactor,tactics/zombies-tdd,styleguides/writing/kitty-glossary-writing; - two stale divergent copies —
tactics/acceptance-test-firstandtactics/glossary-curation-interviewdiffer from their built-in twins (the strays lack the inlinereferences:blocks their twins carry). Two files claiming one artefact id with different content is an edit-the-wrong-file trap; - four seed stubs from the framework's first commit —
directives/test-first(TEST_FIRST),paradigms/test-first,styleguides/python-implementation,toolguides/powershell-syntax. The first three are content-free placeholders superseded by real built-ins; the fourth points at a genuine 245-line guide that has been unreachable the whole time.
- three byte-identical duplicates of live built-ins —
- The kind READMEs document a pack layer that does not exist —
src/doctrine/toolguides/README.mdandsrc/doctrine/paradigms/README.mddescribe<type>/shipped/*.yaml, andtests/glossary/test_seed_schema.pynamessrc/doctrine/tactics/shipped/.... On disk the pack layer is alwaysbuilt-in. A third name for one slot.
Decision Drivers
- Silent invisibility is unacceptable — a misplaced artefact must fail loudly. This is the charter's architectural-gate standing order (close the defect class by construction with a non-vacuous gate) applied to layout.
- Single canonical authority — one artefact id, one file. The two divergent duplicates are a direct violation.
- Forward compatibility with pack extraction — doctrine packs will move to their own repository; the layout decided now should survive that move as a rotation, not a redesign.
- Campsite cleaning — the litter class is small enough (9 files) to clear in the same change that lands the gate, so the gate ships with nothing to allowlist.
Considered Options
- Option 1 (chosen) — Pin
<type>/<pack>/[<category>/]<name>, clear the 9, land a non-vacuous gate with an empty allowlist. - Option 2 — Widen the resolvers to accept
<type>/<category>/<pack>/too (the shape #2918 used), making both layouts legal. - Option 3 — Pin the convention in docs only; no gate.
- Option 4 — Land the gate with the 9 frozen as a shrink-only baseline; clear them later.
Decision Outcome
Chosen option: 1.
The layout this ADR pins:
src/doctrine/<type>/<pack>/[<category>/]<name>.<kind>.yaml
│ │ │
│ │ └─ artefact file, id matches its content
│ └─ OPTIONAL grouping, nests INSIDE the pack
└─ provenance layer: `built-in` today (org/project layers overlay it)
<type>/<pack>/is mandatory. An artefact directly under<type>/is misplaced.- Categories nest inside the pack, never above it.
assets/built-in/audiences/is correct;assets/audiences/built-in/is not. This is the #2918 correction. built-inis the only spelling of the built-in pack layer.shipped/is a phantom — the README and test references to it are stale and get corrected.- Misplacement is an error. A gate rejects any
*.<kind>.yamlundersrc/doctrine/that is not at a legal path, so the failure mode becomes a red test rather than a silently dead file. The gate is non-vacuous (a self-mutation case proves it fails on a planted violation) and its allowlist is empty — there is nothing to grandfather, because the 9 are cleared in the same change. - Mission-tier kinds are outside this rule, because they resolve from their own
documented locations rather than from the artifact-tier shape:
template(mission-tier, empty glob),anti_pattern(hand-authored inside graph fragments, no standalone file), andmission_step_contract— which lives atmissions/built_in_step_contracts/, the pathextractor.py::_iter_step_contract_datadocuments as authoritative; there is nomission_step_contracts/directory at all. This carve-out was discovered by the gate (17 step contracts flagged on its first run) and is pinned positively — the gate also asserts step contracts exist at that path and appear nowhere else — so "it's mission-tier" cannot become a hiding place for a genuine stray. - Future form, after packs are extracted to their own repository:
<pack id>/<type>/[<category>/]<name>. The pack becomes the outermost segment (it is the repo root), so the migration is a rotation of the first two segments, not a re-layout — the type/category/name tail is unchanged. Recorded so the extraction mission inherits a decision instead of re-litigating one.
Disposition of the nine
| Artefact | Action | Why |
|---|---|---|
tactics/tdd-red-green-refactor, tactics/zombies-tdd, styleguides/writing/kitty-glossary-writing |
delete | byte-identical to the live built-in |
tactics/acceptance-test-first, tactics/glossary-curation-interview |
delete | stale strays; the built-in twin is authoritative and richer |
directives/test-first, paradigms/test-first |
delete | content-free stubs; the live tests-doctrine authority is DIRECTIVE_041 + the #2935 series |
styleguides/python-implementation |
delete | superseded by built-in python-conventions |
toolguides/powershell-syntax + POWERSHELL_SYNTAX.md |
promote into built-in/ |
245 lines of real guidance; the toolguide tests already name the built-in/ path |
Note on the two test-first stubs: the #2935 decision was to reconcile them into the new
paradigm:tests-as-scaffold-not-friction rather than drop them blind. On inspection there is
nothing to fold — between them they carry an id, a title, and a one-line summary, with no
procedures, steps, or rules. The reconciliation is therefore honestly a no-op, and recorded as
such rather than dressed up as a merge.
Consequences
Positive
- Misplaced artefacts fail at test time instead of disappearing.
- One artefact id → one file; the divergent-duplicate trap is removed.
- A real 245-line PowerShell guide becomes reachable for the first time.
- Pack extraction inherits a pinned target layout.
Negative
- The gate constrains where future artefacts may live, and contributors adding a new category must put it inside the pack layer — the opposite of the intuition #2918 followed. The gate's failure message must therefore state the correct shape, not merely reject.
- Promoting the PowerShell toolguide makes a previously-dead artefact live. That is the point, but it is a behavioural change riding along with a layout cleanup, and is called out in the PR.
Neutral
- Org/project pack layers overlay the same
<type>/<pack>/shape and are unaffected. - The
assets/tree keeps its loose-contract sidecar convention; only its directory nesting is constrained.
Confirmation
- The layout gate fails on a planted misplaced artefact (self-mutation case) and passes on the shipped tree with an empty allowlist.
find src/doctrine -name '*.<kind>.yaml'yields only paths matching<type>/<pack>/[<category>/]<name>.TerminologyGuard-style README/test references toshipped/are corrected tobuilt-in/.- The promoted PowerShell toolguide loads through
ToolguideRepositoryand appears as a DRG node. - Regenerating the DRG fragments after the cleanup produces no unexpected node loss (the deleted strays were never nodes).
Pros and Cons of the Options
Option 1 — Pin the layout, clear the litter, gate it (chosen)
Pros: removes the silent-invisibility class outright; gate ships with an empty allowlist, so it can never be read as "the violations are acceptable"; corrects #2918's diagnosis before the wrong fix (widening the extractor scan) is built. Cons: widens this PR by a cleanup; makes one dormant artefact live.
Option 2 — Accept both layouts in the resolvers
Pros: #2918 needs no change; contributors cannot get it wrong.
Cons: two legal shapes for one concept — the same two-authority smell as the sibling ADR,
and it doubles the scan surface for every kind forever. It also makes the pack layer's position
meaningless, which breaks the clean rotation to <pack id>/<type>/... on extraction. Rejected.
Option 3 — Document only, no gate
Pros: zero risk to the current tree.
Cons: documentation already exists and is already wrong (the shipped/ phantom). Convention
without enforcement is exactly how nine dead files and #2918 happened. Rejected.
Option 4 — Gate with the 9 frozen as a baseline
Pros: narrower change; standard charter fallback for a litter class too big to clear. Cons: the class is nine files and five of them are provable duplicates — small enough that a baseline would be ceremony, and a frozen list of dead files invites a future reader to assume they matter. Rejected on size, not on principle.
More Information
- Resolver scan paths:
src/doctrine/*/repository.py(_default_builtin_dir);src/doctrine/drg/migration/extractor.py::_discover_built_in_artifact_nodes. - Sibling axis: ADR 2026-07-26-1 (DRG edges as the relationship authority).
- Originating finding: PR #2918 review; corrected diagnosis recorded in this ADR's context.