A blocker declares what would show it is gone — or declares that nothing could, and names what settles it instead

Proposed

ontoref
MEASURED 2026-09-06, in one consumer project, in a single day: FIVE governance

Context

MEASURED 2026-09-06, in one consumer project, in a single day: FIVE governance claims describing work that was already done.

· `gobierno-proyecto`.blocker said «neither of the two has commits» — both had had them for two days. · `control-de-versiones`.blocker said «repo-vacio: 0 commits, 0 tracked files» — 64 files were tracked. · A blocker written that same session said «nothing has been pushed» — the initial commits had been pushed 48 hours earlier. Found only because `ls-remote` was run before pushing rather than trusting the sentence. · Two backlog items described work completed in the session that was reading them.

NONE OF THEM BROKE ANYTHING, and that is the whole finding. A stale blocker does not lie about the future — it preserves a past that stopped applying. It passes every check, because there was never a check: `blocker` is a String, and a String is true forever.

THE COST IS PAID BY THE READER, LATER. The list of «what is pending» is the one surface a person consults to decide what to do next. An entry that describes a solved problem sends them to re-solve it, and the fifth case above was found precisely while composing that list for a human who had asked «what else is pending».

THE WARNING EXISTED AND DID NOT WORK. Every `state.ncl` in this ecosystem opens with «`blocker` es el campo que se queda obsoleto primero: al revisarlo, comprobarlo contra la realidad antes de darlo por bueno». It is accurate, it is prominent, it is at the top of the file — and the field went stale five times under it in one day, three of them written by the same agent that had just read it. A WARNING IS NOT A MECHANISM. That sentence is the ADR.

WHAT ALREADY EXISTED AND WHY IT DOES NOT REACH. `validate state` runs `state-check-fsm`, which reports dimensions whose `current_state` differs from `desired_state` — the DISTANCE, never whether the reason given for it still holds. And `verify-state` (the mode whose trigger is literally «what is left? is this finished?») derives every step from a real exit code, exactly as it claims — but it can only derive what the substrate declares, and the substrate declared prose. The gap was never in the harness. It was that `blocker` had no oracle.

THE PRECEDENT IS ONE LAYER UP. The constraint layer already met this shape and answered it: a claim that cannot be gated declares `gateable = false` with a `gate_source` naming what WOULD verify it, and `governs confessions` checks the freshness of those. This is the same cut applied to the FSM layer, where the same silence was costing the same thing.

Decision

A TRANSITION MAY DECLARE HOW ITS BLOCKER WOULD BE SHOWN GONE. Two optional fields on `transition_type`, and a check that runs them.

(1) `resolved_when` — a shell command. EXIT 0 MEANS THE BLOCKER NO LONGER HOLDS, so a zero exit is a FINDING and not a pass.

THE NAME IS THE DESIGN. `check` or `falsifier` would be read with the opposite sign by half their users, and a polarity bug here is silent in the dangerous direction: it would report every live blocker as stale. The verb says which way it points, and no comment has to carry that weight.

NON-ZERO ALSO COVERS «could not run». An unreachable machine cannot testify that its own blocker dissolved. The default is silence, never a false all-clear.

(2) `unfalsifiable = { reason, settled_by }` — present when `resolved_when` is absent BY DECISION rather than by omission.

NOT A LESSER ANSWER. «The HTML receipt layout at 72mm is untested» is settled by paper, not by an exit code. Forcing a command there produces a check that always passes, which this protocol has already paid for once: adr-009's `no-component-with-majority-pending` carries `check = { cmd = "true" }`, a validator that has never validated anything while its ADR reads as enforced.

(3) BOTH OPTIONAL, and the checker reports THREE POPULATIONS:

falsifiable-and-stale reported: the declaration outlived its fact falsifiable-and-holding fine UNDECLARED counted, and kept distinct from (2)

Requiring either field would break every `state.ncl` in the ecosystem on the day this lands — formalization-vs-adoption. And collapsing the third into the second would let the check report a completeness it does not have: a checker that cannot tell «unaddressable» from «undeclared» publishes coverage it lacks.

(4) ONLY ACTIVE TRANSITIONS ARE ASKED — those whose `from` equals the dimension's `current_state`. A blocker on a future transition asserts nothing about now, and reporting it would be the same category error this check exists to close.

(5) SOFT SEVERITY. A stale blocker is a real defect and not one that should stop a release. A check that is permanently red teaches its reader to ignore it — the failure `state-check-fsm` already names in its own comment.

Constraints

  • Hard `state-check-blockers` MUST be composed into `validate state`, not offered as a separate command
  • Soft A `resolved_when` MUST NOT call `validate state` or any command that runs it: the field is executed BY that command

Alternatives considered

  • A `checked_at` timestamp per blocker, reported when older than a horizonrejected: Measures when somebody last LOOKED, not whether the claim holds, and invites stamping without checking. Three of the five measured cases had been read in the same session in which they were wrong — freshness would have called them fresh.
  • Require every transition to declare one of the two fieldsrejected: Breaks every state.ncl in the ecosystem the day it lands, which is the mandatory-gate failure formalization-vs-adoption exists to refuse. The undeclared population is reported instead, so the pressure is visible without being a wall.
  • Hard severity, so a stale blocker fails the buildrejected: A check that is permanently red gets ignored, and blockers are normal — most of them legitimately hold. A hard gate here would be disabled within a week and would take the honest findings with it.
  • Derive staleness from git history — flag any blocker untouched for N commitsrejected: A blocker that has not been edited is not thereby wrong; the machine-off blocker in the motivating project has been correct and untouched for days. It would flag the accurate ones and miss the freshly-written false one, which is exactly the case that was found.

Anti-patterns

  • Documenting that a field goes stale instead of making staleness detectable — The header of every state.ncl warned that `blocker` decays first and told the reader to verify it against reality. It is correct advice at the top of the file, and it was violated five times in one day beneath itself — three of those by an agent that had read it minutes before. Advice addressed to whoever remembers to follow it is not a control.

Related ADRs

ADR-066 · ADR-088 · ADR-109

Was this useful? Rate it
Got something to add? Tell me what you think, what you'd suggest, or whether we should keep exploring this topic.
· reads

We use cookies to help this site function, understand service usage, and support marketing efforts. Cookie Policy for more info.