From 3a57c1f21ed058ad35714014ad73fe82de29d8e8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:03:44 -0400 Subject: [PATCH] =?UTF-8?q?jcp:=20C26=20=E2=80=94=20the=20one-writer=20req?= =?UTF-8?q?uirement=20gets=20a=20conformance=20clause=20with=20teeth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #346 added §5's one-writer requirement and deliberately added no conformance clause, on the grounds that numbering one the pack cannot test would claim coverage that does not exist. This is that follow-up: the clause, and a check that fails when the implementation stops providing it. `C26 one writer per record: a second holder is refused` asserts three things on the reference `FileJournal`: - a second holder of a record another endpoint holds is **refused**, because two would each replay it, each re-establish the same remaining budget and each debit against it — spending it twice while every entry still verifies; - the refusal is specifically a **contention**, not some other I/O failure a caller might paper over; - the claim is **released when the holder goes away**, or a restart could never reopen the record it previously held, which would turn the requirement into a trap. §9 gains the clause and the harness header gains its class, so the spec's list and the pack agree. **Proving the clause has teeth took two attempts, and the first was wrong.** I first mutated the *check* — accepting a second holder, accepting any error kind — and the suite stayed green. That is not a surviving defect: weakening a check makes it vacuous, and a vacuous check passes. The meaningful direction for a conformance clause is to break the **implementation** and require the clause to catch it: | implementation mutation | result | |---|---| | the lock does not exclude a second holder | C26 FAILED: "a second writer opened a record another endpoint holds" | | no lock taken at all | C26 FAILED, same message | | contention reported as a different error kind | C26 FAILED: "a contended record failed for the wrong reason" | All three caught, with C26 named — which also settles that the clause is actually executed by `all_checks_pass` rather than merely listed. Noted while counting clause ids, not touched here: §9 declares a `C25 — flow` the harness does not implement, and the harness has a `C19` (injection resistance) §9 does not list. Both asymmetries predate this change. Co-Authored-By: Claude Opus 5 --- jcp/SPEC.md | 1 + jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 47 ++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 4c75b2d94..2547128ff 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -1375,3 +1375,4 @@ gate. - **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; `ModelIn` admits only a frame naming one of its models, and denies a frame naming another or none; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. - **C25 — flow:** a value's propagated label must sit at or below the grant's ceiling and clear the resource's taint floor; an argument whose provenance was **not computed** fails closed against a ceilinged grant rather than passing as clean; several ceilings in a chain compose to the strictest and attenuation never widens one; a flow denial debits nothing, so a tainted call costs zero joules; and a grant with no flow caveat encodes byte-identically to one minted before flow existed. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. A priced chained decision applies the parent's money ceiling to its child's spend, and a delegated grant decided without its chain is denied `chain-broken`. +- **C26 — one writer per record:** a durable decision record admits one writer. A second holder of the same record is refused rather than opening it, because two would each replay it, each re-establish the same remaining budget and each debit against it — spending it twice while every entry still verifies (§5). The exclusive claim is released when the holder goes away, so a restart can reopen the record it previously held, and a platform that cannot provide exclusivity fails the open rather than proceeding. diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index f3ab5626e..1aa76b418 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -25,6 +25,10 @@ //! - **C9 — energy honesty:** the meter ladder always yields a figure and its //! provenance is internally consistent (HwShunt ⇒ measured present; //! Estimator ⇒ measured absent). +//! - **C26 — one writer per record:** a durable decision record admits one +//! writer; a second holder is refused, the claim is released with the holder +//! so a restart can reopen it, and a platform that cannot lock fails the +//! open rather than proceeding. //! - **C-neg:** expired grants, forged grants, and wrong callers are denied with //! the precise reason; canonical CBOR rejects floats/bools. @@ -68,6 +72,7 @@ pub fn checks() -> Vec { Check { id: "C22 dual ceilings: money beside joules, no rate invented", run: c22_dual_ceilings }, Check { id: "C23 receipt binds its payload + jcp/1 still verifies", run: c23_payload_binding }, Check { id: "C24 caveats: one-shot, per-call cost, model, monotone attenuation", run: c24_caveats }, + Check { id: "C26 one writer per record: a second holder is refused", run: c26_single_writer }, Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] @@ -889,6 +894,48 @@ fn c13_signed_caller() -> Result<(), String> { Ok(()) } +/// C26 — §5's one-writer requirement, on the reference `FileJournal`. +/// +/// Two endpoints holding one record would each replay it, each re-establish +/// the *same* remaining budget and each debit against it: the budget is spent +/// twice while every entry still verifies, so nothing in the record shows it. +/// The record must therefore admit one writer and refuse the second. +/// +/// Also checks the two properties that make the refusal usable rather than a +/// trap: the claim is released when the holder goes away, so a restart can +/// reopen the record it held; and the refusal is specifically a contention, +/// not some other I/O failure a caller might paper over. +fn c26_single_writer() -> Result<(), String> { + let path = std::env::temp_dir() + .join(format!("jcp-conformance-c26-{}.journal", std::process::id())); + let _ = std::fs::remove_file(&path); + + let (held, records) = + jcp_wire::FileJournal::open(&path).map_err(|e| format!("a fresh record did not open: {e}"))?; + if !records.is_empty() { + return Err("a fresh record replayed entries".into()); + } + + match jcp_wire::FileJournal::open(&path) { + Ok(_) => { + let _ = std::fs::remove_file(&path); + return Err("a second writer opened a record another endpoint holds".into()); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => { + let _ = std::fs::remove_file(&path); + return Err(format!("a contended record failed for the wrong reason: {e}")); + } + } + + // Released with the holder, or a restart could never reopen its own record. + drop(held); + jcp_wire::FileJournal::open(&path) + .map_err(|e| format!("the record did not reopen after its holder went away: {e}"))?; + let _ = std::fs::remove_file(&path); + Ok(()) +} + fn c14_replay() -> Result<(), String> { let issuer = sk(1); let (worker_a, worker_b) = (sk(3), sk(4)); -- 2.54.0 (Apple Git-157)