Skip to main content

ADR-0167: Process rules get detectors — and where a rule cannot be enforced, the repo says so

Status: Accepted Date: 2026-08-01 Issues: #965, #966, #967, #968, #970 (methodology audit epic s4u-methodology#23)

Context

A methodology audit measured this repository's own process controls against merge history rather than against their documentation. Four of the most-cited controls turned out to be enforced by nothing:

RuleWhere it is statedMeasured state
Calibration Review Checklist (#965)CLAUDE.md:201 "MUST pass … all 8 checks"; checklist line 12 "Reviewers MUST block on an unticked box"0/40 recent merges retained the template section; 15/40 had no calibration section at all, several compliance-facing; every freehand rewrite sampled had dropped CR4
Tests must pass before PR merge (#967)CLAUDE.md:34; methodology "ENFORCED … blocks the merge"24 of the last 25 merges did not observe it: 21 merged before the gate finished, 3 merged after it was already red (#885 by 72 min, #880 by ~22 h). PR #891 merged with the ADR-0121 citation gate red
ADR register integrity (#966)Three docs instruct running scripts/check-adr-register.sh; two published pages assert it runs "in CI" and is "Blocking (fail-closed)"The file did not exist in this repo. Every documented invocation could only exit 127; one plan template wrapped it in 2>/dev/null ||, making "missing" and "failing" the same soft advisory
ADR gate registered (#970)appendix-g-adr.md prescribes check-adr.sh as a Stop hookRegistered in no settings file. Not blocking, and not warning either — a file with no caller. The register had drifted 14 entries in CLAUDE.md, 13 in docs/adr/README.md

This is the project's own named worst defect class — claim vs check: an artifact reporting a control's SHAPE while nothing observes its STATE. It had landed on the controls that guard everything else, including the checklist that ADR after ADR cites as the reason a compliance-facing change is safe.

Decision

Build a detector per rule, as a discovery mechanism for the next instance rather than a patch for the ones found. Four things follow from that:

1. Applicability is decided by measurable state, never by the author. The Calibration Review job derives applicability from the DIFF (scripts/check_calibration_review.py, compliance-facing path tokens drawn from CLAUDE.md's own enumeration). Each of the eight checks carries a stable slug CR1…CR8 so a freehand rewrite still matches and a dropped check — the measured failure mode — is detectable. Each must be dispositioned: [x], or N/A — <reason ≥ 15 chars>. Slugs appearing only in an HTML comment or a fenced block do not count.

2. A tick is a self-assertion, and the repo says so out loud. The one exception is a claim measurable state can refute: CR5 ("adversarial fixtures") ticked while the diff touches no test file fails. Beyond that, no PR-body detector can prove a check is TRUE. docs/calibration-review-checklist.md now carries a table naming exactly what is mechanically enforced and what is not, rather than a heuristic that would read as evidence while being none.

3. Where prevention is unavailable, observe. At authoring time no mechanism could require a check here (classic protection 404, both ruleset endpoints []) — that is the state under which all 24 baselined violations accrued. It changed while this ADR's PR was open: ruleset master required checks was armed 2026-08-01 21:36:44+02:00, one minute before the enforcement-state job's first run, which caught it and went red. Backend Tests, Frontend Tests, Lint and Calibration Review are now genuinely required. Observation remains, for a reason recorded in enforcement-state.json: repository admins hold an always bypass and the owner is the only human actor, so a red gate can still be merged past — silently, unless something observes it. scripts/check_merge_gate_observance.py therefore measures each merge's timestamp against the required jobs' own completed_at/conclusion — the state at the moment of merge, which the retrospective green tick in gh pr checks does not report. Historical debt is DECLARED in docs/process-gates/merge-gate-baseline.json, not deleted; the baseline is a ratchet (an entry may only describe a PR merged at or before its as_of), so a new violation cannot be silenced by widening it.

4. The enforcement claim itself gets a detector. docs/process-gates/enforcement-state.json is the single authority for the sentence "what actually blocks a merge here", and the enforcement-state job compares it to the live API on every run. A doc that says "ENFORCED" is now contradicted by a checked-in, machine-verified file; and if protection is ever armed, the job fails until the declaration is updated — the change cannot land silently in either direction.

Supporting changes: scripts/check-adr-register.sh vendored from the kit with a provenance header; scripts/check_adr_register_integrity.py wrapping it and additionally checking the CLAUDE.md register the kit script never looks at; both registers backfilled (13 + 14 rows added, none deleted); check-adr.sh registered as a third Stop hook; scripts/check_hook_registration.py asserting every gate script has a caller or a declared reason not to; and the pre-push hook rewritten so its description matches its behaviour, with a # GATE: manifest the same detector verifies.

Consequences

Positive. The four rules are observable for the first time. The register detector found and fixed 27 real drift rows across two registers. Registering check-adr.sh restores the ADR-presence question at session end. The pre-push hook no longer reports a missing ruff binary as "lint errors found" (#964's shim defect, encountered while making the hook honest).

Negative, and load-bearing. Of the five jobs added here, exactly one (Calibration Review) is a required status check; the other four are advisory and must not be described otherwise. Even a required check is bypassable by a repository admin. Anyone citing any of this in a compliance or conformity artifact must read enforcement-state.json first, which is machine-verified against the live API on every run. The merge-gate baseline ships with 24 declared violations; that number is the repo's actual debt, and it should shrink.

A defect this change caused, and caught. Arming the ruleset with the context Calibration Review while the job reported as Calibration Review (compliance-facing diffs) would have blocked every PR permanently on a check that could never report — the "a gate that cannot run is indistinguishable from a gate that passed" failure, pointing the other way. The job was renamed to match the context exactly, and compare_enforcement now fails when a required context is produced by no job in .github/workflows/. The name is now a contract, and it is checked.

Also negative. The calibration detector's coverage is a path allow-list, so a genuinely compliance-facing change on a path nobody anticipated is not caught. Widening it is a data edit; narrowing it silently shrinks the gate, which is why the exemption list is three entries long and no longer.

Alternatives considered

Enable branch protection. Measured unavailable at authoring time (both endpoints empty) and recorded as the exit_condition in enforcement-state.json rather than left implicit — then done, by the repo owner, while this PR was open. The detector's value is that the transition was observed within a minute rather than assumed in either direction; the exit_condition now describes disarming instead.

Require prose next to each ticked check. Rejected. It would prove prose was typed, not that a review happened — precisely the shape-not-state failure this ADR exists to end. Shipping it would have made the control look stronger while measuring nothing new.

Delete the drifted register rows to make the script green. Rejected outright. The register is the only human-readable index of 159 decisions the compliance story cites; "passing" by deleting entries is the defect, not the fix.

Uncomment pytest in the pre-push hook (#968's first option). Rejected: a 20-minute push gate is a gate that gets disabled. The hook now runs what it can run fast and states plainly what it does not run and where that runs instead — the option the issue itself offers as the alternative to "the template's honest '(Optional)' and the canon's normative 'must' cannot both stand".

Fail the merge-observance job on the historical debt. Rejected: it would be red for the next 25 merges and then ignored — this repo's own scar (a gate that cannot run is indistinguishable from a gate that passed). The ratcheted baseline follows the ADR-0121 citation-lockfile pattern instead.