Plan Decay Detector: halt when the plan stops describing the repo
Bob's failure mode, from inside it the week of the conversation: "you make all these plans and then as the agents are running, you realize that they can't follow that plan because you didn't think of everything… they're running half-cocked off on some nonsense that you have to stop, back up, rewrite the plan" (C19). And the seduction that keeps producing them: "The agents love to write plans… the plans will be gorgeous and beautiful… And then they fall apart at the end" (C19). Every quote here comes through the ledger, never from memory.
The word that matters is end. The plan does not announce its own death. It is discovered dead, at the end, after a batch of agents has already spent itself against it. This island moves that discovery to the front of the batch and makes it mechanical. A prose instruction to "check the plan is still valid" decays in the middle of a context exactly like every other prose rule (CONTEXT.md, law 2); a tool in a fix-until-green loop does not (C4).
What actually decays
The plan is not wrong when it is written. The tree moves under it, from three directions:
- Execution reveals what planning could not. The planner did not think of everything (C19): the interface is shaped differently than assumed, the module is already gone.
- A parallel agent lands first. Three coders can run at once (C10); the file your plan was going to create already exists, written by a sibling.
- A human reorganised. The cadence's manual sort-out step (C20) rewrites structure between batches, and every queued plan aged the moment it ran.
This is a tooling gap, not just a discipline gap. The spec-driven tooling that exists has the same hole: AWS Kiro does not auto-detect implementation/spec divergence, so its specs silently drift from the code (atdd-gherkin-agile, citing a doit.com review of Kiro). The brief names Spec Kit and OpenSpec only for the separate "reinvented waterfall" critique, so this is one reviewer's report about one tool, not a measurement and not a survey of the category. Silent drift is the whole disease. A plan that cannot be checked cannot go stale loudly.
The move: make the plan state its beliefs in checkable form
A plan is admitted to this gate only if it writes down what it believes about the tree. Five kinds, one per line, TAB-separated, in a file that travels with the plan:
| Kind |
Fields |
The belief it records |
exists |
PATH |
"this is here, and my batch reads or edits it" |
absent |
PATH |
"nothing is here yet, and my batch creates it" |
contains |
PATH, LITERAL |
"this file still holds the symbol/marker I am extending" |
lacks |
PATH, LITERAL |
"this file still does not hold the thing I am about to add" |
unchanged |
PATH, SHA256 |
"this file is exactly the text I read when I planned" |
unchanged is the strong form and the one that catches a sibling agent's edit; contains/lacks are the cheap forms for a plan that only cares about one seam. Writing them is the work this island asks of the planner, and it is the reason the plan becomes falsifiable instead of gorgeous.
The check
python3 scripts/plan-decay.py --root . docs/plan/batch-07.assumptions.tsv
python3 scripts/plan-decay.py --root . --digest src/payments.py # to author an `unchanged` row
--root is required and has no default. It used to default to ., which aimed the whole plan at whatever directory the process happened to sit in, without saying so. A create-only plan, every row absent, then held vacuously against an unrelated tree and exited 0. A CI step with the wrong working directory, or an agent shelling out from a subdirectory, is an ordinary condition rather than an attack. And an exit-code-consuming caller cannot see against . in the report. Omitting it is now argparse's usage error, exit 2.
| Exit |
Meaning |
| 0 |
every stated assumption still holds — and --help, which prints usage and computes no verdict, so a 0 is a verdict only when the report says CHECKED n assumption(s) |
| 1 |
at least one assumption diverged — HALT and re-plan |
| 2 |
usage, IO, or malformed input — never a verdict, always fail-closed |
| 3 |
informational: --digest printed a digest and checked no plan |
0, 1, 2 and 3 are the only codes this script produces, and every fault leaves through 2. The faults: an unreadable directory; a target file that is not UTF-8 text; an assumptions file that is a directory or not UTF-8; a malformed row; a missing --root; argparse's usage exit; a SystemExit carrying a non-integer payload; a stdout closed before the run or broken during it, including the flush CPython performs at shutdown, where the status would otherwise become 120; and any unexpected exception. On that non-integer payload the pack's stock tail returns 1. This script raises no SystemExit of its own, so one arriving at the seal is a fault rather than a divergence, and it leaves through 2. The coercion covers 3 as well as 0 and 1: a --digest run whose flush fails printed no digest, so it must not leave claiming it did. None of them may borrow a verdict's code. A caller that reads 1 records a divergence this run never computed; a caller that reads 0 records a plan this run never checked.
What the gate refuses to guess
- One key function for every path. Components are matched against real directory entries after NFC folding, the one fold that is genuinely the same file and routine on macOS. A name that matches only case-folded is reported as a variant and never treated as a match, in either direction:
absent src/refunds.py diverges when the tree holds src/Refunds.py, and so does lacks. There is no lenient branch for a case respelling. Three further respelling classes never reach a branch at all, because they are refused when the row is read (exit 2). Invisible characters: U+FEFF, U+200B and the rest of Unicode Cf/Cs/Co. Compatibility forms: r U+FF52 FULLWIDTH LATIN SMALL LETTER R, caught by NFKC(s) != NFC(s), where NFKC is a refusal test and never the matching fold, because it is lossy for filenames. And a word that mixes scripts: rеfunds with U+0435 CYRILLIC SMALL LETTER IE. None of the three can ever equal the entry it imitates, so locate would report missing and every negative kind would hold vacuously. The mixture test runs per word, not per line, so a multilingual repo still checks normally: Описание.md, コーヒー.py and src/test_日本語_helper.py all pass, since a real line mixes scripts between words while a respelling mixes them inside one. Both fields are covered, because a confusable in a lacks literal is the same false green and is refused too. What is not closed: a whole-word confusable. Every letter of one word respelled into one other script, асе all-Cyrillic for ace, mixes nothing, keys as missing, and still holds for a negative kind; scripts/fixtures/blind-spot-confusable/ ships that as a captured exit-0 run rather than a sentence. Absolute paths, drive letters, .., backslashes, control characters and whitespace-padded components are refused outright rather than rerouted. Two entries that normalise to one name is an ambiguity the gate refuses to resolve (exit 2).
- A missing file never satisfies a negative.
lacks and contains both diverge when the named file is not present under that spelling, so deleting a file can never quietly make a lacks row pass.
- No line with content is dropped in silence. A blank line is skipped, including one holding only spaces or TABs, which carries no assumption. Every line that holds content is either a comment or a checked row, never a silent skip. A comment is a line whose first non-blank character is
# and that holds no TAB; a # line that does hold a TAB is ambiguous and is refused out loud. A BOM is stripped (utf-8-sig) and CRLF is handled, because that is what an editor hands you. An unknown kind, a wrong field count, a non-hex digest, a blank literal, or a literal padded with whitespace is a refusal, not a skip. A single trailing space is the most ordinary editor artefact there is, CRLF stripping does not remove it, and it would silently turn a lacks row into one that can never be found and therefore holds forever.
- An empty plan is not a passing plan. An assumptions file with zero rows exits 2. "No assumptions, therefore no decay" is the laundering this gate exists to refuse.
- Digests are over normalised text. UTF-8 decoded (BOM stripped), line endings folded to
\n, NFC applied, then SHA-256. A checkout's line-ending policy therefore cannot fake a divergence. --digest prints exactly what an unchanged row needs.
What a halt means
Exit 1 is not a warning to note and continue. It is "stop, back up, rewrite the plan" (C19), and the re-plan is budgeted as the normal cost of a cycle, not logged as failure. That budgeting is story-cadence's doctrine. Two things follow immediately:
- Re-plan from the repo as it now is, not by patching the old plan. The old plan's remaining assumptions were written against a tree that no longer exists.
- The context that was already steered by the stale plan is contaminated. A window has momentum: "the only way to clear the trajectory is to clear the context window" (C11). An agent that has been working from the dead plan therefore does not get corrected, it gets replaced. Deciding that is
trajectory-hygiene's seat, not this island's; this island only rings the bell.
And when the halt is contested, an agent's argument that the divergence is harmless is a hypothesis: "you can't trust any debate you have with an agent" (C18). The digest is the evidence; the opinion is not.
Enforced vs advisory
enforced — the divergence verdict. scripts/plan-decay.py computes every stated assumption against the tree under --root. It exits 1 if any diverged, 0 only if all held, and 2 fail-closed on every non-verdict outcome except argparse's --help, which exits 0 and prints no CHECKED line (see the exit table); a --digest that printed leaves through 3. The island's own shape is enforced by the pack validator (scripts/validate-island.py at the pack root).
advisory — whether the assumptions are the ones the plan actually rests on. The gate checks the beliefs that were written down; it cannot check the beliefs that were not. A plan can pass with vacuous rows, and scripts/fixtures/blind-spot-vacuous/ ships that as a captured exit-0 run rather than a sentence. Also advisory: a whole-word confusable path, one word respelled entirely into a single other script, is not refused and keys as missing, so it holds vacuously for absent/lacks (scripts/fixtures/blind-spot-confusable/, the second captured exit 0). Mixed-script and compatibility respellings inside a word are refused, and a whole-word one is reachable only by an author deliberately writing one. Also advisory: contains/lacks are literal substring tests over the whole file, so a match inside a comment or a string literal counts; reach for unchanged when that matters. exists asks whether a directory entry of that name is there, so a broken symlink holds. A symlinked component leads where it links, so an assumption can reach outside --root through a link in the tree. And when to run the check (every batch boundary, every resume, after every merge) is discipline: no hook wires it into a fleet today.
Red/green proof
The gate earns its enforced line by having been watched failing: the known-dirty-fixture ritual. That island's "the pair is necessary, not sufficient" clause is why the fault block below is longer than the pair. One assumptions file, two trees: the same input goes green on the tree the plan was written against and red on the tree that moved. Recompute from this island's directory:
python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/batch-plan.assumptions.tsv # exit 1
# HALT: refunds.py already created by someone else; `def charge(` renamed away; `def refund(` appeared; digest moved
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/batch-plan.assumptions.tsv # exit 0
# PLAN HOLDS: 6 held, 0 diverged
python3 scripts/plan-decay.py --root scripts/fixtures/variant-spelling scripts/fixtures/batch-plan.assumptions.tsv # exit 1
# the tree respelled two paths: `absent src/refunds.py` must NOT pass because the tree holds src/Refunds.py
python3 scripts/plan-decay.py --root scripts/fixtures/unicode-nfd scripts/fixtures/unicode-nfd/assumptions.tsv # exit 0
# the NFD name on disk and the NFC path in the plan are one file, not a divergence
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/bom-crlf.assumptions.tsv # exit 0
# a BOM'd, CRLF assumptions file still parses; unstripped, the BOM makes row 1 an unknown kind
python3 scripts/plan-decay.py scripts/fixtures/create-only.assumptions.tsv # exit 2
# --root omitted. Every row is `absent` - the create-only shape - so with the old
# default of '.' this printed `against . - 3 held` and exited 0 from any unrelated
# cwd, a false green on the island's own verdict. It is now argparse's usage exit.
python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/create-only.assumptions.tsv # exit 1
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/create-only.assumptions.tsv # exit 0
# the same create-only plan, aimed: red on the tree that already holds src/refunds.py,
# green on the one that does not. The root is now the thing the caller must state.
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/empty.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/hash-tab.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/empty-literal.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/unknown-kind.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/field-count.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/bad-digest.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/absolute-path.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/dotdot.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/backslash.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/padded-path.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/non-utf8.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/padded-literal.tsv # exit 2
# `lacks src/payments.py 'def refund( '` - one trailing space. Before the fix this exited 0
# on the plan-decayed tree with `def refund():` sitting in the file: a false green on this
# island's own load-bearing row. Leading space, trailing TAB and the CRLF variant too.
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/format-char-path.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/format-char-literal.tsv # exit 2
# an invisible respelling: U+FEFF inside a path, U+200B inside a literal. Both used to
# match nothing and hold forever; now neither can be written at all.
python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/confusable-path.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/fullwidth-path.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/confusable-literal.tsv # exit 2
# a VISIBLE respelling - the invisible one's twin. `absent src/rеfunds.py` with U+0435
# CYRILLIC SMALL LETTER IE, `absent src/refunds.py` with U+FF52 FULLWIDTH r, and the
# same Cyrillic letter inside a `lacks` literal. All three returned `PLAN HOLDS` and
# exit 0 against this decayed tree before the fix - the third against a file that
# holds `def refund(` right there. Aimed at plan-decayed so the refusal cannot be
# mistaken for the tree simply not having the file.
python3 scripts/plan-decay.py --root scripts/fixtures/binary-target scripts/fixtures/binary-target/assumptions.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/nope scripts/fixtures/batch-plan.assumptions.tsv # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds # exit 2
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/batch-plan.assumptions.tsv >&- # exit 2
bash scripts/fault-probes.sh # exit 0
python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds --digest src/payments.py # exit 3
python3 scripts/plan-decay.py --root scripts/fixtures/blind-spot-vacuous scripts/fixtures/blind-spot-vacuous/assumptions.tsv # exit 0
# LIMIT, not a pass. Both rows are true and neither names src/payments.py, the module the batch
# was written to edit, which is gone. The gate sees only what the plan chose to write down.
python3 scripts/plan-decay.py --root scripts/fixtures/blind-spot-confusable scripts/fixtures/blind-spot-confusable/assumptions.tsv # exit 0
# The second LIMIT. `absent src/асе.py` is 'ace' with every letter respelled into
# Cyrillic - one word, one script, so the mixed-script refusal has nothing to catch -
# while src/ace.py sits in the tree. Captured as a run rather than named as closed.
# The interpreter's 120 - a std-stream flush failing at SHUTDOWN - never reaches an in-run
# handler, because argparse exits before them. Both of these are 120 when the same file is
# given a plain `sys.exit(main())` tail instead; that counterfactual was run, not assumed.
python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(
[sys.executable,"scripts/plan-decay.py","--nope"],stderr=w,stdout=subprocess.DEVNULL).returncode)' # 2
python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(
[sys.executable,"scripts/plan-decay.py","--help"],stdout=w,stderr=subprocess.DEVNULL).returncode)' # 2
# And the informational code, which asserts a digest was PRINTED: with stdout dead it used
# to leave through 3 with nothing written. It leaves through 2.
python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(
[sys.executable,"scripts/plan-decay.py","--root","scripts/fixtures/plan-holds",
"--digest","src/payments.py"],stdout=w,stderr=subprocess.DEVNULL).returncode)' # 2
Thirty-six runs, each proving one thing the others cannot. The pair is plan-decayed versus plan-holds on one assumptions file, so a green cannot come from a friendlier input. variant-spelling is the input a plain Path.exists() answer would wave straight through. On a case-sensitive filesystem it reports src/refunds.py as still absent while a sibling agent's src/Refunds.py sits right there, and the plan proceeds to create a second module. Here it halts. bom-crlf and unicode-nfd are the two inputs an ordinary editor and an ordinary macOS checkout produce; decoded as plain UTF-8, the BOM'd file's first field is '# saved by a Window…', an unknown kind.
The seventeen malformed rows are refusals, not skips: each is a shape a line-oriented parser plausibly swallows in silence. Three of them were watched consenting before the fix: a lacks literal with one trailing space, and the same row respelled with U+FEFF in the path or U+200B in the literal. Each returned PLAN HOLDS and exit 0 against a tree that had already diverged. Three more were forged and watched consenting in the session that closed them, the visible respellings, which have the invisible one's mechanics exactly. absent src/rеfunds.py (U+0435), absent src/refunds.py (U+FF52) and lacks src/payments.py 'def rеfund(' each printed PLAN HOLDS and exited 0 against plan-decayed, the last of them while def refund( sat in the named file.
create-only.assumptions.tsv is the same discipline applied to the root. Run with no --root, it was watched printing against . - 3 held, 0 diverged and exiting 0 from an unrelated cwd; it now leaves through argparse at 2. Four faults were watched taking a consenting code on this script before the fix: the three padded/invisible rows above, plus a run whose stdout was closed before it started. That one exited 0 with the whole report silently discarded, because CPython sets sys.stdout to None and print then does nothing at all. The 120 pair is the measured counterfactual above, not a remembered one. fault-probes.sh captures what a repo cannot store: an unreadable subdirectory, an un-encodable report proving the BaseException seal is live, an assumptions path that is a directory, and a normalisation collision that skips out loud on a filesystem that folds NFC and NFD. Deleting any fixture returns the gate to unverified.
Boundaries — who owns what
- How big a batch should be — the small-batch argument and its $1-house economics are
story-cadence's doctrine (C20, C21). This island says nothing about batch size; it only detects that the batch's plan stopped being true.
- Ticket machinery — turning a plan into specs, tickets with blocking edges, and implementation passes is
spec-pipeline's seat. This island reads an assumptions file; it never issues, orders, or closes a ticket.
- Killing a contaminated context — an agent already steered by the dead plan carries that trajectory (C11), and whether to continue or kill and respawn is
trajectory-hygiene's seat.
- Gate acceptance — the red/green ritual this island submitted to belongs to
known-dirty-fixture; the fixtures above are that island's rule applied here.
Done when
A plan that cannot be checked cannot go stale loudly. Write the assumptions down, and halt the fleet the moment one of them stops being true.
1---2name: plan-decay-detector3description: Mid-execution divergence check for an agent fleet - the plan writes its assumptions down in checkable form, and a gate compares them against the repo as it actually is, exiting 1 to call for a re-plan the moment one has stopped being true instead of letting agents keep running off a plan that is no longer accurate. Reach for it before a batch resumes work from a plan written earlier, after a parallel agent or a human has touched the tree, or when someone says "is this plan still valid", "the plan says X but the repo has Y", "re-check the plan before we continue", or "did the plan drift". Differentiator - this island detects decay and calls the halt, which a human or a harness then has to honour; how big a batch should be is story-cadence's doctrine, and ticket machinery is a Forge concern.4---56# Plan Decay Detector: halt when the plan stops describing the repo78Bob's failure mode, from inside it the week of the conversation: *"you make all these plans and then as the agents are running, you realize that they can't follow that plan because you didn't think of everything… they're running half-cocked off on some nonsense that you have to stop, back up, rewrite the plan"* (C19). And the seduction that keeps producing them: *"The agents love to write plans… the plans will be gorgeous and beautiful… And then they fall apart at the end"* (C19). Every quote here comes through [the ledger](../../docs/01-CONCEPT-LEDGER.md), never from memory.910The word that matters is *end*. The plan does not announce its own death. It is discovered dead, at the end, after a batch of agents has already spent itself against it. This island moves that discovery to the front of the batch and makes it mechanical. A prose instruction to "check the plan is still valid" decays in the middle of a context exactly like every other prose rule ([`CONTEXT.md`](../../CONTEXT.md), law 2); a tool in a fix-until-green loop does not (C4).1112## What actually decays1314The plan is not wrong when it is written. The tree moves under it, from three directions:15161. **Execution reveals what planning could not.** The planner did not think of everything (C19): the interface is shaped differently than assumed, the module is already gone.172. **A parallel agent lands first.** Three coders can run at once (C10); the file your plan was going to create already exists, written by a sibling.183. **A human reorganised.** The cadence's manual sort-out step (C20) rewrites structure between batches, and every queued plan aged the moment it ran.1920This is a tooling gap, not just a discipline gap. The spec-driven tooling that exists has the same hole: AWS Kiro does not auto-detect implementation/spec divergence, so its specs silently drift from the code ([atdd-gherkin-agile](../../research/atdd-gherkin-agile.md), citing a doit.com review of Kiro). The brief names Spec Kit and OpenSpec only for the separate "reinvented waterfall" critique, so this is one reviewer's report about one tool, not a measurement and not a survey of the category. Silent drift is the whole disease. A plan that cannot be checked cannot go stale *loudly*.2122## The move: make the plan state its beliefs in checkable form2324A plan is admitted to this gate only if it writes down what it believes about the tree. Five kinds, one per line, TAB-separated, in a file that travels with the plan:2526| Kind | Fields | The belief it records |27|---|---|---|28| `exists` | PATH | "this is here, and my batch reads or edits it" |29| `absent` | PATH | "nothing is here yet, and my batch creates it" |30| `contains` | PATH, LITERAL | "this file still holds the symbol/marker I am extending" |31| `lacks` | PATH, LITERAL | "this file still does not hold the thing I am about to add" |32| `unchanged` | PATH, SHA256 | "this file is exactly the text I read when I planned" |3334`unchanged` is the strong form and the one that catches a sibling agent's edit; `contains`/`lacks` are the cheap forms for a plan that only cares about one seam. Writing them is the work this island asks of the planner, and it is the reason the plan becomes falsifiable instead of gorgeous.3536## The check3738```bash39python3 scripts/plan-decay.py --root . docs/plan/batch-07.assumptions.tsv40python3 scripts/plan-decay.py --root . --digest src/payments.py # to author an `unchanged` row41```4243`--root` is **required and has no default**. It used to default to `.`, which aimed the whole plan at whatever directory the process happened to sit in, without saying so. A create-only plan, every row `absent`, then held vacuously against an unrelated tree and exited **0**. A CI step with the wrong working directory, or an agent shelling out from a subdirectory, is an ordinary condition rather than an attack. And an exit-code-consuming caller cannot see `against .` in the report. Omitting it is now argparse's usage error, exit 2.4445| Exit | Meaning |46|---|---|47| 0 | every stated assumption still holds — and `--help`, which prints usage and computes no verdict, so a 0 is a verdict only when the report says `CHECKED n assumption(s)` |48| 1 | at least one assumption diverged — **HALT and re-plan** |49| 2 | usage, IO, or malformed input — never a verdict, always fail-closed |50| 3 | informational: `--digest` printed a digest and checked no plan |5152**0, 1, 2 and 3 are the only codes this script produces, and every fault leaves through 2.** The faults: an unreadable directory; a target file that is not UTF-8 text; an assumptions file that is a directory or not UTF-8; a malformed row; a missing `--root`; argparse's usage exit; a `SystemExit` carrying a non-integer payload; a stdout closed before the run or broken during it, including the flush CPython performs at *shutdown*, where the status would otherwise become **120**; and any unexpected exception. On that non-integer payload the pack's stock tail returns **1**. This script raises no `SystemExit` of its own, so one arriving at the seal is a fault rather than a divergence, and it leaves through 2. The coercion covers 3 as well as 0 and 1: a `--digest` run whose flush fails printed no digest, so it must not leave claiming it did. None of them may borrow a verdict's code. A caller that reads 1 records a divergence this run never computed; a caller that reads 0 records a plan this run never checked.5354## What the gate refuses to guess5556- **One key function for every path.** Components are matched against real directory entries after NFC folding, the one fold that is genuinely the same file and routine on macOS. A name that matches only *case-folded* is reported as a **variant** and never treated as a match, in either direction: `absent src/refunds.py` diverges when the tree holds `src/Refunds.py`, and so does `lacks`. There is no lenient branch for a **case** respelling. Three further respelling classes never reach a branch at all, because they are refused when the row is read (exit 2). *Invisible* characters: U+FEFF, U+200B and the rest of Unicode Cf/Cs/Co. *Compatibility* forms: `r` U+FF52 FULLWIDTH LATIN SMALL LETTER R, caught by `NFKC(s) != NFC(s)`, where NFKC is a refusal test and never the matching fold, because it is lossy for filenames. And a word that *mixes scripts*: `rеfunds` with U+0435 CYRILLIC SMALL LETTER IE. None of the three can ever equal the entry it imitates, so `locate` would report `missing` and every negative kind would hold vacuously. The mixture test runs per *word*, not per line, so a multilingual repo still checks normally: `Описание.md`, `コーヒー.py` and `src/test_日本語_helper.py` all pass, since a real line mixes scripts *between* words while a respelling mixes them *inside* one. Both fields are covered, because a confusable in a `lacks` **literal** is the same false green and is refused too. **What is not closed:** a *whole-word* confusable. Every letter of one word respelled into one other script, `асе` all-Cyrillic for `ace`, mixes nothing, keys as `missing`, and still holds for a negative kind; [`scripts/fixtures/blind-spot-confusable/`](scripts/fixtures/blind-spot-confusable/) ships that as a captured exit-0 run rather than a sentence. Absolute paths, drive letters, `..`, backslashes, control characters and whitespace-padded components are refused outright rather than rerouted. Two entries that normalise to one name is an ambiguity the gate refuses to resolve (exit 2).57- **A missing file never satisfies a negative.** `lacks` and `contains` both diverge when the named file is not present under that spelling, so deleting a file can never quietly make a `lacks` row pass.58- **No line with content is dropped in silence.** A blank line is skipped, including one holding only spaces or TABs, which carries no assumption. Every line that holds content is either a comment or a checked row, never a silent skip. A comment is a line whose first non-blank character is `#` *and that holds no TAB*; a `#` line that does hold a TAB is ambiguous and is refused out loud. A BOM is stripped (`utf-8-sig`) and CRLF is handled, because that is what an editor hands you. An unknown kind, a wrong field count, a non-hex digest, a blank literal, or a literal *padded* with whitespace is a refusal, not a skip. A single trailing space is the most ordinary editor artefact there is, CRLF stripping does not remove it, and it would silently turn a `lacks` row into one that can never be found and therefore holds forever.59- **An empty plan is not a passing plan.** An assumptions file with zero rows exits 2. "No assumptions, therefore no decay" is the laundering this gate exists to refuse.60- **Digests are over normalised text.** UTF-8 decoded (BOM stripped), line endings folded to `\n`, NFC applied, then SHA-256. A checkout's line-ending policy therefore cannot fake a divergence. `--digest` prints exactly what an `unchanged` row needs.6162## What a halt means6364Exit 1 is not a warning to note and continue. It is *"stop, back up, rewrite the plan"* (C19), and the re-plan is budgeted as the normal cost of a cycle, not logged as failure. That budgeting is [`story-cadence`](../story-cadence/SKILL.md)'s doctrine. Two things follow immediately:6566- **Re-plan from the repo as it now is**, not by patching the old plan. The old plan's remaining assumptions were written against a tree that no longer exists.67- **The context that was already steered by the stale plan is contaminated.** A window has momentum: *"the only way to clear the trajectory is to clear the context window"* (C11). An agent that has been working from the dead plan therefore does not get corrected, it gets replaced. Deciding that is [`trajectory-hygiene`](../trajectory-hygiene/SKILL.md)'s seat, not this island's; this island only rings the bell.6869And when the halt is contested, an agent's argument that the divergence is harmless is a hypothesis: *"you can't trust any debate you have with an agent"* (C18). The digest is the evidence; the opinion is not.7071## Enforced vs advisory7273- `enforced` — the divergence verdict. [`scripts/plan-decay.py`](scripts/plan-decay.py) computes every stated assumption against the tree under `--root`. It exits 1 if any diverged, 0 only if all held, and 2 fail-closed on every non-verdict outcome except argparse's `--help`, which exits 0 and prints no `CHECKED` line (see the exit table); a `--digest` that printed leaves through 3. The island's own shape is enforced by the pack validator (`scripts/validate-island.py` at the pack root).74- `advisory` — **whether the assumptions are the ones the plan actually rests on.** The gate checks the beliefs that were written down; it cannot check the beliefs that were not. A plan can pass with vacuous rows, and `scripts/fixtures/blind-spot-vacuous/` ships that as a captured exit-0 run rather than a sentence. Also advisory: a **whole-word confusable** path, one word respelled entirely into a single other script, is not refused and keys as missing, so it holds vacuously for `absent`/`lacks` (`scripts/fixtures/blind-spot-confusable/`, the second captured exit 0). Mixed-script and compatibility respellings inside a word *are* refused, and a whole-word one is reachable only by an author deliberately writing one. Also advisory: `contains`/`lacks` are literal substring tests over the whole file, so a match inside a comment or a string literal counts; reach for `unchanged` when that matters. `exists` asks whether a directory *entry* of that name is there, so a broken symlink holds. A symlinked component leads where it links, so an assumption can reach outside `--root` through a link in the tree. And *when* to run the check (every batch boundary, every resume, after every merge) is discipline: no hook wires it into a fleet today.7576### Red/green proof7778The gate earns its `enforced` line by having been watched failing: the [`known-dirty-fixture`](../known-dirty-fixture/SKILL.md) ritual. That island's "the pair is necessary, not sufficient" clause is why the fault block below is longer than the pair. **One assumptions file, two trees**: the same input goes green on the tree the plan was written against and red on the tree that moved. Recompute from this island's directory:7980```bash81python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/batch-plan.assumptions.tsv # exit 182# HALT: refunds.py already created by someone else; `def charge(` renamed away; `def refund(` appeared; digest moved83python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/batch-plan.assumptions.tsv # exit 084# PLAN HOLDS: 6 held, 0 diverged8586python3 scripts/plan-decay.py --root scripts/fixtures/variant-spelling scripts/fixtures/batch-plan.assumptions.tsv # exit 187# the tree respelled two paths: `absent src/refunds.py` must NOT pass because the tree holds src/Refunds.py88python3 scripts/plan-decay.py --root scripts/fixtures/unicode-nfd scripts/fixtures/unicode-nfd/assumptions.tsv # exit 089# the NFD name on disk and the NFC path in the plan are one file, not a divergence90python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/bom-crlf.assumptions.tsv # exit 091# a BOM'd, CRLF assumptions file still parses; unstripped, the BOM makes row 1 an unknown kind9293python3 scripts/plan-decay.py scripts/fixtures/create-only.assumptions.tsv # exit 294# --root omitted. Every row is `absent` - the create-only shape - so with the old95# default of '.' this printed `against . - 3 held` and exited 0 from any unrelated96# cwd, a false green on the island's own verdict. It is now argparse's usage exit.97python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/create-only.assumptions.tsv # exit 198python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/create-only.assumptions.tsv # exit 099# the same create-only plan, aimed: red on the tree that already holds src/refunds.py,100# green on the one that does not. The root is now the thing the caller must state.101102python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/empty.tsv # exit 2103python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/hash-tab.tsv # exit 2104python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/empty-literal.tsv # exit 2105python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/unknown-kind.tsv # exit 2106python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/field-count.tsv # exit 2107python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/bad-digest.tsv # exit 2108python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/absolute-path.tsv # exit 2109python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/dotdot.tsv # exit 2110python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/backslash.tsv # exit 2111python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/padded-path.tsv # exit 2112python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/non-utf8.tsv # exit 2113python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/padded-literal.tsv # exit 2114# `lacks src/payments.py 'def refund( '` - one trailing space. Before the fix this exited 0115# on the plan-decayed tree with `def refund():` sitting in the file: a false green on this116# island's own load-bearing row. Leading space, trailing TAB and the CRLF variant too.117python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/format-char-path.tsv # exit 2118python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/malformed/format-char-literal.tsv # exit 2119# an invisible respelling: U+FEFF inside a path, U+200B inside a literal. Both used to120# match nothing and hold forever; now neither can be written at all.121python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/confusable-path.tsv # exit 2122python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/fullwidth-path.tsv # exit 2123python3 scripts/plan-decay.py --root scripts/fixtures/plan-decayed scripts/fixtures/malformed/confusable-literal.tsv # exit 2124# a VISIBLE respelling - the invisible one's twin. `absent src/rеfunds.py` with U+0435125# CYRILLIC SMALL LETTER IE, `absent src/refunds.py` with U+FF52 FULLWIDTH r, and the126# same Cyrillic letter inside a `lacks` literal. All three returned `PLAN HOLDS` and127# exit 0 against this decayed tree before the fix - the third against a file that128# holds `def refund(` right there. Aimed at plan-decayed so the refusal cannot be129# mistaken for the tree simply not having the file.130python3 scripts/plan-decay.py --root scripts/fixtures/binary-target scripts/fixtures/binary-target/assumptions.tsv # exit 2131python3 scripts/plan-decay.py --root scripts/fixtures/nope scripts/fixtures/batch-plan.assumptions.tsv # exit 2132python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds # exit 2133python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds scripts/fixtures/batch-plan.assumptions.tsv >&- # exit 2134bash scripts/fault-probes.sh # exit 0135136python3 scripts/plan-decay.py --root scripts/fixtures/plan-holds --digest src/payments.py # exit 3137python3 scripts/plan-decay.py --root scripts/fixtures/blind-spot-vacuous scripts/fixtures/blind-spot-vacuous/assumptions.tsv # exit 0138# LIMIT, not a pass. Both rows are true and neither names src/payments.py, the module the batch139# was written to edit, which is gone. The gate sees only what the plan chose to write down.140python3 scripts/plan-decay.py --root scripts/fixtures/blind-spot-confusable scripts/fixtures/blind-spot-confusable/assumptions.tsv # exit 0141# The second LIMIT. `absent src/асе.py` is 'ace' with every letter respelled into142# Cyrillic - one word, one script, so the mixed-script refusal has nothing to catch -143# while src/ace.py sits in the tree. Captured as a run rather than named as closed.144145# The interpreter's 120 - a std-stream flush failing at SHUTDOWN - never reaches an in-run146# handler, because argparse exits before them. Both of these are 120 when the same file is147# given a plain `sys.exit(main())` tail instead; that counterfactual was run, not assumed.148python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(149 [sys.executable,"scripts/plan-decay.py","--nope"],stderr=w,stdout=subprocess.DEVNULL).returncode)' # 2150python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(151 [sys.executable,"scripts/plan-decay.py","--help"],stdout=w,stderr=subprocess.DEVNULL).returncode)' # 2152# And the informational code, which asserts a digest was PRINTED: with stdout dead it used153# to leave through 3 with nothing written. It leaves through 2.154python3 -c 'import os,subprocess,sys;r,w=os.pipe();os.close(r);print(subprocess.run(155 [sys.executable,"scripts/plan-decay.py","--root","scripts/fixtures/plan-holds",156 "--digest","src/payments.py"],stdout=w,stderr=subprocess.DEVNULL).returncode)' # 2157```158159Thirty-six runs, each proving one thing the others cannot. The pair is `plan-decayed` versus `plan-holds` on *one* assumptions file, so a green cannot come from a friendlier input. `variant-spelling` is the input a plain `Path.exists()` answer would wave straight through. On a case-sensitive filesystem it reports `src/refunds.py` as still absent while a sibling agent's `src/Refunds.py` sits right there, and the plan proceeds to create a second module. Here it halts. `bom-crlf` and `unicode-nfd` are the two inputs an ordinary editor and an ordinary macOS checkout produce; decoded as plain UTF-8, the BOM'd file's first field is `'# saved by a Window…'`, an unknown kind.160161The seventeen malformed rows are refusals, not skips: each is a shape a line-oriented parser plausibly swallows in silence. Three of them were **watched consenting before the fix**: a `lacks` literal with one trailing space, and the same row respelled with U+FEFF in the path or U+200B in the literal. Each returned `PLAN HOLDS` and exit 0 against a tree that had already diverged. Three more were **forged and watched consenting in the session that closed them**, the *visible* respellings, which have the invisible one's mechanics exactly. `absent src/rеfunds.py` (U+0435), `absent src/refunds.py` (U+FF52) and `lacks src/payments.py 'def rеfund('` each printed `PLAN HOLDS` and exited 0 against `plan-decayed`, the last of them while `def refund(` sat in the named file.162163`create-only.assumptions.tsv` is the same discipline applied to the *root*. Run with no `--root`, it was watched printing `against . - 3 held, 0 diverged` and exiting **0** from an unrelated cwd; it now leaves through argparse at 2. Four faults were **watched taking a consenting code on this script before the fix**: the three padded/invisible rows above, plus a run whose stdout was closed before it started. That one exited **0** with the whole report silently discarded, because CPython sets `sys.stdout` to `None` and `print` then does nothing at all. The 120 pair is the measured counterfactual above, not a remembered one. `fault-probes.sh` captures what a repo cannot store: an unreadable subdirectory, an un-encodable report proving the `BaseException` seal is live, an assumptions path that is a directory, and a normalisation collision that skips out loud on a filesystem that folds NFC and NFD. Deleting any fixture returns the gate to `unverified`.164165## Boundaries — who owns what166167- **How big a batch should be** — the small-batch argument and its $1-house economics are [`story-cadence`](../story-cadence/SKILL.md)'s doctrine (C20, C21). This island says nothing about batch size; it only detects that the batch's plan stopped being true.168- **Ticket machinery** — turning a plan into specs, tickets with blocking edges, and implementation passes is [`spec-pipeline`](../../COMPANION.md#spec-pipeline)'s seat. This island reads an assumptions file; it never issues, orders, or closes a ticket.169- **Killing a contaminated context** — an agent already steered by the dead plan carries that trajectory (C11), and whether to continue or kill and respawn is [`trajectory-hygiene`](../trajectory-hygiene/SKILL.md)'s seat.170- **Gate acceptance** — the red/green ritual this island submitted to belongs to [`known-dirty-fixture`](../known-dirty-fixture/SKILL.md); the fixtures above are that island's rule applied here.171172## Done when173174- [ ] The plan ships an assumptions file naming every file the batch reads, edits, or creates: `unchanged` for anything a sibling agent could touch.175- [ ] `plan-decay.py` exits 0 immediately before the batch starts and again before any agent resumes from that plan.176- [ ] Every exit 1 was answered with a re-plan written from the current tree, never with a patched old plan.177- [ ] Any exit 0 that later proved wrong was answered by adding the assumption that would have caught it. The blind spot above is closed one row at a time, by hand.178179**A plan that cannot be checked cannot go stale loudly. Write the assumptions down, and halt the fleet the moment one of them stops being true.**