ProcessJobStore and SubstrateStore already implemented the
loader-accepts-any-version-in-[1, kVersion] pattern. The other five
stores (ControlJobStore, CarrierStore, LoadPortStore, ExceptionStore,
SpoolStore) used strict `header[1] != kVersion` rejection, meaning
a future kVersion bump there would silently nuke every persisted
record on first replay. That's a footgun the test_persistence_upgrade
test already flagged as a tripwire.
This commit flips the strict checks to `< 1 || > kVersion`, mirroring
PJ + Substrate. No format change (kVersion stays at 1 across the
five stores), but:
- Future v2 of any store now Just Works: add fields at the end of
write_record_, bump kVersion to 2, gate the new reads behind
`if (version >= 2)`. Old v1 records on disk continue to replay
with the new fields defaulted.
- Future versions beyond kVersion still get rejected (downgrade
protection — older code can't try to decode trailers it doesn't
understand).
Comment blocks on each kVersion declaration now describe the upgrade
discipline so the next contributor doesn't reinvent it.
Test additions:
- Positive test that v1 ControlJob records load on current code
(will continue to pass when kVersion bumps to 2, proving v1 is
still readable)
- ExceptionStore rejects a v9 (future) record, matching CJ + Carrier
- The existing tripwire tests get retitled from "rejects unknown
version" to "rejects a future version" to reflect the new contract
README §6 gets honest: every store is now multi-version-aware, not
just PJ + Substrate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-EXID binary record (.ex), magic + version + atomic .tmp+rename.
Records full E5 §9 lifecycle: state, EXID, EXTYPE, EXMESSAGE, and
the candidate EXRECVRA list.
Cleared exceptions are terminal — the FSM transitions through
Cleared remove the in-memory entry AND delete the journal file
(matching the existing in-memory semantics). Recovering /
RecoverFailed states survive restart: the application can decide
on replay whether to retry recovery or abort.
Five new tests cover post+replay, Recovering-survives-restart,
autonomous-clear cleanup, RecoverFailed retry post-restart, and
corrupt-record drop.
This completes #12 in the test-gap backlog (persistence for the four
in-memory stores beyond Spool).
Closes#4 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-EXID exception lifecycle for E5 §9. States mirror the wire flow:
Posted equipment sent S5F9, awaiting host or autonomous clear
Recovering host's S5F13 accepted; equipment running recovery
RecoverFailed S5F15 reported a failed result; host may retry
Cleared terminal — store removes the row
Events:
Created synthetic NoState->Posted observer signal
Recover host's S5F13 (Posted/RecoverFailed -> Recovering)
RecoveryComplete equipment internal (Recovering -> Cleared)
RecoveryFailed equipment internal (Recovering -> RecoverFailed)
RecoveryAbort host's S5F17 (Recovering -> Posted)
Clear equipment internal (Posted/RecoverFailed -> Cleared)
ExceptionStore mirrors ProcessJobStore: per-EXID FSMs heap-allocated via
unique_ptr, non-movable to keep `this`-captures safe, synthetic Created
fires after the row lands so observers can decide whether to emit S5F9
out of band. on_recover validates EXRECVRA against the candidates the
post advertised.
The store joins EquipmentDataModel alongside process_jobs / control_jobs.
S5F9-F18 server-side dispatch lands in C2.
Tests (12 cases) cover FSM transitions including retry, abort, and
autonomous clear, plus store-level duplicate-rejection, EXRECVRA
validation, and Cleared-removes-the-row semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>