jcp: C26 — the one-writer requirement gets a conformance clause with teeth #352

Merged
dcharlot merged 1 commit from feat/c25-single-writer-conformance into main 2026-10-20 08:28:34 -04:00
2 changed files with 48 additions and 0 deletions

View file

@ -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.

View file

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