fix(bin): resolve the wake sequence instead of trusting a supplied one - #45
Merged
Merged
Conversation
`fm-wake-ledger.sh outcome` took the wake sequence as a positional argument and validated only that it was an integer. A coordinator recording outcomes after the fact no longer had the real sequences and supplied descending placeholders; every one was accepted and stored with queued=unknown, which is also what a legitimately wiped state/ produces, so nothing looked wrong and nothing reconciled outcome records against wake records. Measured 2026-08-04: 200 of 249 outcome records were fabricated this way. The coordinator now names what a wake cost, never which number it was: - `outcome <token>` with no sequence resolves the most recent wake record that no outcome record joins, and is the normal path. A second bare call moves on rather than re-recording the same wake, and no unrecorded wake left is a loud refusal rather than a guess. - An explicitly passed sequence that joins no wake record is refused before anything is written, confirmed against the whole file so the refusal states a fact rather than a lookup horizon. `--allow-unjoined` keeps the genuine wiped-state/ case reachable and a guess unreachable. - `reconcile` counts the outcome records that join no wake record, and session-start bootstrap reports it, so the corruption cannot stay silent. An absent ledger counts a real zero; one that exists and cannot be read refuses rather than reporting that same zero. The (seq, queued) durable join identity, the closed outcome vocabulary, and multiple sequences per invocation are unchanged, and no existing record is rewritten or migrated.
fm-wake-lib.sh ran `mkdir -p "$STATE"` at source time, so merely sourcing it
made the caller a writer. The new `reconcile` bootstrap diagnostic sources it
to count unjoined outcome records, which turned a read into a mutation and
broke bootstrap's detect-only read-only contract; the guard that asserts that
contract went red ("detect-only bootstrap created its state directory").
Fixed at the source rather than by teaching bootstrap to avoid it, because any
future read-only caller inherits the same trap. Sourcing now observes a home
and never creates one; the write paths materialize the directory on demand:
- fm_state_ensure() is the named, idempotent creation point, called by
fm_wake_append for the sequence file that always lives in $STATE.
- fm_lock_owner_dir creates the directory that will hold a lock it is about to
take. This is load-bearing, not defensive: fm_lock_abs_path fails while the
parent is missing and fm_lock_acquire_wait retries forever, so without it a
lock under a missing state directory hangs instead of failing. Verified by
removing the guard and watching the wait spin until killed.
Every other caller that writes into $STATE either creates it itself or takes a
lock first, so the removed source-time mkdir was duplication, not the contract.
The diagnostic itself is unchanged and still distinguishes its three cases: an
absent ledger counts a real zero and stays silent, a populated ledger reports
the true unjoined count, and a ledger that exists but cannot be read reports
that rather than the same zero. Reads leave the ledger byte-identical; it stays
append-only and nothing is rewritten, migrated, or truncated.
sbracewell64
force-pushed
the
fm/wake-ledger-accepts-unjoinable-sequence
branch
from
August 6, 2026 02:28
a04d608 to
30b280f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivery note
This branch shipped without the no-mistakes pipeline and carries NO attestation marker.
It was delivered directly under a time-boxed captain authorization (2026-08-04, expiring 2026-08-08), based on and targeting the fork trunk
sbracewell64/firstmate main.It has had no automated review, no pipeline gates, and no attestation; the verification below is what the author ran by hand and nothing more.
Rebased onto
mainat3611e49after the trunk advanced; both rebases were conflict-free.The defect
fm-wake-ledger.sh outcome <token> <seq>took the wake sequence as a positional argument and validated only that it was an integer.A coordinator recording outcomes after the fact no longer had the real sequences in front of it and supplied descending placeholders.
Every one was accepted and stored with
queued=unknown, which is also exactly what a legitimately wipedstate/produces, so nothing looked wrong, and nothing ever reconciled outcome records against wake records.Measured 2026-08-04: 200 of 249 outcome records in
data/wake-ledger.tsvwere fabricated this way, and quantitative claims were built on them before the corruption was found.Root cause in one line: the identifier was supplied by hand, from memory, after the fact, and nothing checked it.
The change
The coordinator now names what a wake cost, never which number it was.
outcome <token>with no sequence resolves the most recent wake record that no outcome record joins, and records against it.This is the normal path.
A second bare call moves to the next unrecorded wake rather than repeating one, and when no unrecorded wake remains it refuses loudly instead of guessing.
The bounded 500-line lookup still serves the common path, but a miss re-confirms against the whole file, so the refusal states a fact rather than a lookup horizon.
--allow-unjoinedkeeps the genuine wiped-state/case reachable and a guess unreachable.reconcilecounts the outcome records that join no wake record, and session-start bootstrap reports that count as aWAKE_LEDGER:diagnostic, so silent corruption becomes a visible alarm.An absent ledger counts a real zero; a ledger that exists and cannot be read refuses rather than reporting that same zero.
Preserved exactly: the
(seq, queued)durable join identity, the closed outcome vocabulary, and multiple sequences per invocation.No existing record is rewritten or migrated; the file stays append-only and the fabricated records remain for the captain to purge separately.
Follow-up fix: the new diagnostic mutated what it inspected
The first revision of this branch shipped with a red guard, and the guard was right.
wake_ledger_reconcile()runs inside bootstrap's detect-only mode, which is contractually filesystem read-only.Counting the unjoined records made bootstrap create the home's state directory, and
tests/fm-secondmate-harness.test.shsaid so:Confirmed introduced, not pre-existing: a detect-only run on a clean home creates the directory with this branch's
bin/and creates nothing with the trunk's.The stated line was not the cause.
bin/fm-wake-ledger.sh'smkdir -p "$dir"is on the append path and createsdata/, notstate/.The actual mutation was
bin/fm-wake-lib.shrunningmkdir -p "$STATE"at source time, so merely sourcing the shared library made any caller a writer.reconcileitself never wrote anything.Fixed at the source rather than by teaching bootstrap to avoid it, because any future read-only caller inherited the same trap.
Sourcing now observes a home and never creates one, and the write paths materialize the directory on demand:
fm_state_ensure()is the named, idempotent creation point, called byfm_wake_appendfor the sequence file that always lives in$STATE.fm_lock_owner_dircreates the directory that will hold a lock it is about to take.That second one is load-bearing, not defensive.
fm_lock_abs_pathfails while the parent directory is missing andfm_lock_acquire_waitretries forever, so removing the source-timemkdirwithout it turns a lock under a missing state directory into a hang.Proven by deleting the guard and watching the wait spin until it was killed at 6s, against the same call completing instantly with it.
Every other caller that writes into
$STATEeither creates it itself or takes a lock first, so the removed source-timemkdirwas duplication rather than the contract.Audited across all 32 scripts that source the library; the ones that write into
$STATEare covered as follows:fm-afk-return,fm-lock,fm-pr-check-migrate,fm-spawn,fm-supervise-daemonmkdir -p "$STATE"fm-backlog-handoff,fm-claude-stop-autoarm,fm-watch-arm,fm-wake-drain$STATEfirstfm-push-transition-libfm-watch.sh, which creates$STATEitselfThe diagnostic is unchanged and still keeps its distinctions, measured on the fixed tree:
reconcile --count02seq=999999that does join a wake0The last row matters: the count is a
(seq, queued)join, not a numeric pattern, and a genuine high sequence is still not counted.Reads leave the ledger byte-identical, so it stays append-only and nothing is rewritten, migrated, or truncated.
Are there other mutating detect-only diagnostics?
No.
Measured rather than read: a whole-tree checksum snapshot before and after a detect-only bootstrap on a populated home with a full fake toolchain, so no diagnostic was skipped for a missing tool.
The state directory was the only difference, and after the fix the diff is empty.
The same probe against a mutating run still reports the PR-check migration markers, so the probe can see writes.
The contract does have a mechanical enforcement point -
tests/fm-secondmate-harness.test.sh's B24 case - and it worked exactly as designed here: it went red the moment a mutating diagnostic was added.Nothing new was built for it.
Verification
Every negative control was witnessed failing first, against the unmodified script:
outcome <token>error: outcome needs at least one wake sequence, exit 2seq=999999 queued=unknown--allow-unjoinederror: unknown flag for outcomereconcileerror: unknown subcommandnot ok - detect-only bootstrap created its state directorynot ok - sourcing the wake library created its state directoryThen re-run against the real 1315-line ledger from the primary home (read-only copy):
outcome--allow-unjoinedqueued=unknown999xxxblockMutation testing of the four new guarantees killed 3 of 4 mutants.
The surviving mutant (bare resolution falling back to a fabricated sequence) is held by a second independent guard: the fabricated value hits the join refusal and nothing is written.
Tests
On the final rebased tree, the suites covering this change all pass:
fm-secondmate-harnessfm-wake-queuefm-wake-ledgerfm-bootstrapbin/fm-lint.sh(ShellCheck 0.11.0, pinned) andbin/fm-doc-audience-check.share both clean.The new regression case lives in the existing
tests/fm-wake-queue.test.shrather than a new suite, and pins the contract through the library's public functions: sourcing creates nothing,fm_wake_appendcreates the directory on demand, and a lock under a missing directory completes within a bounded timeout instead of spinning.CI on this branch
3611e49Behavior portable serial 4Behavior portable serial 2Require no-mistakesBehavior portable serial 2is red on the trunk this branch targets, for the same reason.Measured, not assumed - the failing suite and assertion are identical in both runs:
Trunk run 31064347747 on
3611e49, this branch's run 31065702384.Two further suites -
fm-tmux-agent-livenessandfm-pi-watch-extension- fail in this author's local environment but pass in CI, and they fail identically on a clean trunk checkout locally, so they are environmental and not introduced here.CI is the authority for those two.