Contracts
content-invariance.md
Contract: ADR Content-Invariance Check (FR-003, C-002, NFR-001)
The 117-unique-ADR conversion changes only location and header format — never decision content (C-002). This check is the proof. It is the gate that distinguishes a safe header-format change from a content mutation, and it must be false-green-proof (a re-render comparison would pass on whitespace normalisation and miss a real edit).
Method: body-minus-header byte-identity
For each ADR being converted:
1. Pre-image: read the original ADR file; strip the original header block — one of three formats the three parsers consume: (a) markdown-table, (b) bold-inline Status: …, (c) dash-bullet - Status: / - Date: (e.g. architecture/2.x/adr/2026-04-15-2-explicit-empty-charter-selections-remain-empty.md) — and the leading title line; retain the remaining decision body verbatim. The dash-bullet boundary: the header block ends at the last consecutive - Status:/- Date:/- Deciders:-style bullet at the top of the file; the decision body begins at the first non-bullet, non-blank line after it. Bullets inside the body (after a blank line / heading) are body, not header. 2. Post-image: read the converted ADR file; strip the new YAML frontmatter block by reusing _inventory.parse_frontmatter (do NOT fork a second frontmatter parser — the post-image strip must use the same canonical parser the inventory uses); retain the remaining decision body verbatim. 3. Assert: bytes(pre_body) == bytes(post_body) — byte-identical, not re-rendered, not normalised.
invariant(adr): body_minus_header(pre) == body_minus_frontmatter(post) # byte-for-byte
Scope
| In scope | Out of scope |
|---|---|
All 117 unique ADRs (97 era + 20 era-less migrated to adr/3.x/) | The reconciliation ADR self-amendment (FR-013) — a sanctioned prose edit of its own Neutral note; explicitly excluded from this check (C-002 protects moved decision-records, not this self-amendment) |
Header → bare-status YAML frontmatter (title/status/date) | The 47 byte-identical flat mirrors — dropped losslessly (identical to their era originals), not converted |
Coupling
the body they leave untouched is what this check guards. Live census: 70 bold-inline / 46 table / 1 dash-bullet = 117 (the spec's "~12 table / ~34 bold" was wrong and missed the dash-bullet format — a missing 3rd branch would convert that ADR status-less and block the ratchet).
failure (NFR-001: 0 lost). The 47 mirrors are proven byte-identical to their originals before the drop, so "dropped" is provably "not lost".
- Three parsers (markdown-table + bold-inline + dash-bullet) produce the post-image header;
- Census pairing: the check runs over the full 117 — a missing ADR (count < 117) is itself a
Invariant
> Content-invariance: for every converted ADR, the decision body is byte-identical pre/post; the > count of unique ADRs post-move == 117; 0 lost, 0 content-altered (NFR-001, SC-002).
redirect-stub.md
Contract: Redirect-Stub Generation (FR-006, NFR-002)
Implements Mission A's D4 redirect mechanism: DocFX on GitHub Pages has no native redirect, so every moved/deleted URL is preserved by a generated <meta http-equiv="refresh"> stub page emitted at the old path into the _site output, produced by a post-build step in scripts/docs/ (new redirect_stub_generator.py) reading a checked-in redirect map.
Inputs
| Input | Shape | Source |
|---|---|---|
| Redirect map | { old_path: new_path } (checked-in) | Appended per move by IC-05 as references are rewritten |
| Baseline-URL inventory | set of pre-move published URLs | Captured + committed by IC-02b before any move — the NFR-002 denominator |
DocFX _site | built site output | docfx build docs/docfx.json |
Pre-move baseline capture (IC-02b) — makes coverage falsifiable
The baseline-URL set is captured by a dedicated pre-move step (IC-02b), not inside IC-05 (which runs after the move). Steps:
1. Install DocFX (.NET — CI-only today; install it in this step). 2. Build the pre-move tree (docfx docfx.json over docs/ + architecture/ before IC-03). 3. Snapshot the emitted _site URL set into a checked-in baseline-URL manifest. 4. Commit the manifest before IC-03.
If the baseline is reconstructed from the post-move tree, check_coverage measures against the wrong denominator and reports a false 100% — the guarantee becomes unfalsifiable.
CI wiring (IC-05) — inject stubs between build and upload
Wire redirect_stub_generator.py into .github/workflows/docs-pages.yml: add a step that runs after the Build documentation step (docfx docfx.json) and before the Upload artifact (actions/upload-pages-artifact@v3) step, so the emitted stubs land inside _site before publish. The coverage check (against IC-02b's committed baseline) runs in the same job and fails the build on any uncovered baseline URL.
Behavior
generate(redirect_map, site_dir) -> emitted_stubs
<meta http-equiv="refresh" content="0; url=<new_path>"> page (client-side redirect — the only primitive available on static GitHub Pages).
- For each
old_path → new_path, emit a stub file atold_pathinside_sitewhose content is a - The stub MUST resolve to a live
new_path(no stub may point at a 404).
check_coverage(baseline, redirect_map, site_dir) -> uncovered[]
redirect stub exists at that path pointing to a live target.
uncovered set is a CI failure; each entry is a dead public URL.
- A baseline URL is covered iff it resolves directly (the page still exists at that path) OR a
- Contract:
uncovered == []— 100% of baseline URLs covered (NFR-002). A non-empty
Invariant
> URL-continuity: coverage(baseline) == 100%. The denominator is the captured baseline set > — capturing it before the move is what makes the guarantee falsifiable.
Out of scope
canonical/301 continuity (NFR-003).
- Server-side redirects / DocFX native aliases (unavailable on static GitHub Pages — D4 rejected).
- #1652 SEO optimization (sequenced after this mission); this contract only preserves existing
rulers-blocking.md
Contract: The Three Rulers — Blocking Interfaces (FR-011)
Mission A shipped the three structural rulers report-only. Mission B flips them to blocking. The flip is non-uniform: two are a --strict CLI toggle; the third is a code change. This contract pins the exact pre-state (verified live on this branch) and the required post-state.
R1 — Anti-sprawl ratchet (scripts/docs/anti_sprawl_ratchet.py)
Pre-state (verified): --strict is wired but off by default; report-only. The blocking branch already exists:
# anti_sprawl_ratchet.py
ADR_FRONTMATTER_REQUIRED_KEYS: Final[tuple[str, ...]] = ("title", "status", "date")
...
if args.strict and report["baseline_count"] > 0:
return 1
return 0 # report-only (C-002): the default ruler never blocks.
Contract (post-flip):
present (second doc root, missing section index.md, un-frontmattered ADR, shadow tree); exit 0 on a clean tree.
("title", "status", "date") (bare status, MADR vocabulary).
- Interface unchanged — flip is achieved by invoking with
--strictin CI. - Input: the cleaned
docs/tree. Output: exit non-zero when a sprawl regression is - ADR-frontmatter rule: an ADR is valid iff its frontmatter contains all of
R2 — related: validator (scripts/docs/related_validator.py)
Pre-state (verified): report-only; --strict wired but off:
# related_validator.py
if args.strict and report.dangling_edges:
... # non-zero
Contract (post-flip):
dangling related: edge (a related: path that does not resolve to a real .md); exit 0 when every edge resolves (NFR-004 = 0 dangling edges).
- Interface unchanged — flip via
--strictin CI. - Input: all
related:frontmatter edges acrossdocs/. Output: exit non-zero on any
R3 — Lockfile drift gate (scripts/docs/check_docs_freshness.py) — CODE CHANGE
Pre-state (verified): the gate has NO flag. _check_inventory_lockfile_drift hardcodes strict=False, and _lockfile_finding hardcodes severity="warning", so the finding can never raise the aggregate exit (which keys off any(f.severity == "error")):
# check_docs_freshness.py — PRE
def _check_inventory_lockfile_drift(inventory: Path, docs_root: Path) -> list[FreshnessFinding]:
...
report = run_generate_and_compare(docs_root=docs_root, inventory=inventory,
repo_root=None, strict=False) # ← hardcoded False
...
def _lockfile_finding(location: str, message: str) -> FreshnessFinding:
return FreshnessFinding(rule_id="INVENTORY-LOCKFILE-DRIFT",
severity="warning", # ← hardcoded warning
...)
Contract (post-flip — THREE code changes, not two): 1. Thread strict=True through _check_inventory_lockfile_drift (so run_generate_and_compare(..., strict=True)). Annotation: in this codepath strict=True is a harmless no-op — run_generate_and_compare returns the same drift report regardless; the value that actually flips the gate is change (2), the severity escalation. Thread it for intent/consistency, but the implementer should not expect (1) alone to change CI behavior. 2. Escalate INVENTORY-LOCKFILE-DRIFT from severity="warning" to severity="error" in _lockfile_finding — this is the real gate change (the aggregate exit keys off any(f.severity == "error")). 3. Remove the if inventory_lockfile_check: opt-in guard in check_docs_freshness.run_orchestrator (~line 433) — make the lockfile check default-on. Without (3) the escalation is DEAD CODE in CI: .github/workflows/docs-freshness.yml:24 invokes check_docs_freshness.py --ci --report freshness.json --link-check none with no --inventory-lockfile, so the guarded block never runs. (Equivalent alternative: add --inventory-lockfile to the CI invocation — but default-on is the robust choice.)
non-zero (the aggregate already raises on an error finding). Exit 0 only when drift = 0.
the live drift (252 removed / 296 changed) to 0 — else the flip red-fails the mission's own merge.
- Result: any lockfile drift (added/removed/changed) makes
check_docs_freshness.pyexit - Ordering precondition: the gate is flipped after the move (IC-03) + backfill (IC-05) close
CI wiring (FR-011) — NO CI job invokes the rulers today
Verified: only .github/workflows/docs-freshness.yml runs any of the three scripts, and it runs only check_docs_freshness.py (without --inventory-lockfile). Neither anti_sprawl_ratchet.py nor related_validator.py is invoked by any CI job. So flipping --strict (R1/R2) is inert until they are wired into CI.
Contract (IC-06 CI wiring): add to .github/workflows/docs-freshness.yml a step (or steps) invoking:
--inventory-lockfile passed.
uv run python scripts/docs/anti_sprawl_ratchet.py --strictuv run python scripts/docs/related_validator.py --strictcheck_docs_freshness.pywith the lockfile check default-on (change (3) above) or
/tasks names the exact step. All three are paired with the C-005 whole-tree dry-run below.
Cross-cutting: gate-unmask discipline (C-005)
All three flips are paired with a full-gate dry-run over the whole tree before merge, not scoped to the mission diff. A ruler that only bites on the mission diff is invisible until post-merge ("gate-unmask cannot self-validate"). The dry-run is quickstart S3 run against the entire tree.
LEAK coupling (FR-014): version_leakage_check.py's LEAK-FRONTMATTER-MISMATCH is retired only after R3 is proven red-live + blocking — the lockfile drift gate subsumes it.