Skip to content

fix(bin): resolve the wake sequence instead of trusting a supplied one - #45

Merged
sbracewell64 merged 2 commits into
mainfrom
fm/wake-ledger-accepts-unjoinable-sequence
Aug 6, 2026
Merged

fix(bin): resolve the wake sequence instead of trusting a supplied one#45
sbracewell64 merged 2 commits into
mainfrom
fm/wake-ledger-accepts-unjoinable-sequence

Conversation

@sbracewell64

@sbracewell64 sbracewell64 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 main at 3611e49 after 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 wiped state/ 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.tsv were 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.
  • An explicitly passed sequence that joins no wake record is refused, before anything is written, so a rejected sequence in a multi-sequence invocation cannot leave a half-recorded batch behind.
    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-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 that count as a WAKE_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.sh said so:

not ok - detect-only bootstrap created its state directory

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's mkdir -p "$dir" is on the append path and creates data/, not state/.
The actual mutation was bin/fm-wake-lib.sh running mkdir -p "$STATE" at source time, so merely sourcing the shared library made any caller a writer.
reconcile itself 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 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.

That second one is load-bearing, not defensive.
fm_lock_abs_path fails while the parent directory is missing and fm_lock_acquire_wait retries forever, so removing the source-time mkdir without 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 $STATE either creates it itself or takes a lock first, so the removed source-time mkdir was duplication rather than the contract.
Audited across all 32 scripts that source the library; the ones that write into $STATE are covered as follows:

Script Writes Covered by
fm-afk-return, fm-lock, fm-pr-check-migrate, fm-spawn, fm-supervise-daemon 1-7 each own mkdir -p "$STATE"
fm-backlog-handoff, fm-claude-stop-autoarm, fm-watch-arm, fm-wake-drain 1-3 each acquire a lock in $STATE first
fm-push-transition-lib 2 only sourced by fm-watch.sh, which creates $STATE itself

The diagnostic is unchanged and still keeps its distinctions, measured on the fixed tree:

Ledger state reconcile --count Bootstrap line Created anything
absent 0 silent no
4 outcome records, 2 joining no wake 2 reports 2 no
exists, unreadable exit 2 reports unreadable, not a zero no
seq=999999 that does join a wake 0 silent no

The 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:

Control Before
bare outcome <token> error: outcome needs at least one wake sequence, exit 2
unjoinable seq, no override accepted, exit 0, wrote seq=999999 queued=unknown
--allow-unjoined error: unknown flag for outcome
reconcile error: unknown subcommand
detect-only bootstrap read-only guard not ok - detect-only bootstrap created its state directory
new wake-library read-only case, run against the unfixed library not ok - sourcing the wake library created its state directory
lock under a missing state directory, guard removed hung until killed (exit 124)

Then re-run against the real 1315-line ledger from the primary home (read-only copy):

Check Result
bare outcome recorded seq 3433, matching an independent scan for the newest unrecorded wake; a second call took 3432 with no repeat
unjoinable seq, no override refused, exit 2, zero bytes written
with --allow-unjoined accepted, queued=unknown
explicit joinable seq unchanged, including several sequences in one invocation
reconciliation count 240 of 555 outcome records join no wake record, of which exactly 200 are the fabricated 999xxx block

Mutation 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:

Suite Result
fm-secondmate-harness exit 0, 47 ok, including B24 (was 46 ok + 1 fail)
fm-wake-queue exit 0, 13 ok, including the new read-only case
fm-wake-ledger exit 0, 15 ok
fm-bootstrap exit 0, 25 ok

bin/fm-lint.sh (ShellCheck 0.11.0, pinned) and bin/fm-doc-audience-check.sh are both clean.

The new regression case lives in the existing tests/fm-wake-queue.test.sh rather than a new suite, and pins the contract through the library's public functions: sourcing creates nothing, fm_wake_append creates the directory on demand, and a lock under a missing directory completes within a bounded timeout instead of spinning.

CI on this branch

Check Trunk 3611e49 This branch
Behavior portable serial 4 success success (was the introduced failure)
Behavior portable serial 2 failure failure (pre-existing)
every other CI job success success
Require no-mistakes n/a failure, by design under this authorization

Behavior portable serial 2 is red on the trunk this branch targets, for the same reason.
Measured, not assumed - the failing suite and assertion are identical in both runs:

tests/fm-remote-secondmate-lifecycle-e2e.test.sh exit=1
not ok - first inheritance transaction never reached its blocked write

Trunk run 31064347747 on 3611e49, this branch's run 31065702384.

Two further suites - fm-tmux-agent-liveness and fm-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.

`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
sbracewell64 force-pushed the fm/wake-ledger-accepts-unjoinable-sequence branch from a04d608 to 30b280f Compare August 6, 2026 02:28
@sbracewell64
sbracewell64 merged commit aef7a6d into main Aug 6, 2026
12 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant