ADR-0168: STATE.md Gets a Freshness Detector, Not Another Promise to Update It
Date: 2026-08-01 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus (implementation + analysis), methodology audit epic Tsunami-max/s4u-methodology#23 (finding, adversarially verified)
Decision context:
- Latency: no runtime impact — this is a repo-hygiene check. It runs as its own CI
job in a few seconds (git-only; no network, no
gh, no test suite). - Dependency surface: none added.
scripts/check-state-freshness.shusesgit,awk,grep,dateandwc— all already required by the existingscripts/check-docs-sync.shand the.claude/hooks/*set. The CI job re-usesactions/checkout@v7, already present four times in this workflow. - Debuggability: strictly better than the status quo, which produced no signal at all. The checker prints the four measured quantities (commit distance, age, table rows, size) with their limits on every run — pass or fail — so the trend is visible before it becomes a failure.
Context
CLAUDE.md:5 reads: "Fresh agent? Read STATE.md first — it answers what is happening
right now … Read both before doing anything." That makes STATE.md the mandatory first
read of every agent session in this repo. It is load-bearing onboarding.
On 2026-08-01 the methodology audit measured what that file actually contained:
| Measure | Value | Contract |
|---|---|---|
Commits on master since STATE.md last changed | 241 | — |
| Calendar age | 9 days | 30-day cadence floor (appendix-J) |
Last updated: header | 2026-07-14 | file body said 2026-07-22, git said 2026-07-23 |
| "Last Shipped" data rows | 42 | 7 (appendix-J anti-pattern 2) |
| File size | 158 lines / 46,604 bytes | ~30-50 lines (appendix-J template) |
| Newest PR in "Last Shipped" | #485 | master was at #942 |
Its "Blockers" section read "None blocking." Its "Next Up" listed items closed weeks earlier. Four epics (#528 AMLR, #511 OSINT, #848 Detection Supremacy, #913/#927 provenance) and roughly 35 ADRs had landed since it was written, none of them reflected.
appendix-j-state-md.md:3 is unambiguous about this state: "Canon v3: STATE.md is
GENERATED or ABSENT — never hand-maintained", and :106: "a stale STATE.md is worse
than a missing one because it actively misleads." This project adopted that rule
verbatim in its own .claude/s4u-operating-card.md:111. It then did not follow it, and
nothing noticed, because the rule's only enforcement was that someone would remember.
That is the shape of the defect, and it is the one this repo already has a name for:
a control whose claim is documented and whose check does not exist
(feedback_claim_vs_check). Rewriting STATE.md fixes today's instance. It does not fix
the class — a file that went 241 PRs stale once will do it again, on the same mechanism,
within a month.
Decision
Two moves. Only the second one lasts.
1. Rebuild STATE.md against machine state, and say plainly what is not verified
STATE.md is rewritten to the appendix-J shape (88 lines / 5,071 bytes; 5-row Last
Shipped) from git log, merged PRs, the ADR register, the open-issue tracker and the
Alembic revision graph. The 46 KB narrative moves to
docs/history/STATE-archive-2026-07-23.md behind a header that says it is not current —
the history is preserved, its authority is not.
The rebuilt file carries a section the template does not have: "Uncertain — do not treat as settled." Three items are listed there (dark-launch flag state, the age of the last live OB Holding validation, suite health) because they could not be confirmed from machine state inside this change. A STATE.md that confidently reports a stale fiction is worse than one that admits a gap — the entire value of the file is that a fresh agent trusts it, and trust survives an acknowledged unknown far better than it survives a confident wrong answer.
One claim in the file is verified rather than inferred: master has no branch
protection. gh api repos/.../branches/master/protection returns
404 Branch not protected. It is recorded as a blocker, citing #963.
2. scripts/check-state-freshness.sh — the detector
Exit 0 fresh · 1 stale or non-conformant · 2 cannot evaluate. Two arms:
Staleness. Commit distance on the default branch since the last commit touching STATE.md, plus calendar age.
appendix-J conformance. Age alone is insufficient — a file updated on schedule can
still be the 46 KB journal. So the checker also asserts: the five required sections are
present; a Last updated: YYYY-MM-DD header exists and agrees with when the file
actually changed (this is the check that catches the exact 2026-07-14-vs-07-23
self-contradiction found in the audit); the Last Shipped table is ≤ 7 rows; the file is
within 120 lines / 12,000 bytes.
Fail-closed throughout: a shallow clone, an unparseable date, or an untracked STATE.md exits 2 rather than passing. Absence is handled asymmetrically and deliberately — appendix-J says absent beats stale, so a missing STATE.md passes only if CLAUDE.md no longer points at it. A missing file that every agent is still ordered to read first is a dangling first-read, and exits 2.
A STATE.md with pending changes is treated as distance 0. That is not a loophole: it encodes appendix-J's own lifecycle rule that "STATE.md travels in the feature commit". The way to satisfy this gate is to update the file in the change that made it stale.
Threshold: 100 commits, and here is the arithmetic
The caller's constraint is the real design pressure: a check that fires constantly gets ignored, which is the same as no check. This repo merges 13 PRs a day. A naive "STATE.md must be current" gate would fire on every push and be disabled within a week.
The unit is commits on the default branch. On this repo every merge is a squash, so
commit distance is merged-PR distance — over the audit window git rev-list --count
returned 241 and gh pr list returned 241, exactly. That makes the unit identical to
appendix-J's cadence trigger ("a task completes and merges") while needing only git.
Velocity over the 90 days to 2026-08-01, across 41 active days:
| Statistic | Commits |
|---|---|
| Median active day | 16 |
| p90 active day | 47 |
| p95 active day | 50 |
| Busiest single day | 74 (2026-06-28) |
| 30-day total | 481 (~16/day) |
| Observed rot | 241 |
COMMIT_LIMIT = 100:
- 100 > 74, the busiest day ever recorded here. A single working session — this project's normal unit of work — can never trip it. This is the constraint that keeps the check credible.
- 100 ≈ 2× p95. It takes two consecutive heavy sessions, or about six days at the 30-day mean rate, to trip. That is comfortably inside appendix-J anti-pattern 4's two-week "actively misleading" line, so the gate fires while the file is merely behind rather than after it has become fiction.
- 100 ≪ 241. The failure this exists to catch reproduces at 2.4× the limit — not marginally over it.
DAY_LIMIT = 30 is appendix-J's cadence floor imported unchanged. It is the dormancy
arm: a project committing nothing still gets caught, where a commit-count gate alone
would sit silently at zero forever. Neither arm subsumes the other; both are required.
Consequences
Positive. The rule now has a mechanism instead of a habit. The failure mode is self-correcting — the fix is to edit the file the gate names. Conformance is checked in addition to age, so the 42-row journal shape cannot come back under a fresh timestamp. The measurements print on every run, so drift is visible at 60 commits rather than discovered at 241.
Negative / honest limits.
- This job does not actually block a merge today.
masterhas no branch protection (#963), so every CI job that says "blocks" is advisory here, this one included. It exits non-zero and explains why; whether that stops anything is a repo setting owned by the repo owner. It must not be counted as an enforced control in any conformity artifact until #963 lands. Recording this is the whole point of ADR-0157's precedent: a gate that cannot run is indistinguishable from a gate that passed, and the same is true of a gate nothing consults. - STATE.md remains hand-authored, so this repo is still formally outside Canon v3.
The canon's answer is
templates/scripts/generate-state-md.sh. That generator was examined and deliberately not adopted: its output is Recent commits / Open PRs / Branch / Migration head — machine facts, none of the four sections appendix-J actually requires. Current Focus, Blockers and Next Up are judgements that cannot be derived from git; generating the file would satisfy the canon's letter by deleting the content that makes it worth reading first. The chosen position is: keep the judgement, and mechanise the property the canon was protecting (freshness). The residual divergence from Canon v3 is stated here rather than papered over, and generating the machine-derivable half while keeping the judgement half authored is a live follow-up. - The 120-line / 12,000-byte bound is a proxy for appendix-J's prose anti-patterns, not a measurement of them. A terse but wrong STATE.md passes. Nothing here checks that the content is true — only that it is recent and correctly shaped.
Alternatives considered
Delete STATE.md and the CLAUDE.md pointer. Canon-compliant and cheap, and the audit offers it as an option. Rejected: the operational orientation is genuinely useful, and deleting it moves the onboarding cost onto every future session instead of removing it.
Adopt generate-state-md.sh as-is. Rejected for the reason above — it would replace
four judgement sections with a git log excerpt.
Make it advisory (warn-only), like check-doc-staleness.sh. Rejected as the default.
Advisory is precisely what produced the 241-commit rot: the methodology's own staleness
hook never blocks and, as the audit verifier noted, keys on a last_verified marker
that only the generated form emits — so it was blind to this file by construction. A
--warn-only flag is retained for callers that genuinely want reporting.
Threshold at one session (~20-25 commits). Rejected. It would fire on most working days, and a check that fires constantly gets turned off.