mesh-core: one steward per journal — the chain detects a second, it cannot stop one #347

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

§2.2 requires a steward that can restart to keep a durable journal and to re-establish spend by replaying it. Nothing required the journal to have one writer, and FileJournal::open was:

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

with no lock at all. This is the sibling of #346, found while fixing it.

Mesh's chain changes the failure without removing it

Mesh's journal is hash-chained ({seq, prev}, BLAKE3), and §2.2 says a journal that does not form one chain means the steward MUST NOT serve from it. So the chain catches concurrent writers. That is not the same as stopping them:

The chain detects a second writer; it does not prevent one.

Two stewards sharing a journal each replay it, each re-establish the same spend and each serve against it — the grant is overspent while both are running. Only afterwards do their records collide on seq, the chain stop forming, and Mesh::with_journal (verified at mesh-core/src/lib.rs:220) refuse the journal. So the detection arrives after the overspend and costs the steward its ability to restart at all.

open also truncates a torn trailing line, 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 — which this workspace denies, and which is what sent me looking for a better primitive than the flock binding this started as;
  • no new dependency and no lockfile change in any workspace (mesh-core is named in joulehook's lockfile too, via mesh-openai, so a dependency here would have staled that one);
  • 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 — 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. Serving against a record another steward may also be writing is the overspend this exists to stop, and a platform limit is not a licence for it.

Falsification

mutation result
M1 no lock 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 therefore split out from the I/O so all three outcomes are reachable from a test, and M5 now fails only its own. This is the third fix running where the untested thing turned out to be the central claim rather than an edge case.

M2 is the other one worth reading: the lock is still there, 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 (mesh-rs) — exit 0
  • cargo test --workspace --locked — exit 0, 22 test binaries green, no failures
  • cargo check -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server --all-targets --locked — exit 0 (CI's feature leg)
  • cargo clippy -p mesh-core --all-targets --no-deps --locked -- -D warnings — exit 0
  • check-lockfiles — 42 lockfiles, all current; check-spec-refs 1673 citations / 16 standards; check-comparative-prose clean

§2.2 gains the requirement, since the gap was in the spec before it was in the code — prose alone would not stop a reimplementation repeating it. 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.

🤖 Generated with Claude Code

§2.2 requires a steward that can restart to keep a durable journal and to re-establish spend by **replaying** it. Nothing required the journal to have one **writer**, and `FileJournal::open` was: ```rust OpenOptions::new().read(true).append(true).create(true) ``` with no lock at all. This is the sibling of #346, found while fixing it. ## Mesh's chain changes the failure without removing it Mesh's journal is hash-chained (`{seq, prev}`, BLAKE3), and §2.2 says a journal that does not form one chain means the steward **MUST NOT** serve from it. So the chain *catches* concurrent writers. That is not the same as stopping them: > **The chain detects a second writer; it does not prevent one.** Two stewards sharing a journal each replay it, each re-establish the same spend and each serve against it — the grant is overspent **while both are running**. Only afterwards do their records collide on `seq`, the chain stop forming, and `Mesh::with_journal` (verified at `mesh-core/src/lib.rs:220`) refuse the journal. So the detection arrives *after* the overspend and costs the steward its ability to restart at all. `open` also truncates a torn trailing line, 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` — which this workspace denies, and which is what sent me looking for a better primitive than the `flock` binding this started as; - **no new dependency** and no lockfile change in any workspace (`mesh-core` is named in `joulehook`'s lockfile too, via `mesh-openai`, so a dependency here would have staled that one); - **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 — 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. Serving against a record another steward may also be writing is the overspend this exists to stop, and a platform limit is not a licence for it. ## Falsification | mutation | result | |---|---| | **M1** no lock 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 therefore split out from the I/O so all three outcomes are reachable from a test, and M5 now fails only its own. This is the third fix running where the untested thing turned out to be the central claim rather than an edge case. **M2** is the other one worth reading: the lock is still there, 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` (`mesh-rs`) — exit **0** - `cargo test --workspace --locked` — exit **0**, **22** test binaries green, no failures - `cargo check -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server --all-targets --locked` — exit **0** (CI's feature leg) - `cargo clippy -p mesh-core --all-targets --no-deps --locked -- -D warnings` — exit **0** - `check-lockfiles` — 42 lockfiles, all current; `check-spec-refs` 1673 citations / 16 standards; `check-comparative-prose` clean §2.2 gains the requirement, since the gap was in the spec before it was in the code — prose alone would not stop a reimplementation repeating it. 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mesh-core: one steward per journal — the chain detects a second, it cannot stop one
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 9s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 56s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m20s
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 2m45s
29eb46469e
§2.2 requires a steward that can restart to keep a durable journal and to
re-establish spend by replaying it. Nothing required the journal to have one
writer, and `FileJournal::open` was

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

with no lock at all.

Mesh's journal is hash-chained (`{seq, prev}`), and that changes the failure
from jcp-wire's without removing it. **The chain detects a second writer; it
does not prevent one.** Two stewards sharing a journal each replay it, each
re-establish the same spend and each serve against it, so the grant is
overspent *while both are running*. Their records then collide on `seq`, the
chain no longer forms, and `Mesh::with_journal` refuses the journal entirely —
so the detection arrives after the overspend and costs the steward its ability
to restart. `open` also truncates a torn trailing line, so a concurrent opener
can cut the file out from under an appender.

`open` now takes an exclusive lock, **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`,
which this workspace denies, 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, which a lock-file sidecar would need a story for.

Where a platform cannot lock at all, `open` **fails** rather than proceeding:
serving against a record another steward may also be writing is the overspend
this exists to stop.

Falsification, five mutations, all killed by their named test. **M5 survived the
first round** — swallowing the unsupported-platform error broke nothing, 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 therefore split out from
the I/O so every arm is reachable from a test, and M5 now fails only its own.
This is the third fix running where the untested thing was the central claim.

M2 is the other one worth reading: the lock still present but taken after the
truncation fails **only** the no-truncation test, isolating the ordering from
the existence of the lock.

§2.2 gains the requirement, since the gap was in the spec before it was in the
code. `jcp-wire` carried the identical defect and is fixed the same way in
#346; the two standards deliberately keep their own copies of this machinery,
as `mesh-receipt` already vendors JCR-1 rather than depending on `jcp-schema`.

Co-Authored-By: Claude Opus 5 <[email protected]>
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!347
No description provided.