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
Owner

SPEC §5 requires an enforcement point that can restart to record every allowed decision durably, and to re-establish spend by replaying that 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 — the same shape as the refill-on-restart defects fixed in #305 and #310, except the replay that fixed those is what is being defeated.

open also truncates a torn trailing record, so a concurrent opener can cut the file out from under an appender.

The fix

open takes an exclusive lock, before the read and the truncation. An opener refused afterwards would already have cut the holder's file — the ordering is the property, not merely the presence of a lock.

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.

Revised during review of my own work. This PR first used a hand-written libc::flock binding with an unsafe block and a SINGLE_WRITER_ENFORCED constant to carry the unix-only caveat. Porting the same fix to mesh-core (#347) hit that workspace's deny-unsafe lint, which sent me looking for a better primitive — and std::fs::File::try_lock removes the unsafe, the dependency, the lockfile churn and the platform caveat together. Both PRs now carry the same approach rather than the first one that worked.

SPEC §5 gains the requirement

The gap was in the spec before it was in the code, so prose alone would not stop a reimplementation repeating it. §5 now requires a single writer per record, failing to start over proceeding beside another writer, prefers an exclusive claim the OS releases on death, and requires failing to open where a platform cannot provide exclusivity at all.

No conformance clause (C…) is added: the pack has no vector for it, and numbering a clause nothing tests would claim coverage that does not exist. That vector is the follow-up.

Falsification

mutation result
M1 no lock taken at all (the pre-fix behaviour) the_single_writer_claim_matches_what_open_does + 1 FAILED
M2 lock taken after the read and truncation only a_refused_open_does_not_truncate_the_holders_journal FAILED
M3 a shared lock instead of an exclusive one the_single_writer_claim_matches_what_open_does + 1 FAILED
M4 a contended lock reported as success the_single_writer_claim_matches_what_open_does + 2 FAILED
M5 the unsupported-platform error swallowed only a_platform_that_cannot_lock_fails_closed FAILED

M5 survived the first round, and that was the finding. Swallowing TryLockError::Error broke no test, because that arm is unreachable on a platform whose locks work — and an unreachable arm is exactly where a fail-open sits unnoticed. lock_outcome is split out from the I/O so all three outcomes are reachable from a test, and M5 now fails only its own.

M2 is the other one worth reading: the lock is still present, just in the wrong place, and only the truncation test notices.

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

Verification

  • cargo check --workspace --all-targets --locked (jcp-rs) — exit 0
  • cargo test --workspace --locked — exit 0, 64 test binaries green, no failures
  • cargo clippy -p jcp-wire --all-targets --no-deps --locked -- -D warnings — clean
  • check-lockfiles — 42 lockfiles, all current (unchanged); check-spec-refs 1673 citations / 16 standards

The sibling

mesh-core's journal had the identical defect and is fixed the same way in #347. Mesh's journal is hash-chained, so there the chain detects a second writer — but only after both stewards have already served against the same spend, and at the cost of making the journal unreplayable. The two standards deliberately keep their own copies of this machinery, as mesh-receipt already vendors JCR-1 rather than depending on jcp-schema, so each gets its own change rather than a shared crate.

🤖 Generated with Claude Code

SPEC §5 requires an enforcement point that can restart to record every allowed decision durably, and to re-establish spend by **replaying** that record. Nothing said the record may have only **one writer**, and `FileJournal::open` was: ```rust 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 — the same shape as the refill-on-restart defects fixed in #305 and #310, except the replay that fixed those is what is being defeated. `open` also **truncates** a torn trailing record, so a concurrent opener can cut the file out from under an appender. ## The fix `open` takes an exclusive lock, **before** the read and the truncation. An opener refused afterwards would already have cut the holder's file — the ordering is the property, not merely the presence of a lock. 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. > **Revised during review of my own work.** This PR first used a hand-written `libc::flock` binding with an `unsafe` block and a `SINGLE_WRITER_ENFORCED` constant to carry the unix-only caveat. Porting the same fix to `mesh-core` (#347) hit that workspace's deny-`unsafe` lint, which sent me looking for a better primitive — and `std::fs::File::try_lock` removes the `unsafe`, the dependency, the lockfile churn and the platform caveat together. Both PRs now carry the same approach rather than the first one that worked. ## SPEC §5 gains the requirement The gap was in the spec before it was in the code, so prose alone would not stop a reimplementation repeating it. §5 now requires a single writer per record, failing to start over proceeding beside another writer, prefers an exclusive claim the OS releases on death, and requires failing to open where a platform cannot provide exclusivity at all. No conformance clause (`C…`) is added: the pack has no vector for it, and numbering a clause nothing tests would claim coverage that does not exist. That vector is the follow-up. ## Falsification | mutation | result | |---|---| | **M1** no lock taken at all (the pre-fix behaviour) | `the_single_writer_claim_matches_what_open_does` + 1 FAILED | | **M2** lock taken **after** the read and truncation | **only** `a_refused_open_does_not_truncate_the_holders_journal` FAILED | | **M3** a shared lock instead of an exclusive one | `the_single_writer_claim_matches_what_open_does` + 1 FAILED | | **M4** a contended lock reported as success | `the_single_writer_claim_matches_what_open_does` + 2 FAILED | | **M5** the unsupported-platform error swallowed | **only** `a_platform_that_cannot_lock_fails_closed` FAILED | **M5 survived the first round, and that was the finding.** Swallowing `TryLockError::Error` broke no test, because that arm is unreachable on a platform whose locks work — and an unreachable arm is exactly where a fail-**open** sits unnoticed. `lock_outcome` is split out from the I/O so all three outcomes are reachable from a test, and M5 now fails only its own. **M2** is the other one worth reading: the lock is still present, just in the wrong place, and only the truncation test notices. A blocking-lock mutation was deliberately not used: it would hang the suite rather than fail a test, and a mutation that hangs reports nothing. ## Verification - `cargo check --workspace --all-targets --locked` (`jcp-rs`) — exit **0** - `cargo test --workspace --locked` — exit **0**, **64** test binaries green, no failures - `cargo clippy -p jcp-wire --all-targets --no-deps --locked -- -D warnings` — clean - `check-lockfiles` — 42 lockfiles, all current (unchanged); `check-spec-refs` 1673 citations / 16 standards ## The sibling **`mesh-core`'s journal had the identical defect** and is fixed the same way in **#347**. Mesh's journal is hash-chained, so there the chain *detects* a second writer — but only after both stewards have already served against the same spend, and at the cost of making the journal unreplayable. The two standards deliberately keep their own copies of this machinery, as `mesh-receipt` already vendors JCR-1 rather than depending on `jcp-schema`, so each gets its own change rather than a shared crate. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jcp-wire: one writer per journal, or the replay spends twice
Some checks failed
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
f246a411df
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 advisory lock, and takes it **before** the read
and the truncation: an opener refused afterwards would already have cut the
holder's file. `flock` is the choice rather than a lock-file sidecar because the
kernel releases it when the descriptor closes *or the process dies*, so a crash
leaves no stale lock and there is no recovery story to get wrong. `libc` is
added under `[target.'cfg(unix)'.dependencies]` — already a dependency of
`jcp-gateway` and `jcp-intercept` at the same version, so no new external crate
enters the tree.

`flock` is POSIX. Rather than let the type imply a property it cannot hold
everywhere, `FileJournal::SINGLE_WRITER_ENFORCED` states it as a constant a
caller can **check** at run time, and the test asserts the constant against the
behaviour in *both* directions — where it claims enforcement a second open must
be refused, and where it does not a second open must succeed. A branch that
asserted nothing would leave the constant free to understate as well as
over-claim.

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 enforce it MUST say so checkably.

Falsification, four mutations, all killed by their named test:

- no lock at all (the pre-fix behaviour), and a shared lock instead of an
  exclusive one, and a contended lock reported as success — each fails the
  claim-matches-behaviour test;
- the lock taken *after* the read and truncation fails **only** the
  no-truncation test, which is what isolates the ordering rather than the
  presence of the lock.

Clippy also caught a flaw in my first version of that test: an
`assert!(!SINGLE_WRITER_ENFORCED)` in the else branch was a constant assertion
and vacuous. It now asserts behaviour instead.

**`mesh-core`'s journal has the identical defect** and is not touched here;
there is no exclusive-lock precedent anywhere else in open-standards, so this
introduces the pattern.

Co-Authored-By: Claude Opus 5 <[email protected]>
dcharlot force-pushed fix/journal-single-writer from f246a411df
Some checks failed
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
to 8fb6588e63
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
2026-10-19 22:04:00 -04:00
Compare
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!346
No description provided.