From 29eb46469eb29ab6dd648b583d07b96498456fdf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 22:00:04 -0400 Subject: [PATCH] =?UTF-8?q?mesh-core:=20one=20steward=20per=20journal=20?= =?UTF-8?q?=E2=80=94=20the=20chain=20detects=20a=20second,=20it=20cannot?= =?UTF-8?q?=20stop=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 --- mesh/SPEC.md | 14 ++ mesh/mesh-rs/crates/mesh-core/src/journal.rs | 162 ++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/mesh/SPEC.md b/mesh/SPEC.md index a83f0dddf..f64bcdc8e 100644 --- a/mesh/SPEC.md +++ b/mesh/SPEC.md @@ -137,6 +137,20 @@ journal is not one the steward produced, and the steward MUST NOT serve from it. Replayed presentations count as spent, and the receipt chain continues from the last `Sealed` entry (§6.3). +**One steward per journal.** A steward MUST take exclusive access to the +journal when it opens it, and MUST fail to start rather than serve where +another holds it — or where the platform cannot provide exclusivity at all. + +The chain above **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 the rule +above requires the steward not to serve from the journal at all — so the +detection arrives after the overspend and costs the steward its ability to +restart. An exclusive claim the operating system releases when the holding +process dies is preferable to one the steward must clean up, because a crash +then leaves nothing to recover. + An authorisation with no debit is a serve that was in flight when the steward stopped, and whether the engine finished is not knowable from the journal. The steward MUST NOT retry it or report it as completed or failed. It stays debited, diff --git a/mesh/mesh-rs/crates/mesh-core/src/journal.rs b/mesh/mesh-rs/crates/mesh-core/src/journal.rs index b9c564c64..ca2c1c754 100644 --- a/mesh/mesh-rs/crates/mesh-core/src/journal.rs +++ b/mesh/mesh-rs/crates/mesh-core/src/journal.rs @@ -1,7 +1,7 @@ //! The steward's decision journal (SPEC §2.2): where a grant's spend at this //! steward, and the steward's receipt chain, survive the process (JCP §4.11). -use std::fs::{File, OpenOptions}; +use std::fs::{File, OpenOptions, TryLockError}; use std::io::{self, Read, Write}; use std::path::Path; @@ -118,6 +118,11 @@ impl FileJournal { /// records it already holds, for /// [`Mesh::with_journal`](crate::Mesh::with_journal) to replay. /// + /// Takes an **exclusive** lock first, so a second steward cannot hold the + /// same journal. Fails with [`io::ErrorKind::WouldBlock`] where the + /// journal is already held, and fails rather than proceeding where the + /// platform cannot provide a lock at all. + /// /// A final line with no newline is an append the steward was interrupted /// in. It is dropped and cut from the file, which is safe for every entry: /// an authorisation never recorded never started its serve, a debit never @@ -126,6 +131,10 @@ impl FileJournal { /// because a journal that cannot be read cannot re-establish spend. pub fn open(path: impl AsRef) -> io::Result<(Self, Vec)> { let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?; + // Before the read, and before the truncation below: a would-be second + // steward must be refused *before* it can cut a torn trailing line out + // from under the holder's appends. + lock_exclusively(&file)?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; let complete = bytes.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1); @@ -147,6 +156,48 @@ impl FileJournal { } } +/// Take an exclusive lock on the journal file, so a second steward cannot hold +/// it. +/// +/// **The chain detects a second writer; it does not prevent one.** Two +/// stewards on one journal each replay it, each derive the same spend and each +/// serve against it, so the budget is overspent *in the world* while both run. +/// Afterwards their records collide on `seq`, the chain no longer forms and +/// [`Mesh::with_journal`](crate::Mesh::with_journal) refuses the journal +/// entirely — so the detection costs the steward its ability to restart, +/// having already failed to stop the overspend. [`FileJournal::open`] also +/// truncates a torn trailing line, so a concurrent opener can cut the file out +/// from under an appender. +/// +/// The lock is the operating system's, held by the open file and released when +/// it closes **or the process dies**, so a crash leaves nothing to recover. A +/// lock-file sidecar would need a story for the crash case, and that story is +/// where such schemes go wrong. +fn lock_exclusively(file: &File) -> io::Result<()> { + lock_outcome(file.try_lock()) +} + +/// Turn a lock attempt into an outcome for [`FileJournal::open`]. +/// +/// Separate from the I/O so every arm is testable. The +/// [`TryLockError::Error`] arm cannot be reached on a platform whose locks +/// work, so on this one it is unreachable through `open` — and an unreachable +/// arm is exactly where a fail-**open** would sit unnoticed. Falsification +/// found it: swallowing that error broke no test until this function existed. +fn lock_outcome(attempt: Result<(), TryLockError>) -> io::Result<()> { + match attempt { + Ok(()) => Ok(()), + Err(TryLockError::WouldBlock) => Err(io::Error::new( + io::ErrorKind::WouldBlock, + "journal already held by another steward: a second one would serve against the same spend", + )), + // The platform could not provide exclusivity. Failing to open is the + // safe direction, and the only honest one: the alternative is serving + // against a record another steward may also be writing. + Err(TryLockError::Error(e)) => Err(e), + } +} + impl Journal for FileJournal { fn append(&mut self, record: &JournalRecord) -> io::Result<()> { let mut line = serde_json::to_vec(record)?; @@ -155,3 +206,112 @@ impl Journal for FileJournal { self.file.sync_data() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_path(tag: &str) -> std::path::PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let p = std::env::temp_dir() + .join(format!("mesh-core-journal-{}-{tag}-{n}.log", std::process::id())); + let _ = std::fs::remove_file(&p); + p + } + + /// A second steward must not be able to hold the journal. + /// + /// `std::fs::File::try_lock` is the operating system's lock, so this needs + /// no platform caveat and no `unsafe`: where a platform cannot provide one + /// at all, `open` fails rather than proceeding. + #[test] + fn the_single_writer_claim_matches_what_open_does() { + let path = tmp_path("second-open"); + let (_held, records) = FileJournal::open(&path).expect("the first open"); + assert!(records.is_empty()); + + let err = FileJournal::open(&path) + .err() + .expect("a second open of a held journal must be refused"); + assert_eq!(err.kind(), io::ErrorKind::WouldBlock, "{err}"); + let _ = std::fs::remove_file(&path); + } + + /// Every arm of the lock policy, including the one `open` cannot reach on + /// a platform whose locks work. + /// + /// A platform that cannot lock **MUST NOT** yield an open journal: the + /// alternative is serving against a record another steward may also be + /// writing, which is the overspend this whole mechanism exists to stop. + #[test] + fn a_platform_that_cannot_lock_fails_closed() { + assert!(lock_outcome(Ok(())).is_ok(), "an acquired lock opens the journal"); + + let contended = lock_outcome(Err(TryLockError::WouldBlock)) + .expect_err("a contended lock must not open the journal"); + assert_eq!(contended.kind(), io::ErrorKind::WouldBlock); + + let unsupported = + lock_outcome(Err(TryLockError::Error(io::Error::from(io::ErrorKind::Unsupported)))) + .expect_err("an unsupported lock must not open the journal"); + assert_eq!( + unsupported.kind(), + io::ErrorKind::Unsupported, + "the platform's own error is propagated, not replaced" + ); + } + + /// The lock is the kernel's, held by the descriptor, so dropping the + /// journal frees it — and a crash frees it the same way, which is why this + /// is an advisory lock rather than a lock file. + #[test] + fn dropping_the_journal_releases_the_lock() { + let path = tmp_path("release"); + { + let (_held, _) = FileJournal::open(&path).expect("the first open"); + } + let (_again, records) = FileJournal::open(&path).expect("reopen after drop"); + assert!(records.is_empty()); + let _ = std::fs::remove_file(&path); + } + + /// **The lock is taken before the truncation.** `open` cuts a trailing line + /// with no newline, so an opener refused *afterwards* would already have + /// cut the holder's file. A refused open must leave it byte-for-byte. + /// + /// Raw bytes rather than real records: the property under test is the + /// ordering of the lock against the truncation, and a torn line is all + /// that takes. + #[test] + fn a_refused_open_does_not_truncate_the_holders_journal() { + let path = tmp_path("no-truncate"); + let (held, _) = FileJournal::open(&path).expect("the first open"); + + // A torn line appended behind the holder's back: no trailing newline, + // which `open` cuts away. + { + use std::io::Write; + let mut raw = OpenOptions::new().append(true).open(&path).expect("raw append"); + raw.write_all(b"{\"partial\"").expect("torn line"); + raw.sync_data().expect("sync"); + } + let before = std::fs::metadata(&path).expect("stat").len(); + assert!(before > 0, "the torn line is on disk"); + + assert!(FileJournal::open(&path).is_err(), "the second open must be refused"); + assert_eq!( + std::fs::metadata(&path).expect("stat").len(), + before, + "a refused open truncated the file it was refused from" + ); + drop(held); + + // Once the holder is gone the torn line is cut, as it always was — the + // truncation still happens, just never from a refused writer. + let (_now, records) = FileJournal::open(&path).expect("reopen"); + assert!(records.is_empty(), "the torn line yields no record"); + assert_eq!(std::fs::metadata(&path).expect("stat").len(), 0, "and is cut"); + let _ = std::fs::remove_file(&path); + } +} -- 2.54.0 (Apple Git-157)