jcp-wire: one writer per journal, or the replay spends twice #346

Merged
dcharlot merged 1 commit from fix/journal-single-writer into main 2026-10-20 03:13:48 -04:00

2026-10-19

Transaction Science
jcp-wire: one writer per journal, or the replay spends twice
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m19s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m13s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m43s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 2m48s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m25s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 6m34s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 7m13s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m54s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m34s
SPEC §5 requires an enforcement point that can restart to record every allowed
decision durably and to re-establish spend by **replaying** the record. Nothing
said the record may have only one writer, and `FileJournal::open` was

    OpenOptions::new().read(true).append(true).create(true)

with no lock at all.

**The failure is a double-spend, not a corrupt file.** Two endpoints sharing one
journal each replay it, each derive the *same* remaining budget, each decide
against it and each append. The budget is spent twice, every record still
verifies, and nothing in the journal shows it — the durability requirement is
defeated one layer beneath itself. `open` also truncates a torn trailing
record, so a concurrent opener can cut the file out from under an appender.

`open` now takes an exclusive lock, and takes it **before** the read and the
truncation: an opener refused afterwards would already have cut the holder's
file.

The lock is `std::fs::File::try_lock`, stable since Rust 1.89: no `unsafe`, no
new dependency, no lockfile change in any workspace, and no platform caveat,
because std covers Windows as well as unix. Verified on the toolchain rather
than assumed — a second `try_lock` on a held file returns `WouldBlock`, and the
lock releases when the file is dropped. It is also released when the process
dies, so a crash leaves no stale lock to recover; a lock-file sidecar would
need a story for that case, and that story is where such schemes go wrong.

Where a platform cannot lock at all, `open` **fails** rather than proceeding.
Deciding against a record another endpoint may also be writing is the overspend
this exists to stop, and a platform limit is not a licence for it.

Falsification, five mutations, all killed by their named test. Two are worth
reading:

- **the lock taken *after* the read and truncation** fails **only** the
  no-truncation test, which isolates the ordering from the presence of the
  lock;
- **the unsupported-platform error swallowed** survived the first round,
  because that arm is unreachable on a platform whose locks work — and an
  unreachable arm is exactly where a fail-**open** hides. `lock_outcome` is
  split out from the I/O so every arm is reachable from a test, and the
  mutation now fails only its own.

A blocking-lock mutation was deliberately not used: dropping the non-blocking
attempt would hang the suite rather than fail a test, and a mutation that hangs
reports nothing.

SPEC §5 gains the requirement, since the gap was in the spec before it was in
the code: a single writer per record is MUST, failing to start beats proceeding
beside another writer, an exclusive claim the OS releases on death is
preferred, and a platform that cannot provide exclusivity means failing to open
rather than proceeding.

No conformance clause is added: the pack has no vector for it, and numbering a
clause nothing tests would claim coverage that does not exist.

`mesh-core`'s journal had the identical defect and is fixed the same way on its
own branch, since the two standards deliberately keep their own copies of this
machinery.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-19 22:03:57 -04:00