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 scopeOut 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

InputShapeSource
Redirect map{ old_path: new_path } (checked-in)Appended per move by IC-05 as references are rewritten
Baseline-URL inventoryset of pre-move published URLsCaptured + committed by IC-02b before any move — the NFR-002 denominator
DocFX _sitebuilt site outputdocfx 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 at old_path inside _site whose 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 --strict in 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

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 --strict in CI.
  • Input: all related: frontmatter edges across docs/. 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-oprun_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_findingthis 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.py exit
  • 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 --strict
  • uv run python scripts/docs/related_validator.py --strict
  • check_docs_freshness.py with 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.