Documentation-Sync Gates (tiered)
In one line: Documentation-sync is enforced the way code is — as fitness functions, tiered by what a machine can judge: objective checks block (fail-closed), judgment checks advise (fail-open).
Living documentation (§11) is a discipline, not a stage; v3.2 gives it teeth without the blanket-age-gate friction the earlier advisory hook deliberately avoided.
| Tier | Check | Mechanism |
|---|---|---|
| Blocking (fail-closed) | Link integrity | the rendered build (onBrokenLinks: throw) + a repo-level markdown link pass |
| Blocking (fail-closed) | ADR-register integrity | every ADR file has a register row (check-adr-register.sh) |
| Blocking (fail-closed) | Scoped code↔doc drift | changed code under the committed doc-pointers mapping (§11.4) without its regular mapped document → block; a governed waiver can clear assessed drift, never invalid assessment inputs |
| Blocking (fail-closed) | Generated-artifact freshness | a committed generated tree (e.g. a Docusaurus site built from canon *.md) must equal a fresh run of its generator — check-generated-fresh.sh seeds a throwaway dir from the committed tree, regenerates into it, and diffs; any difference blocks. Closes the "generated copy lags source" class (edit source, forget to regenerate) |
| Advisory (fail-open) | Blanket 30-day staleness | check-doc-staleness.sh warns; a blanket age-gate that blocks commits surprises contributors, so it advises (never shipped armed) |
| Advisory (fail-open) | Prose quality, Diátaxis classification | human judgment; tooling (vale, etc.) is Recommended, not Core |
The split is scope, not predicate: scoped co-change checks can block where adopted, while calendar-age checks advise. Adopting the shipped check-doc-sync.sh and its adjacent doc_sync_gate.py requires a committed docs/doc-pointers.yaml manifest and actual invocation by the project gate; they do not read a DOC_SYNC_BLOCKING feature flag. Once invoked, an absent committed manifest produces UNASSESSED (cannot assess) and exits 2, including a deleted mapping or one present only in the working tree or index. [skip-docs: <reason>] and SKIP_DOCS cannot waive missing assessment inputs. Non-adoption must be an explicit caller policy before invocation; missing configuration never implies a pass. The supported manifest is a restricted code/doc-pair syntax, not general YAML. Co-change proves neither factual correctness nor semantic agreement. Historical --dry-run reporting remains a separate co-change proxy and does not assess adoption readiness.
Gate mode pins one HEAD commit. The mapping and document type/existence come from that tree, while changed paths come from the selected immutable commit pair; dirty or untracked files cannot repair a committed discrepancy. The default compares with the first parent, or includes the root commit's additions. --base selects a tip-to-tip comparison, not an automatic merge base. The invoking protected pipeline owns base selection and policy changes; an agent-selected empty range or deleted manifest must not bypass the adopter's mandatory control. Renames count as deletion plus addition; NUL-delimited paths preserve newlines/tabs, while non-UTF-8 tree evidence is unassessed. Unavailable history or unsupported policy cannot be waived.
--dry-run N now emits a JSON historical co-change proxy, examining up to N non-merge commits ending at a pinned HEAD, including root commits. Its deliberately naive mapping is each code path's top-level name to docs/<name>.md. It checks that exact page's change and regular-file existence in the historical tree, not today's workspace. Observed and code-bearing commit counts are separate. Missing code scope, shallow history or failed reads are UNASSESSED (exit 2); successful analysis exits 0 even if all changes mismatch.
The JSON declares its case-sensitive code suffixes: .py, .ts, .tsx, .js, .jsx, .go, .rs, .java. The denominator does not cover every language. Git reads, including root discovery, are checked and timeboxed; roots containing control characters are unsupported and unassessed. This is a bounded local history tool, not proof of an authenticated repository or semantic documentation coverage.
The proxy leaves false_positive_rate and policy admission unassessed: human-reviewed labels are needed to determine whether flagged changes actually required documentation. A mismatch percentage is not a false-positive rate, and cannot automatically enable a mandatory gate. Adopters must define scope, measure useful catches and review burden, and authorize deployment of the control.
The [skip-docs: reason] path is considered only after valid assessment finds drift. It appends a JSON-line record to .s4u/skip-docs.log with the pinned HEAD/base, reason, mapping count and explicitly unauthenticated authority. A clean change does not log an unused waiver; a failed log write blocks. Commit messages and SKIP_DOCS do not authenticate an approving owner. Projects needing controlled waivers must enforce approval separately. Logs need their own audience, retention and integrity policy; this local receipt is not a signature or distributed transaction.
The generated-artifact freshness script compares committed output with regeneration and returns nonzero on differences or comparison errors. It has no warm-up flag; adopters still establish safe ownership, deterministic inputs and correct installation before requiring it. Reproducibility is not semantic correctness, complete source coverage or privacy approval. A time-dependent generator can legitimately differ; choose a reproducible projection or another explicitly scoped policy. Verify both unchanged output and a known stale case.
Why: enforcing every documentation concern as advisory is the weak form — subjectively-evaluated concerns end up sparsely evaluated. Tiering keeps the objective concerns honest (they block) while refusing to arm the one gate — blanket age — that trains bypass behavior.