A legacy-ledger migration sweep can drop a gate: line from a pre-existing canonical entry, pass deterministic validation, and commit the rewrite. The next dispatch then runs the story that entry was there to hold back.
_ensure_migration snapshots the pre-existing canonical entries as id → status only (sweep.py:717):
pre_canonical = {e.id: e.status for e in deferredwork.parse_ledger(text)}
and validate_migration checks only that the id survived and its status first-word is unchanged (sweep.py:279-284). Nothing in that function reads gate: — the other gate matches in sweep.py are the unrelated gates notification module.
Repro
Migrate a ledger whose canonical half carries a gate, and hand back a rewrite with the gate: line removed and everything else intact:
DW-1 gate tokens BEFORE: ('3-2',)
DW-1 gate tokens AFTER : ()
validate_migration errors: []
The rewrite is accepted. _refuse_gated_story then sees no gate, and story 3-2 dispatches.
Why it matters
This is the failure the field was added to prevent, reached by a route the field's own guard does not cover. deferred-work-format.md states the contract the migration breaks:
Like source_spec:, a gate: line is never edited or dropped when an entry is otherwise touched: removing it un-gates the story silently, which is the exact failure this field exists to prevent.
That sentence is instruction to the session, not a check. Migration is the one path where an LLM rewrites entries it did not author, so it is exactly where the instruction should not be the only thing holding.
Severity is bounded by reachability: it needs a project with a pre-DW-format ledger and a gated canonical entry, so it cannot affect a project that never carried legacy content. It is silent when it does happen, which is what makes it worth a check rather than a note.
Suggested shape
Extend the snapshot beyond status — the minimal form is id → (status, gate tokens), with validate_migration reporting a dropped or altered token the same way it reports a changed status. Preserving whole entry bodies would be stricter but would refuse the reformatting a migration legitimately does.
Worth deciding at the same time whether origin: and location: deserve the same treatment; source_spec: carries a comparable never-drop instruction and has no check either.
Provenance
Found by Codex reviewing #502, which adds the gate: field. Filed separately rather than folded in: sweep.py is not in that PR's diff, and the fix needs its own tests against the migration path.
A legacy-ledger migration sweep can drop a
gate:line from a pre-existing canonical entry, pass deterministic validation, and commit the rewrite. The next dispatch then runs the story that entry was there to hold back._ensure_migrationsnapshots the pre-existing canonical entries as id → status only (sweep.py:717):and
validate_migrationchecks only that the id survived and its status first-word is unchanged (sweep.py:279-284). Nothing in that function readsgate:— the othergatematches insweep.pyare the unrelatedgatesnotification module.Repro
Migrate a ledger whose canonical half carries a gate, and hand back a rewrite with the
gate:line removed and everything else intact:The rewrite is accepted.
_refuse_gated_storythen sees no gate, and story 3-2 dispatches.Why it matters
This is the failure the field was added to prevent, reached by a route the field's own guard does not cover.
deferred-work-format.mdstates the contract the migration breaks:That sentence is instruction to the session, not a check. Migration is the one path where an LLM rewrites entries it did not author, so it is exactly where the instruction should not be the only thing holding.
Severity is bounded by reachability: it needs a project with a pre-DW-format ledger and a gated canonical entry, so it cannot affect a project that never carried legacy content. It is silent when it does happen, which is what makes it worth a check rather than a note.
Suggested shape
Extend the snapshot beyond status — the minimal form is id → (status, gate tokens), with
validate_migrationreporting a dropped or altered token the same way it reports a changed status. Preserving whole entry bodies would be stricter but would refuse the reformatting a migration legitimately does.Worth deciding at the same time whether
origin:andlocation:deserve the same treatment;source_spec:carries a comparable never-drop instruction and has no check either.Provenance
Found by Codex reviewing #502, which adds the
gate:field. Filed separately rather than folded in:sweep.pyis not in that PR's diff, and the fix needs its own tests against the migration path.