Phase 0 Research — Docs Structural Sanity & Concern Guard
The finding set is settled by the Common-Docs section audit (docs/plans/engineering-notes/common-docs-section-audit.md, this branch); research here resolves the how, not the what.
D1 — Extend existing documentation doctrine, don't mint (FR-006, FR-011, C-005)
- Decision: Extend the artifacts that already exist, do NOT author a new directive. Ground truth:
src/doctrine/directives/built-in/042-common-docs.directive.yaml= DIRECTIVE_042 "Common Docs Documentation Standard" (enforcement: required) already binds the single Common-Docs root + 13-section structure,doc_statusfrontmatter (barestatusprohibited; ADR MADR-status exempt),related:path validity, ADRs underadr/<era>/, single livingarchitecture/, delete-stale/distil-then-retire, and no-shadow-tree. Itsvalidation_criteriaalready NOTES the anti-sprawl ratchet was retired but wires NO replacement gate. So: (a) EXTEND DIRECTIVE_042validation_criteriato citescripts/docs/docs_structural_lint.pyas the live successor gate; (b) EXTEND the companionsrc/doctrine/styleguides/built-in/common-docs.styleguide.yamlwith the finer concern-bucket→section map, the point-in-time allowlist, and a machine-parseable config block (FR-011) the lint loads; (c) RECONCILE the 4 dangling-ratchet artifacts (the 042 directive, the styleguidetooling/quality_test,common-docs-curation.tactic.yaml,common-docs-scaffold.tactic.yaml) so none names the deleted "WP05 anti-sprawl structure ratchet". Highest existing directive number is 046; no new number is minted. - Rationale: The concern (a durable placement standard) is already the subject of DIRECTIVE_042 — minting a
documentation-placementdirective would duplicate an active canonical authority (charter: single canonical authority; unification not parity). The gap is only the missing enforcement wire + the finer bucket map, both of which are extensions of the existing artifacts. The reverse edge — the lint's module path lands back INTO these 4 artifacts (IC-02 → IC-01) — closes the loop. - Alternatives: (a) a new
documentation-placementdirective — REJECTED, duplicates DIRECTIVE_042; (b) a singledocs/page — rejected, not canonical/loadable (C-005); (c) reviving the ratchet's inline constants — rejected, that is the drift being retired.
D2 — Docs structural lint + config SSOT (FR-007, FR-011)
- Decision: A
scripts/docs/docs_structural_lint.pymodule with four independent checks (structured violations + nonzero exit), each scoped so the current clean tree passes (NFR-003): (1) index_completeness — only for sections the styleguide config declares "curated-complete" (startarchitecture/only; all other section indexes are landing pages, exempt); (2) point_in_time_placement — dated/point-in-time files live underplans/broadly (ADR D7), honoring an allowlist coveringadr/(era-dated ADRs) + the audit's STAY subtreesplans/research/andplans/investigations/; (3) shadow_tree_basename — no shared NON-NAV content-duplicate basename across section subtrees (nav basenames + sanctioned era files exempt); (4) frontmatter_contract — every in-scope page carries the required fields, in-scope EXCLUDING sectionREADME.mdlanding pages. - Config SSOT (resolves the prose-vs-code split): the section list / curated-complete set, the dated-filename patterns, the allowlist, and the required frontmatter fields live in the extended
common-docsstyleguide's machine-parseable config block (FR-011); the lint LOADS that config rather than hard-coding policy. Atests/docs/test asserts the lint's behaviour matches the styleguide config — one source of truth for policy, not two. - Rationale: These four are exactly the finding-classes the audit surfaced and the ratchet never caught (it checked index existence, not completeness; nothing checked shadowing). Config-in-doctrine + lint-loads-config prevents the prose standard and the code gate from drifting apart.
- Alternatives: extend the retired ratchet — rejected (removed by PR #2855); a monolithic check — rejected (harder to test per-class); lint hard-codes policy — rejected (re-creates the prose/code split C-005 forbids).
- Clean-tree calibration (verified counts): the tree has 38
index.md, 38README.md, 132 dated files underadr/, and exactly 3 frontmatter-less files (docs/adr/{1.x,2.x,3.x}/README.md). All must PASS: theadr/dates are allowlisted, the READMEs are nav/landing-exempt, and the 3 frontmatter-less ADR READMEs are deferred to #2227. Fixtures assertadr/+plans/{research,investigations}/**pass clean.
D3 — Redistribution homes (FR-001/002/005, C-002)
- Decision: Per the audit + Common-Docs ADR (
2026-06-27-1, D7 distil-then-retire → engineering-notes): the 9 firm architecture artifacts + the borderline assessment + the migrations closeout all go todocs/plans/engineering-notes/(the audits as aarchitecture-audits/subfolder).operations/receives nothing (it already absorbed #2851's runbooks). - Rationale: engineering-notes is the canonical home for point-in-time/report content (D7); keeping the 7 audits grouped preserves their provenance.
- Alternatives: a mission-archive under
plans/— rejected, engineering-notes is the established home and already indexes such content.
D4 — Shadow-tree reconciliation is FOLD-then-DELETE, NOT relocation (FR-003)
- Decision: The 3 shadow
to:targets already exist — they are the canonical twins that STAY (feature-detection.md↔architecture/,gap-analysis-connector-installation-model.md↔architecture/,adr-connector-auth-binding-separation.md↔adr/3.x/). So this is fold-then-delete, not agit mvrelocation: diff eachplans/notes/shadow against its canonical twin, PORT any unique content INTO the existing canonical copy, then DELETE the shadow, repoint referrers to the canonical path, and add a redirect from the old-shadow URL → the existing canonical.html. A mechanicalgit mv shadow canonicalwould CLOBBER the canonical — forbidden. These 3 are therefore carried distinctly inoccurrence_map.yaml(a clearly-marked fold-then-delete note, NOT ordinarymoves:relocation entries whosetodoes not yet exist). - Rationale: blind
git mvor blind-delete would lose the canonical (or the shadow's divergent content); the canonical twins are the ones referrers should resolve to. - Alternatives: keep both + cross-link — rejected, that is the split-brain being eliminated (NFR-005); treat as a plain relocation move — rejected, clobbers the canonical twin.
D5 — Move mechanics, gate obligations & un-pinning the tooling (FR-009, FR-010, NFR-001/002/004, C-007)
- Verified real CLIs (an earlier draft fabricated flags):
redirect_stub_generator.pysubcommands areregenerate-map | check-map | generate | coverage(there is NO--add/--old/--new); itsredirect_map.yamlis a single-writer, DERIVED artifact ("do not hand-edit") regenerated from the baseline manifest + a mission'soccurrence_map.yamlmoves:spine.relative_link_fixer.pywrites by default (flags--dry-run/--check/--no-exclude; there is NO--write) and walksdocs/ONLY**.check_docs_freshness.pyuses--inventory(NOT--write-inventory) and--ci. - Tooling is foreign-pinned (FR-010/C-007):
redirect_stub_generator.pysetsMISSION_SLUG = "common-docs-structural-move-01KW3SBK"as its DEFAULT occurrence map;bulk_ref_rewrite.pyhardcodes.../common-docs-structural-move-01KW3SBK/occurrence_map.yamlas its default. BOTH already accept a--occurrence-mapoverride. Decision: drive both against THIS mission'soccurrence_map.yamlvia--occurrence-map, and un-pin/parametrize the DEFAULT (or file an upstream gap per charter). FR-009/NFR-002 must not ride on the silent foreign default. - Decision (per move): (1)
git mv(relocations only — NOT the 3 folds, see D4); (2) do NOT regenerateredirect_map.yaml(see the redirect-corpus gap below — this mission's moved paths are never-published, soregenerate-mapderives ZERO entries and OVERWRITES the map, wiping the landed01KW3SBK149 redirects); (3)relative_link_fixer --occurrence-map <this mission's map>overdocs/referrers; (4) abulk_ref_rewrite --occurrence-map <this mission's map>sweep of non-docs/referrers** — confirmed:src/doctrine/paradigms/built-in/brownfield-onboarding.paradigm.yaml:50references a moved audit path andrelative_link_fixerwill never see it;bulk_ref_rewrite.py(prefix-anchored) is the complement that also rewritessrc/+scripts/prefixes; (5) regenerate the page-inventory lockfile in place viainventory_lockfile.py --write(check_docs_freshness --inventoryis READ-ONLY),toc.ymlverify-only (no generator, no moved-path reference); (6) updaterelated:frontmatter edges. Verify aggregate withrelative_link_fixer --check --occurrence-map <map>,check_docs_freshness --ci, the terminology guard, andtests/docs/, plusgit statusprovingredirect_map.yaml+redirect_baseline_urls.jsonare unmodified. - Redirect-corpus gap (UPSTREAM — file, do not improvise): redirect derivation is NOT cumulative across missions.
redirect_stub_generator regenerate-maprebuildsredirect_map.yamlfrom the baseline manifest + a SINGLE mission'soccurrence_map.yamlmoves:spine, so a later mission'sregenerate-mapwipes every earlier mission's entries (proven by execution: drivingregenerate-mapwith THIS mission's map derives ZERO entries — the moved paths are never-published — and overwrites the landed01KW3SBK149 redirects). The tooling needs a committed cross-mission move manifest / cumulative derivation (accumulate all missions' moves, or persist prior entries) so redirects compose across missions. Until that upstream fix lands, missions whose moved paths are never-published (like this one) MUST NOT runregenerate-map; link integrity is delivered byrelative_link_fixer(in-repo docs) +bulk_ref_rewrite(non-docs) only. File this as an upstream gap per charter (missing/broken canonical surface → trace + file). - Rationale: this is the never-published subset of the Common-Docs move contract — no path here is a baseline/published URL, so no redirect is required (NFR-002 reframed: no baseline-URL 404 regression + all in-repo relative links resolve); the non-
docs/sweep closes the gaprelative_link_fixer'sdocs/**-only walk leaves; runningregenerate-mapwould be actively destructive (wipes foreign redirects). - Alternatives: rely on redirects without rewriting in-repo links — rejected (NFR-001); consume the foreign
01KW3SBKdefault — rejected (C-007); regenerate the redirect map for this mission — rejected (derives zero entries, wipes 149 landed foreign redirects).