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));