From f865bd9975207acfcdc76abeefb62e1682bc6a40 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 22 Jul 2026 04:17:15 -0400 Subject: [PATCH] jcp-receipt: Seal a flow verdict as jcp/3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime gate decides a call against its argument's provenance; this makes that decision a sealed, replayable artifact. A new jcp/3 profile carries a flow block — the folded label, its origins and readers, the witness commitment, the ceiling it was tested against, the below/above verdict, and the monitor's own evaluation energy — so a third party can see not just that a call was allowed or denied but on what provenance grounds. The compatibility property is load-bearing and byte-exact: jcp/3 is minted only when a call is actually tainted. A clean call — no facts, or facts that were computed and came back bottom — seals as jcp/2, byte-for-byte identical to the plain path, so a flow deployment and a non-flow one share a receipt stream and every existing receipt is unmoved. A test pins that seal_flow of a clean seal equals seal. The flow energy is sealed as its own field, flow_uj, never folded into cost_uj: a call's tool energy keeps its exact meaning, and the cost of running the monitor is accounted separately and honestly. The witness is a commitment, not the artifact — the cut is disclosed out of band, so the receipt stays small and the disclosure boundary stays where jcp-cite already put it. verify_flow recomputes the jcp/3 core from the same seal, so a jcp/3 receipt round-trips; a jcp/2 clean call verifies through it too. The chain path (append_flow) keeps seq/prev identical regardless of profile, so a mixed jcp/2 + jcp/3 log verifies end to end via verify_log, and extract reads a jcp/3 core generically because it is jcp/2 plus a block. Four tests cover the byte-identity, the tainted seal and its block, the above verdict on a flow denial, and the mixed-profile chain. Deferred, and NOT the plan's §4D fix: replay_spend under-counts a cost-breached call, but the plan's proposed predicate (count caveat-unsatisfied denies toward spend) is unsound — a caveat deny debits in the charge path yet not in the decide path, and the receipt cannot tell them apart, so counting them over-counts the decide path. The correct fix needs an explicit debited_uj field on the wire, a separate change with its own compatibility analysis; it is left untouched here rather than replaced with a broken heuristic. Increment 4 of the FL-2 plan; the gateway relay that computes the facts and the receipt-stream replay follow. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-receipt/src/lib.rs | 265 +++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index ad74ca5d1..9770d3125 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -35,6 +35,37 @@ pub const PROFILE: &str = "jcp/2"; /// (see [`verify`]), but are no longer minted. pub const PROFILE_V1: &str = "jcp/1"; +/// The **flow** profile (SPEC §8, §4.10): `jcp/2` plus a `flow` block recording a +/// call's propagated provenance verdict. Minted **only** when a call is tainted; +/// a clean call still seals as [`PROFILE`] (`jcp/2`), byte-for-byte, so nothing +/// on the existing path moves. +pub const PROFILE_FLOW: &str = "jcp/3"; + +/// What a flow verdict seals: the argument's provenance facts and, if the grant +/// carried one, the digest of the flow ceiling it was tested against. A `None` +/// or bottom `facts` means the call was clean and the receipt stays `jcp/2`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FlowSeal { + pub facts: Option, + /// The ceiling's content id ([`jcp_schema::FlowCeiling`] digest), if any. + pub ceiling: Option, +} + +impl FlowSeal { + /// A clean seal — the call touched no untrusted provenance, so the receipt is + /// an ordinary `jcp/2`. + pub fn clean() -> Self { + FlowSeal { facts: None, ceiling: None } + } + + /// Is this seal tainted (a non-bottom label)? Decides whether a receipt is + /// minted as `jcp/3` rather than `jcp/2`. + fn is_tainted(&self) -> bool { + self.facts.as_ref().is_some_and(|f| !f.is_bottom()) + } +} + + /// Project a decision to the **legacy `jcp/1`** wire — byte-for-byte what this /// library sealed before `payload_hash` existed. Retained solely so previously /// sealed receipts keep verifying; never used to mint. @@ -76,6 +107,117 @@ pub fn to_jcr1_wire(frame: &Frame, grant: &Grant, decision: &Decision, steward: Value::Object(obj) } +/// The `flow` block of a `jcp/3` receipt: the sealed provenance verdict. All +/// text/uint/array (no float/bool/null), so it survives canonical CBOR, and the +/// witness is a **commitment** — the artifact itself is disclosed out of band, not +/// carried here. +fn flow_block(facts: &jcp_schema::FlowFacts, seal: &FlowSeal, decision: &Decision) -> Value { + use jcp_schema::flow::{Integrity, Origins, Readers}; + let label = &facts.label; + let integrity = match label.integrity { + Integrity::Untrusted => "untrusted", + Integrity::Derived => "derived", + Integrity::Attested => "attested", + Integrity::Principal => "principal", + }; + let origins = match &label.origins { + Origins::Any => json!("any"), + Origins::Set(set) => { + Value::Array(set.iter().map(|o| json!(o.channel.to_hex())).collect()) + } + }; + let readers = match &label.readers { + Readers::Public => json!("public"), + Readers::Only(dids) => { + Value::Array(dids.iter().map(|d| json!(d.to_did_key())).collect()) + } + }; + // The flow verdict is about the gate specifically: "above" the ceiling exactly + // when the decision was denied for flow. A tainted call denied for another + // reason (or allowed under a permissive ceiling) passed the flow gate: "below". + let verdict = if decision.verdict == Verdict::Deny(jcp_schema::DenyReason::TaintedFlow) { + "above" + } else { + "below" + }; + let mut block = serde_json::Map::new(); + block.insert("label".into(), json!(label.digest().map(|c| c.to_hex()).unwrap_or_default())); + block.insert("integrity".into(), json!(integrity)); + block.insert("origins".into(), origins); + block.insert("readers".into(), readers); + block.insert("witness".into(), json!(facts.witness_id.to_hex())); + if let Some(ceiling) = &seal.ceiling { + block.insert("ceiling".into(), json!(ceiling.to_hex())); + } + block.insert("verdict".into(), json!(verdict)); + // The monitor's own evaluation cost — sealed as its OWN field, never folded + // into `cost_uj` (the tool's energy), so a call's energy keeps its meaning. + block.insert("flow_uj".into(), json!(facts.eval_uj)); + block.insert("flow_energy_provenance".into(), json!(facts.eval_prov.jcr1_label())); + Value::Object(block) +} + +/// Project a decision to flow-aware wire JSON. When the call is clean this is +/// byte-for-byte [`to_jcr1_wire`] (`jcp/2`); when tainted it is `jcp/3` — the same +/// core plus a `flow` block. The clean case producing identical bytes is what lets +/// a non-flow deployment and a flow deployment share a receipt stream. +pub fn to_jcr1_wire_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + steward: &str, + seal: &FlowSeal, +) -> Value { + let mut wire = to_jcr1_wire(frame, grant, decision, steward); + if seal.is_tainted() { + if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) { + o.insert("jcp_receipt".into(), json!(PROFILE_FLOW)); + o.insert("flow".into(), flow_block(facts, seal, decision)); + } + } + wire +} + +/// Seal a decision with its flow verdict. A clean call yields an ordinary `jcp/2` +/// envelope indistinguishable from [`seal`]; a tainted call yields `jcp/3`. +pub fn seal_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + key: &SigningKey, + steward: &str, + seal: &FlowSeal, +) -> Result, ReceiptError> { + let core = jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))?; + Ok(jcr1::cose_sign1_envelope(&core, key, steward)) +} + +/// Verify a flow envelope: like [`verify`], but recomputes the `jcp/3` core from +/// the same `seal`, so a `jcp/3` receipt round-trips. A `jcp/2` envelope (a clean +/// call) verifies here too, since a clean seal recomputes the `jcp/2` core. +pub fn verify_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + seal: &FlowSeal, + envelope: &[u8], + steward: &str, + pubkey: &[u8; 32], +) -> Result<(), ReceiptError> { + let payload = jcr1::verify_cose_sign1(envelope, steward, pubkey)?; + let expected = match profile_of(&payload)?.as_str() { + PROFILE | PROFILE_FLOW => { + jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))? + } + PROFILE_V1 => jcr1::signed_core_bytes(&to_jcr1_wire_v1(frame, grant, decision, steward))?, + other => return Err(ReceiptError::UnknownProfile(other.to_string())), + }; + if payload != expected { + return Err(ReceiptError::PayloadMismatch); + } + Ok(()) +} + fn frame_kind_tag(f: &Frame) -> &'static str { use jcp_schema::FrameKind::*; match f.kind { @@ -404,6 +546,32 @@ impl ReceiptLog { env } + /// Seal a decision as the next link, carrying its flow verdict. A clean call + /// appends a `jcp/2` entry byte-identical to [`append`](Self::append); a + /// tainted call appends a `jcp/3` entry. Either way the chaining (`seq`/`prev`) + /// is identical, so a mixed stream verifies end to end via [`verify_log`]. + pub fn append_flow( + &mut self, + frame: &Frame, + grant: &Grant, + decision: &Decision, + seal: &FlowSeal, + ) -> Vec { + let mut wire = to_jcr1_wire_logged(frame, grant, decision, &self.steward, self.seq, &self.head); + if seal.is_tainted() { + if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) { + o.insert("jcp_receipt".into(), json!(PROFILE_FLOW)); + o.insert("flow".into(), flow_block(facts, seal, decision)); + } + } + let core = jcr1::signed_core_bytes(&wire).expect("canonical core"); + let env = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward); + self.head = jcp_schema::ContentId::of_bytes(&env); + self.seq += 1; + self.entries.push(env.clone()); + env + } + /// The sealed entries, in order. pub fn entries(&self) -> &[Vec] { &self.entries @@ -663,4 +831,101 @@ mod tests { let env = seal(&f, &g, &d, &key, "s").unwrap(); assert!(verify(&f, &g, &d, &env, "s", &key.verifying_key().to_bytes()).is_ok()); } + + // ── jcp/3 flow seal (SPEC §8, §4.10) ───────────────────────────────────── + + use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers}; + use jcp_schema::{FlowFacts, Provenance}; + + fn tainted_facts() -> FlowFacts { + FlowFacts { + label: Label { + integrity: Integrity::Untrusted, + origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))), + readers: Readers::Public, + }, + witness_id: ContentId::of_bytes(b"a witness"), + eval_uj: 42, + eval_prov: Provenance::ModelBased, + } + } + + #[test] + fn a_clean_call_is_byte_identical_to_jcp2() { + // The load-bearing compatibility property: a clean flow seal produces the + // exact bytes of the plain path, so a flow deployment and a non-flow one + // share a stream and every existing receipt is unmoved. + let (f, g, d) = scenario(); + let key = sk(7); + let plain = seal(&f, &g, &d, &key, "s").unwrap(); + let clean = seal_flow(&f, &g, &d, &key, "s", &FlowSeal::clean()).unwrap(); + assert_eq!(plain, clean, "a clean flow seal == the plain jcp/2 seal"); + // Even facts that were computed but are bottom stay jcp/2. + let bottom = FlowSeal { facts: Some(FlowFacts::bottom()), ceiling: None }; + assert_eq!(plain, seal_flow(&f, &g, &d, &key, "s", &bottom).unwrap()); + } + + #[test] + fn a_tainted_call_seals_jcp3_with_a_flow_block() { + let (f, g, d) = scenario(); + let key = sk(7); + let seal_data = FlowSeal { + facts: Some(tainted_facts()), + ceiling: Some(ContentId::of_bytes(b"the ceiling")), + }; + let env = seal_flow(&f, &g, &d, &key, "s", &seal_data).unwrap(); + + // It verifies against the same seal, and NOT as a plain jcp/2 (the flow + // block is part of the signed core). + let pk = key.verifying_key().to_bytes(); + assert!(verify_flow(&f, &g, &d, &seal_data, &env, "s", &pk).is_ok()); + assert!(verify(&f, &g, &d, &env, "s", &pk).is_err(), "jcp/3 needs the seal to verify"); + + // The profile and flow block are present and correct. + let payload = jcr1::verify_cose_sign1(&env, "s", &pk).unwrap(); + let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap(); + assert_eq!(v.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3")); + let flow = v.get("flow").expect("flow block"); + assert_eq!(flow.get("integrity").and_then(Value::as_str), Some("untrusted")); + assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("below")); // the scenario allowed + assert_eq!(flow.get("flow_uj").and_then(Value::as_u64), Some(42)); + assert_eq!(flow.get("flow_energy_provenance").and_then(Value::as_str), Some("ModelBased")); + assert!(flow.get("witness").and_then(Value::as_str).is_some()); + assert!(flow.get("ceiling").and_then(Value::as_str).is_some()); + } + + #[test] + fn a_flow_denied_call_seals_verdict_above() { + // A tainted call denied for flow records "above" the ceiling. + let (f, g, _d) = scenario(); + let denied = Decision { + verdict: Verdict::Deny(jcp_schema::DenyReason::TaintedFlow), + cost: jcp_schema::Energy::measured(0), + budget_remaining_uj: 100, + }; + let key = sk(7); + let seal_data = FlowSeal { facts: Some(tainted_facts()), ceiling: None }; + let env = seal_flow(&f, &g, &denied, &key, "s", &seal_data).unwrap(); + let payload = jcr1::verify_cose_sign1(&env, "s", &key.verifying_key().to_bytes()).unwrap(); + let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap(); + assert_eq!(v["flow"].get("verdict").and_then(Value::as_str), Some("above")); + assert_eq!(v.get("deny_reason").and_then(Value::as_str), Some("tainted-flow")); + } + + #[test] + fn a_mixed_jcp2_and_jcp3_log_verifies_end_to_end() { + let (f, g, d) = scenario(); + let key = sk(7); + let mut log = ReceiptLog::new(key.clone(), "s"); + log.append_flow(&f, &g, &d, &FlowSeal::clean()); // jcp/2 + log.append_flow(&f, &g, &d, &FlowSeal { facts: Some(tainted_facts()), ceiling: None }); // jcp/3 + log.append(&f, &g, &d); // jcp/2 plain + let pk = key.verifying_key().to_bytes(); + assert_eq!(verify_log(log.entries(), "s", &pk), Ok(3), "the chain is intact across profiles"); + + // extract reads a jcp/3 entry's core generically (it is jcp/2 + a block). + let core = extract(&log.entries()[1], "s", &pk).unwrap(); + assert_eq!(core.verdict, "allow"); + assert_eq!(core.cost_uj, 30, "cost_uj is the tool energy, untouched by flow_uj"); + } } -- 2.54.0 (Apple Git-157)