Five defects in the write path, found while reviewing the distribution plan in #12. All are live in the CLI today. None is caused by the MCP server work, but that work widens the blast radius by letting an agent supply plan JSON, so these should land first.
This blocks the MCP server. See #12.
1. applyEligibility sits outside the content hash
planBody() (src/planner.ts:212) hashes {createdAt, expiresAt, snapshotHash, deviceBinding, changes, frames}. applyEligibility is attached outside that body (src/planner.ts:207). But assertValidPlan gates on it (src/planner.ts:227):
if (!plan.applyEligibility.eligible) throw new Error(...)
So editing a plan file to flip eligible: true and clear reasons leaves planId valid, and a plan rejected for legacy firmware or weak device identity applies anyway. The firmware allowlist is the main guard against writing to an untested firmware, and it is bypassable with a text editor.
Fix: include applyEligibility in planBody(), and recompute eligibility inside the apply path from the freshly-read device rather than trusting the serialized field.
2. Frames are not proven to match changes
The hash is unkeyed, so it proves integrity in transit, not authenticity — anyone can recompute it. Nothing re-derives frames from changes, so a plan can declare one change and carry frames that write something else. assertConfigurationWrite (src/protocol.ts:97) validates frame shape, not correspondence to the declared intent, and the mismatch would only be noticed after bytes were sent.
Fix: have assertValidPlan re-derive frames from changes via encodeGlobalDryRun / encodeEncoderDryRun and compare before connecting.
3. Plans are not single-use
assertPlanNotConsumed (src/journal.ts:28) rejects only outcome === "complete". A plan whose apply died mid-write — journaled unknown — is replayable. There is also no atomic reservation between the check and the started record, so two applies can both pass the check.
The README states "Completed plans are single-use", which is literally true and materially misleading.
Fix: block on any prior started / pending / unknown until explicitly resolved; make check→started an atomic reservation; add a per-device apply mutex. A long-running MCP server makes concurrent invocation natural, so this stops being theoretical.
4. .mft-state is relative to the current working directory
src/cli.ts:149 resolves the journal, backups, and last-verified.json against cwd. Consequences:
- Running
apply from a different directory than a previous apply consults a different journal, silently defeating (3).
- An MCP server's cwd is set by its host, not the user's shell, so server and CLI would never share a journal.
- Backups can land in a plugin cache directory the user never looks at.
This was previously masked by the skill instructing the agent to run from the repository root. Now that the CLI installs from npm there is no repository root, so the constraint is documented prose in the skill and README rather than anything enforced.
Fix: one stable per-user state root shared by CLI and any server, with an explicit --state-dir / env override, plus migration for existing cwd-local journals.
5. The display re-enable write is unverified
src/applier.ts:81-97 sends a global write after encoder targets so the LED display comes back. It sits outside the per-target try/catch and:
- is not in the plan's
frames, so the hash-covered list a reviewer approves is not the complete set of bytes sent;
- gets no read-back and never receives
verified;
- on a throw leaves the journal's last entry as
pending with no terminal outcome, contradicting docs/write-safety.md item 7 — and since (3) only blocks complete, the plan stays replayable;
- means
postSnapshot (returned at src/applier.ts:98, written to last-verified.json) predates the final write.
Reachable in practice: encodeGlobalDryRun calls assertExactTags (src/write-codec.ts:41), which throws if a freshly-read globals tag set is not exactly allowlisted — after every encoder write has already landed.
Fix: either make it a planned, verified target, or document and test it as explicitly best-effort with honest journal semantics. Correct docs/write-safety.md and the README's "reads the full configuration after every target" either way.
Acceptance criteria
None of these paths has a regression test today.
Five defects in the write path, found while reviewing the distribution plan in #12. All are live in the CLI today. None is caused by the MCP server work, but that work widens the blast radius by letting an agent supply plan JSON, so these should land first.
This blocks the MCP server. See #12.
1.
applyEligibilitysits outside the content hashplanBody()(src/planner.ts:212) hashes{createdAt, expiresAt, snapshotHash, deviceBinding, changes, frames}.applyEligibilityis attached outside that body (src/planner.ts:207). ButassertValidPlangates on it (src/planner.ts:227):So editing a plan file to flip
eligible: trueand clearreasonsleavesplanIdvalid, and a plan rejected for legacy firmware or weak device identity applies anyway. The firmware allowlist is the main guard against writing to an untested firmware, and it is bypassable with a text editor.Fix: include
applyEligibilityinplanBody(), and recompute eligibility inside the apply path from the freshly-read device rather than trusting the serialized field.2. Frames are not proven to match changes
The hash is unkeyed, so it proves integrity in transit, not authenticity — anyone can recompute it. Nothing re-derives
framesfromchanges, so a plan can declare one change and carry frames that write something else.assertConfigurationWrite(src/protocol.ts:97) validates frame shape, not correspondence to the declared intent, and the mismatch would only be noticed after bytes were sent.Fix: have
assertValidPlanre-derive frames fromchangesviaencodeGlobalDryRun/encodeEncoderDryRunand compare before connecting.3. Plans are not single-use
assertPlanNotConsumed(src/journal.ts:28) rejects onlyoutcome === "complete". A plan whose apply died mid-write — journaledunknown— is replayable. There is also no atomic reservation between the check and thestartedrecord, so two applies can both pass the check.The README states "Completed plans are single-use", which is literally true and materially misleading.
Fix: block on any prior
started/pending/unknownuntil explicitly resolved; make check→startedan atomic reservation; add a per-device apply mutex. A long-running MCP server makes concurrent invocation natural, so this stops being theoretical.4.
.mft-stateis relative to the current working directorysrc/cli.ts:149resolves the journal, backups, andlast-verified.jsonagainst cwd. Consequences:applyfrom a different directory than a previousapplyconsults a different journal, silently defeating (3).This was previously masked by the skill instructing the agent to run from the repository root. Now that the CLI installs from npm there is no repository root, so the constraint is documented prose in the skill and README rather than anything enforced.
Fix: one stable per-user state root shared by CLI and any server, with an explicit
--state-dir/ env override, plus migration for existing cwd-local journals.5. The display re-enable write is unverified
src/applier.ts:81-97sends a global write after encoder targets so the LED display comes back. It sits outside the per-targettry/catchand:frames, so the hash-covered list a reviewer approves is not the complete set of bytes sent;verified;pendingwith no terminal outcome, contradictingdocs/write-safety.mditem 7 — and since (3) only blockscomplete, the plan stays replayable;postSnapshot(returned atsrc/applier.ts:98, written tolast-verified.json) predates the final write.Reachable in practice:
encodeGlobalDryRuncallsassertExactTags(src/write-codec.ts:41), which throws if a freshly-read globals tag set is not exactly allowlisted — after every encoder write has already landed.Fix: either make it a planned, verified target, or document and test it as explicitly best-effort with honest journal semantics. Correct
docs/write-safety.mdand the README's "reads the full configuration after every target" either way.Acceptance criteria
applyEligibilityis rejectedstarted/pending/unknownjournal entry is refusedNone of these paths has a regression test today.