What the rule is
commands/handoff.md:100 — "Do not mark it closed while <docsRoot>/programmes/<SLUG>/deferred.md has open rows." Each must be promoted to the repo-wide deferred-work.md with its fix shape intact, or killed with a reason recorded in the archive. templates/deferred.md:6 states the same obligation from the other side.
This is the rule that stops repo-wide items being archived alive when a programme's directory becomes history. It is the transition the whole two-file split exists to protect.
The gap
The rule is executed by a model reading handoff.md. Nothing observes whether it happened.
All three of these close a programme, and none consults deferred.md:
- running
/programme:handoff and skipping step 6
- editing
INDEX.md's status column directly
- closing on the user's say-so mid-session, without running
/programme:handoff at all
The resulting state — INDEX.md saying closed while that programme's deferred.md holds open rows — is one the two files jointly forbid and neither reports.
The only test asserts the wording
tests/test-templates.sh:84:
assert_contains "$D" "cannot be closed" "deferred: promotion obligation"
That checks the sentence is present in the template. It passes identically whether or not any close ever honoured it.
This is not a criticism of the assertion — you cannot unit-test whether a model followed a prose instruction. The point is that there is no other observation either.
The asymmetry is visible in the suite
| implemented as |
how it is tested |
shell — lib.sh + the three hooks |
62 behavioural assertions across 4 files |
| model instructions — 4 commands, all templates |
76 assert_contains against markdown text |
The plugin already spends a hook on a softer obligation: hooks/stale-ledger.sh fires when a session changed code and the ledger did not move. That is a judgement call, and it is deliberately non-blocking and latched to once per session. The close rule gets no hook.
Field evidence — n=2, one repo, one author
Two programmes in the same repo closed over their own open rows, four days apart, by different mechanisms:
- 2026-08-22 — a check was run and was wrong.
grep -c '^### ' read 2 while 48 rows existed: older rows were table rows and only the two newest were ### headings. Found 6 days later.
- 2026-08-26 — no check was run. The close commit does not touch
deferred.md at all. Found 4 days later.
Worth noting the first one's shape: a hand-rolled check written against the file's newest formatting convention and blind to its oldest. Whatever ends up observing this should not be a grep for a heading style.
Deliberately not proposing a fix
The mechanism is a design call and the obvious candidates each cost something — a blocking gate on INDEX.md edits is intrusive, a Stop-hook detector fires only after the close commit lands, and a stricter row schema trades expressiveness for checkability.
One caution from the same field data. My rows outgrew the template badly: the shipped example row is one line, while my median row is 27 lines and the longest is 212. That is my repo being an outlier, not the template being wrong — and a fix sized against those numbers would be overfitted to a single user. The gap reported here is the one that holds regardless of row size, row format, or programme scale.
What the rule is
commands/handoff.md:100— "Do not mark it closed while<docsRoot>/programmes/<SLUG>/deferred.mdhas open rows." Each must be promoted to the repo-widedeferred-work.mdwith its fix shape intact, or killed with a reason recorded in the archive.templates/deferred.md:6states the same obligation from the other side.This is the rule that stops repo-wide items being archived alive when a programme's directory becomes history. It is the transition the whole two-file split exists to protect.
The gap
The rule is executed by a model reading
handoff.md. Nothing observes whether it happened.All three of these close a programme, and none consults
deferred.md:/programme:handoffand skipping step 6INDEX.md's status column directly/programme:handoffat allThe resulting state —
INDEX.mdsayingclosedwhile that programme'sdeferred.mdholds open rows — is one the two files jointly forbid and neither reports.The only test asserts the wording
tests/test-templates.sh:84:That checks the sentence is present in the template. It passes identically whether or not any close ever honoured it.
This is not a criticism of the assertion — you cannot unit-test whether a model followed a prose instruction. The point is that there is no other observation either.
The asymmetry is visible in the suite
lib.sh+ the three hooksassert_containsagainst markdown textThe plugin already spends a hook on a softer obligation:
hooks/stale-ledger.shfires when a session changed code and the ledger did not move. That is a judgement call, and it is deliberately non-blocking and latched to once per session. The close rule gets no hook.Field evidence — n=2, one repo, one author
Two programmes in the same repo closed over their own open rows, four days apart, by different mechanisms:
grep -c '^### 'read 2 while 48 rows existed: older rows were table rows and only the two newest were###headings. Found 6 days later.deferred.mdat all. Found 4 days later.Worth noting the first one's shape: a hand-rolled check written against the file's newest formatting convention and blind to its oldest. Whatever ends up observing this should not be a grep for a heading style.
Deliberately not proposing a fix
The mechanism is a design call and the obvious candidates each cost something — a blocking gate on
INDEX.mdedits is intrusive, aStop-hook detector fires only after the close commit lands, and a stricter row schema trades expressiveness for checkability.One caution from the same field data. My rows outgrew the template badly: the shipped example row is one line, while my median row is 27 lines and the longest is 212. That is my repo being an outlier, not the template being wrong — and a fix sized against those numbers would be overfitted to a single user. The gap reported here is the one that holds regardless of row size, row format, or programme scale.