From 6423d5d892a0631ce5caa56e1673bea0d3ebf7b9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:42:01 -0400 Subject: [PATCH] =?UTF-8?q?jcp:=20C25=20=E2=80=94=20flow=20was=20a=20decla?= =?UTF-8?q?red=20clause=20with=20no=20check;=20now=20it=20has=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9 has listed `C25 — flow` as a conformance clause for as long as the clause existed, and the harness never had it. Worse than a missing test: a published list of conformance classes claiming a class the pack cannot run. Verified before writing anything, because a declared gap is not evidence of one: - the harness's only occurrence of "flow" was an unrelated `// control flow` comment; - `jcp-flow` is not even a dependency of `jcp-conformance`, so no check could have used it; - and `jcp-flow` is the wrong crate anyway — it is provenance *scanning* (L17–L20, content windows). The clause is about the *decision*: a grant's ceiling, the enforcement point's taint floor, attenuation. That lives in `jcp_schema::flow` and `jcp-runtime`, both already dependencies, so the check needs no new one. `C25` asserts each property the clause states, separately, so a failure names which one broke: - a label at or below the grant's ceiling passes and one above it does not; - the enforcement point's taint floor binds **even under a ceiling permissive enough to admit the value** — an operator refusal a delegated grant cannot widen; - facts that were never computed **fail closed** against a ceilinged grant. This is the unknown-is-not-a-value rule in its sharpest form: "no provenance" must not read as "no taint"; - several ceilings compose to the strictest, so adding a permissive caveat cannot widen a strict one; - a flow denial debits nothing, so a tainted call costs zero joules; - a ceiling-free grant — which is every grant minted before flow existed — behaves identically whatever the facts say and carries no flow field on the wire. Falsified against the implementation rather than the check, which is the only direction that means anything for a conformance clause: four mutations of `flow_gate` and `ceiling_of` (ceiling unenforced, floor unapplied, uncomputed provenance failing **open**, ceilings composing to the most permissive). All four caught, each with its own message, so the clause discriminates between the properties instead of failing generically. Also fixes the asymmetry in the other direction: the harness has carried a `C19` (injection resistance) that §9 never listed, so §9 now lists it. The spec's classes and the harness's checks are both C1–C26 and agree item for item. I first wrote this as `C27`, taking the next free id. That was wrong — §9 had already assigned `C25` to flow, and implementing a different id would have recreated the asymmetry this closes. Co-Authored-By: Claude Opus 5 --- jcp/SPEC.md | 1 + jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 105 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 5f27d1314..b1f6ad44c 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -1285,6 +1285,7 @@ A conforming implementation passes, with real checks: forged, stale, and regressing statements are refused; post-settlement the merged ledger binds and any overdraft is provable. - **C18 — DKG:** a dealer-free k-of-n actor signs a standard Ed25519 signature; +- **C19 — injection resistance:** a tool-poisoning corpus presented through an untrusted frame cannot steer a decision; the trust tag is structural, so data cannot become control. (Implemented in the harness since before this entry: §9 listed every other clause but omitted this one.) one share cannot. - **C-neg:** expired, forged, and wrong-caller frames are denied with the precise reason; canonical CBOR rejects floats/bools. diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 1aa76b418..56f35ea98 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -29,6 +29,13 @@ //! 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. +//! - **C19 — injection resistance:** a tool-poisoning corpus cannot steer a +//! decision through an untrusted frame. +//! - **C25 — flow:** a propagated label must sit at or below the grant's +//! ceiling and clear the enforcement point's taint floor; facts that were +//! never computed fail closed against a ceilinged grant; several ceilings +//! compose to the strictest; a flow denial debits nothing; and a +//! ceiling-free grant behaves and encodes as it did before flow existed. //! - **C-neg:** expired grants, forged grants, and wrong callers are denied with //! the precise reason; canonical CBOR rejects floats/bools. @@ -73,6 +80,7 @@ pub fn checks() -> Vec { 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: "C25 flow: label under ceiling, floor, fail-closed, no debit", run: c25_flow }, Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] @@ -936,6 +944,103 @@ fn c26_single_writer() -> Result<(), String> { Ok(()) } +/// C25 — §4.10's flow ceiling and taint floor. §9 has listed this clause by +/// this id since before any check existed for it; this is the check. +/// +/// `jcp-flow` covers provenance *scanning*; this covers the decision: what the +/// runtime does with a label once it has one. +fn c25_flow() -> Result<(), String> { + use jcp_schema::flow::{FlowCeiling, FlowFacts, Integrity, Label, Origin, Origins, Readers}; + + let r = ContentId::of_bytes(b"db"); + let holder = did(&sk(2)); + let ceilinged = |caveats: Vec| -> Result { + let mut g = grant(&sk(1), holder, r, &["read"], 1_000_000, 0).grant; + g.caveats = caveats; + issue(&sk(1), g).map_err(|e| e.to_string()) + }; + // A ceiling admitting only principal-clean, origin-free values. + let principal_only = + || FlowCeiling::new(Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public }); + let permissive = || FlowCeiling { ceiling: Label::top(), tainted_budget_uj: None }; + let tainted = |channel: u8| FlowFacts { + label: Label { + integrity: Integrity::Untrusted, + origins: Origins::one(Origin::channel(ContentId::of_bytes(&[channel; 4]))), + readers: Readers::Public, + }, + witness_id: ContentId([0u8; 32]), + eval_uj: 5, + eval_prov: Provenance::ModelBased, + }; + + // (a) A label at or below the ceiling passes; one above it does not. + let sg = ceilinged(vec![Caveat::FlowBelow(principal_only())])?; + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + if !rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &FlowFacts::bottom()).is_allow() { + return Err("a clean label was denied under a ceiling that admits it".into()); + } + let mut rt = Runtime::new(); + let denied = rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(3)); + if denied.is_allow() { + return Err("a label above the grant's ceiling was allowed".into()); + } + + // (e) A flow denial debits nothing: a tainted call costs zero joules. + if rt.spent(&sg.grant.id()) != 0 { + return Err(format!("a denied tainted call debited {} uJ", rt.spent(&sg.grant.id()))); + } + + // (b) The enforcement point's own taint floor binds even under a ceiling + // permissive enough to admit the value. + let sg_open = ceilinged(vec![Caveat::FlowBelow(permissive())])?; + let f_open = frame(&sg_open, r, "read", Trust::Control); + let mut rt = Runtime::new(); + if !rt.decide_flow(&f_open, &sg_open, holder, Energy::measured(10), 10, &tainted(4)).is_allow() { + return Err("a permissive ceiling denied a label it admits, before any floor was set".into()); + } + let mut rt = Runtime::new(); + rt.forbid_flow(r, Origins::one(Origin::channel(ContentId::of_bytes(&[4u8; 4])))); + if rt.decide_flow(&f_open, &sg_open, holder, Energy::measured(10), 10, &tainted(4)).is_allow() { + return Err("the resource's taint floor was not applied under a permissive ceiling".into()); + } + + // (c) Facts that were never computed fail closed against a ceilinged grant, + // rather than passing as clean. This is the unknown-is-not-a-value rule: + // "no provenance" must not read as "no taint". + let mut rt = Runtime::new(); + if rt.decide(&f, &sg, holder, Energy::measured(10), 10).is_allow() { + return Err("a ceilinged grant allowed a call whose provenance was never computed".into()); + } + + // (d) Several ceilings compose to the strictest, so adding a permissive one + // cannot widen a strict one. + let sg_both = ceilinged(vec![Caveat::FlowBelow(principal_only()), Caveat::FlowBelow(permissive())])?; + let f_both = frame(&sg_both, r, "read", Trust::Control); + let mut rt = Runtime::new(); + if rt.decide_flow(&f_both, &sg_both, holder, Energy::measured(10), 10, &tainted(5)).is_allow() { + return Err("a permissive ceiling widened a strict one instead of composing to the strictest".into()); + } + + // (f) A ceiling-free grant is what every grant minted before flow existed + // is: it must behave identically whatever the facts say, and carry no + // flow field on the wire. + let plain = ceilinged(Vec::new())?; + let f_plain = frame(&plain, r, "read", Trust::Control); + for facts in [FlowFacts::bottom(), tainted(6)] { + let mut rt = Runtime::new(); + if !rt.decide_flow(&f_plain, &plain, holder, Energy::measured(10), 10, &facts).is_allow() { + return Err("a ceiling-free grant was gated by flow facts".into()); + } + } + let bytes = canonical_cbor(&plain.grant).map_err(|e| e.to_string())?; + if String::from_utf8_lossy(&bytes).contains("flow") { + return Err("a ceiling-free grant carries a flow field on the wire".into()); + } + 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)