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_status frontmatter (bare status prohibited; ADR MADR-status exempt), related: path validity, ADRs under adr/<era>/, single living architecture/, delete-stale/distil-then-retire, and no-shadow-tree. Its validation_criteria already NOTES the anti-sprawl ratchet was retired but wires NO replacement gate. So: (a) EXTEND DIRECTIVE_042 validation_criteria to cite scripts/docs/docs_structural_lint.py as the live successor gate; (b) EXTEND the companion src/doctrine/styleguides/built-in/common-docs.styleguide.yaml with 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 styleguide tooling/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-placement directive 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-placement directive — REJECTED, duplicates DIRECTIVE_042; (b) a single docs/ 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.py module 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" (start architecture/ only; all other section indexes are landing pages, exempt); (2) point_in_time_placement — dated/point-in-time files live under plans/ broadly (ADR D7), honoring an allowlist covering adr/ (era-dated ADRs) + the audit's STAY subtrees plans/research/ and plans/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 section README.md landing 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-docs styleguide's machine-parseable config block (FR-011); the lint LOADS that config rather than hard-coding policy. A tests/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, 38 README.md, 132 dated files under adr/, and exactly 3 frontmatter-less files (docs/adr/{1.x,2.x,3.x}/README.md). All must PASS: the adr/ dates are allowlisted, the READMEs are nav/landing-exempt, and the 3 frontmatter-less ADR READMEs are deferred to #2227. Fixtures assert adr/ + 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 to docs/plans/engineering-notes/ (the audits as a architecture-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.mdarchitecture/, gap-analysis-connector-installation-model.mdarchitecture/, adr-connector-auth-binding-separation.mdadr/3.x/). So this is fold-then-delete, not a git mv relocation: diff each plans/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 mechanical git mv shadow canonical would CLOBBER the canonical — forbidden. These 3 are therefore carried distinctly in occurrence_map.yaml (a clearly-marked fold-then-delete note, NOT ordinary moves: relocation entries whose to does not yet exist).
  • Rationale: blind git mv or 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.py subcommands are regenerate-map | check-map | generate | coverage (there is NO --add/--old/--new); its redirect_map.yaml is a single-writer, DERIVED artifact ("do not hand-edit") regenerated from the baseline manifest + a mission's occurrence_map.yaml moves: spine. relative_link_fixer.py writes by default (flags --dry-run/--check/--no-exclude; there is NO --write) and walks docs/ ONLY**. check_docs_freshness.py uses --inventory (NOT --write-inventory) and --ci.
  • Tooling is foreign-pinned (FR-010/C-007): redirect_stub_generator.py sets MISSION_SLUG = "common-docs-structural-move-01KW3SBK" as its DEFAULT occurrence map; bulk_ref_rewrite.py hardcodes .../common-docs-structural-move-01KW3SBK/occurrence_map.yaml as its default. BOTH already accept a --occurrence-map override. Decision: drive both against THIS mission's occurrence_map.yaml via --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 regenerate redirect_map.yaml (see the redirect-corpus gap below — this mission's moved paths are never-published, so regenerate-map derives ZERO entries and OVERWRITES the map, wiping the landed 01KW3SBK 149 redirects); (3) relative_link_fixer --occurrence-map <this mission's map> over docs/ referrers; (4) a bulk_ref_rewrite --occurrence-map <this mission's map> sweep of non-docs/ referrers** — confirmed: src/doctrine/paradigms/built-in/brownfield-onboarding.paradigm.yaml:50 references a moved audit path and relative_link_fixer will never see it; bulk_ref_rewrite.py (prefix-anchored) is the complement that also rewrites src/+scripts/ prefixes; (5) regenerate the page-inventory lockfile in place via inventory_lockfile.py --write (check_docs_freshness --inventory is READ-ONLY), toc.yml verify-only (no generator, no moved-path reference); (6) update related: frontmatter edges. Verify aggregate with relative_link_fixer --check --occurrence-map <map>, check_docs_freshness --ci, the terminology guard, and tests/docs/, plus git status proving redirect_map.yaml + redirect_baseline_urls.json are unmodified.
  • Redirect-corpus gap (UPSTREAM — file, do not improvise): redirect derivation is NOT cumulative across missions. redirect_stub_generator regenerate-map rebuilds redirect_map.yaml from the baseline manifest + a SINGLE mission's occurrence_map.yaml moves: spine, so a later mission's regenerate-map wipes every earlier mission's entries (proven by execution: driving regenerate-map with THIS mission's map derives ZERO entries — the moved paths are never-published — and overwrites the landed 01KW3SBK 149 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 run regenerate-map; link integrity is delivered by relative_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 gap relative_link_fixer's docs/**-only walk leaves; running regenerate-map would be actively destructive (wipes foreign redirects).
  • Alternatives: rely on redirects without rewriting in-repo links — rejected (NFR-001); consume the foreign 01KW3SBK default — rejected (C-007); regenerate the redirect map for this mission — rejected (derives zero entries, wipes 149 landed foreign redirects).