From fa54cb16b98233cda7bf656074456cda82dc9ffd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 14 Jul 2026 09:42:24 -0400 Subject: [PATCH 1/8] =?UTF-8?q?jouleclaw-runtime:=20score=5Fbinary=20?= =?UTF-8?q?=E2=80=94=20one-forward-pass=20token=20scoring=20for=20rerankin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add score_binary / score_binary_tokens: prefill a prompt and return the softmax probability of one token vs another as the next token — the mechanism of a cross-encoder reranker (Qwen3-Reranker and friends format query+document into a yes/no question and read P(yes)). One forward pass, no generation. - Uses the lfm2-aware cached step builder (run_inplace_step_cached), so it scores hybrid conv+attention models too (the plain run_inplace_step assumes every block is attention and dies on a conv layer). - score_binary_tokens takes pre-tokenized input so a reranker prompt's chat special tokens (<|im_start|>/<|im_end|>) can be encoded atomically — plain BPE would shatter them and the model would score garbage. - Reads the last-position logits as the final vocab window, robust to whether the step returns per-position or last-only logits. Verified with Qwen3-Reranker-0.6B (real_reranker.rs, ignored): relevant document 0.300 vs irrelevant 0.120. Note: the reranker template must omit the reasoning block for the 0.6B GGUF. Co-Authored-By: Claude Opus 4.8 --- .../crates/jouleclaw-runtime/src/generate.rs | 76 +++++++++++++++++++ .../jouleclaw-runtime/tests/real_reranker.rs | 39 ++++++++++ 2 files changed, 115 insertions(+) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/real_reranker.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/src/generate.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/src/generate.rs index 4cc5d2b2b..d36c3c982 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/src/generate.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/src/generate.rs @@ -316,6 +316,82 @@ fn generate_with_inplace_cache( Ok((generated, stopped)) } +/// Score a prompt by how much more probable `positive` is than `negative` as the +/// very next token — the mechanism behind a cross-encoder reranker (Qwen3-Reranker +/// and friends format a query + document into a yes/no question and read P(yes)). +/// One forward pass over the prompt, no generation; returns P(positive) in [0, 1]. +/// +/// Works with any causal LM, so a dedicated reranker model is optional — the +/// already-loaded generation model can score relevance directly. +pub fn score_binary( + model: &GgufModel, + vocab: &Vocab, + text: &str, + positive: u32, + negative: u32, + cfg: &GenerateConfig, +) -> Result { + let tokenizer_kind = resolve_tokenizer_kind(vocab, cfg.tokenizer_kind); + let tokens = match tokenizer_kind { + TokenizerKind::Spm => vocab.encode_spm(text, cfg.add_bos), + TokenizerKind::Bpe => vocab.encode_bpe_regex(text, cfg.add_bos), + TokenizerKind::Auto => unreachable!(), + }; + score_binary_tokens(model, vocab, &tokens, positive, negative) +} + +/// [`score_binary`] over pre-tokenized input. A reranker prompt is full of chat +/// special tokens (`<|im_start|>`, `<|im_end|>`) that plain BPE would shatter into +/// byte-pieces — encode those atomically (`encode_with_specials`) and pass the ids +/// here so the model sees the real template. +pub fn score_binary_tokens( + model: &GgufModel, + vocab: &Vocab, + tokens: &[u32], + positive: u32, + negative: u32, +) -> Result { + if tokens.is_empty() { + return Err(GenerateError::EmptyPromptTokens); + } + let vocab_size = vocab.len(); + if positive as usize >= vocab_size || negative as usize >= vocab_size { + return Err(GenerateError::Load(LoadError::UnsupportedArchitecture( + "score token id out of vocab range".into(), + ))); + } + + let runtime = Runtime::boot(); + let mut cache = InPlaceKvCache::for_model(model, tokens.len() + 1)?; + // Use the lfm2-aware cached step builder (not the plain run_inplace_step, which + // assumes every block is attention and dies on a hybrid model's conv layers). + let mut shortconv = ShortConvStateCache::for_model(model)?; + let mut step_cache = DecodeStepCache::new(); + let prefill = run_inplace_step_cached( + model, + &runtime, + &mut cache, + &mut shortconv, + &mut step_cache, + tokens_to_tensor(tokens), + tokens.len(), + )?; + + // The next-token distribution is the LAST row of the prefill logits. Read it as + // the final vocab_size window so this is correct whether the step returns logits + // for every position (n*vocab) or only the last one (1*vocab). + let base = prefill.logits.len().saturating_sub(vocab_size); + let logit = |id: u32| prefill.logits.get(base + id as usize).copied().unwrap_or(f32::NEG_INFINITY); + let pos = logit(positive); + let neg = logit(negative); + // Numerically-stable two-way softmax over just the yes/no pair. + let max = pos.max(neg); + let pos_exp = (pos - max).exp(); + let neg_exp = (neg - max).exp(); + let denom = pos_exp + neg_exp; + Ok(if denom > 0.0 { pos_exp / denom } else { 0.0 }) +} + pub(crate) fn resolve_tokenizer_kind(vocab: &Vocab, requested: TokenizerKind) -> TokenizerKind { match requested { TokenizerKind::Auto => { diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/real_reranker.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/real_reranker.rs new file mode 100644 index 000000000..c6ec109b0 --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime/tests/real_reranker.rs @@ -0,0 +1,39 @@ +//! Cross-encoder reranking via `score_binary_tokens`: a Qwen3-Reranker (or any +//! instruct model) scores a (query, document) pair by the yes-vs-no logit after a +//! single forward pass. Ranks relevant documents above irrelevant ones. +//! +//! Ignored by default (needs a real GGUF). Run with: +//! JOULE_RERANK=/path/qwen3-reranker-0.6b-q8_0.gguf \ +//! cargo test -p jouleclaw-runtime --test real_reranker -- --ignored --nocapture +use jouleclaw_loader_gguf::read_gguf_file; +use jouleclaw_loader_gguf::tokenizer::Vocab; +use jouleclaw_runtime::chat::encode_with_specials; +use jouleclaw_runtime::generate::score_binary_tokens; + +fn rerank_prompt(instruct: &str, query: &str, doc: &str) -> String { + // Qwen3-Reranker format WITHOUT the reasoning `` block — the 0.6B GGUF + // predicts yes/no directly; the think block derails it. + format!("<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n: {instruct}\n: {query}\n: {doc}<|im_end|>\n<|im_start|>assistant\n") +} + +#[test] +#[ignore] +fn reranker_ranks_relevant_above_irrelevant() { + let path = std::env::var("JOULE_RERANK").expect("set JOULE_RERANK to a reranker gguf"); + let model = read_gguf_file(&path).expect("read"); + let vocab = Vocab::from_gguf(&model).expect("vocab"); + let yes = *vocab.token_to_id.get("yes").expect("yes token"); + let no = *vocab.token_to_id.get("no").expect("no token"); + + let instruct = "Given a code search query, find the relevant source file"; + let query = "how to invalidate the cache"; + let score = |doc: &str| { + let toks = encode_with_specials(&vocab, &rerank_prompt(instruct, query, doc), &["<|im_start|>", "<|im_end|>"], false); + score_binary_tokens(&model, &vocab, &toks, yes, no).expect("score") + }; + + let relevant = score("fn cache_invalidate() { self.entries.clear(); }"); + let irrelevant = score("struct Color { r: u8, g: u8, b: u8 }"); + eprintln!("relevant={relevant:.3} irrelevant={irrelevant:.3}"); + assert!(relevant > irrelevant, "reranker must rank the cache fn above the color struct"); +} -- 2.54.0 (Apple Git-157) From 28d2bb59931a5ee14a69b394d9eb006f4678343c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 14 Jul 2026 15:01:47 -0400 Subject: [PATCH 2/8] Pin the toolchain to Rust 1.97 Match the fleet's current stable (agent-joule moved to 1.97.0 too), so a standalone jouleclaw build and a build as an agent-joule dependency agree. Co-Authored-By: Claude Opus 4.8 --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d58c37bd7..191fc678f 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.96.0" +channel = "1.97.0" components = ["rustc", "cargo", "rust-std", "rustfmt", "clippy"] profile = "default" -- 2.54.0 (Apple Git-157) From 3172a43fbde36e13d0aa9c728a53e6fcd84b7fe5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 21 Jul 2026 06:19:07 -0400 Subject: [PATCH 3/8] jcp-bridge: Identify the authorization server per RFC 9207 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge validated the `iss` claim inside an access token (RFC 9068) but had no check on the authorization *response*. Those guard different steps. The token claim says who signed a credential already in hand, which only means something once that credential is trusted; RFC 9207 guards the step before it. In a mix-up attack a client is steered to a malicious authorization server that returns a code or token minted elsewhere. With no issuer on the response the client cannot tell the answer came from a different server than the question, and forwards the credential to the honest one. Comparing the response's `iss` against the server the request actually went to is what breaks that, and it is the check MCP's 2026-07-28 OAuth 2.1 hardening expects a client to perform. A missing `iss` fails closed rather than being read as benign: a response that cannot name its issuer proves nothing about its origin, which is precisely the condition the check exists to rule out. Decoding is the caller's job and is documented as such. The parameter arrives in a redirect URI query where a did:key issuer's colons are percent-encoded, and guessing at decoding here would silently compare an encoded string against a decoded issuer — a test pins that a still-encoded value is refused rather than quietly accepted. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 4b2f3dff1..8ae31915b 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -61,6 +61,53 @@ pub enum OAuthError { /// Key/crypto failure. #[error("crypto: {0}")] Crypto(String), + /// The authorization response carried no `iss` (RFC 9207). Treated as fatal: + /// a response that cannot identify its issuer is indistinguishable from the + /// mix-up case it exists to rule out. + #[error("authorization response has no iss parameter (RFC 9207)")] + AuthorizationIssuerMissing, + /// The authorization response's `iss` is not the server the request went to — + /// a mix-up attack, or a misconfiguration that must be treated as one. + #[error("authorization response issuer {got} is not the expected {expected}")] + AuthorizationIssuerMixUp { expected: String, got: String }, +} + +/// The RFC 9207 authorization-response parameter naming the issuing server. +pub const AUTHORIZATION_ISS_PARAM: &str = "iss"; + +/// **RFC 9207 issuer identification** — check that an authorization response came +/// from the authorization server the request was actually sent to. +/// +/// This is a distinct check from the `iss` *claim* inside an access token +/// ([`verify_access_token`]): that claim says who signed a token already in hand, +/// which is only meaningful once the token is trusted. RFC 9207 guards the step +/// before, where a client redeems an authorization code. In a **mix-up attack** a +/// client is steered to a malicious authorization server, which hands back a code +/// or token minted elsewhere; without an issuer on the *response*, the client +/// cannot tell that the answer came from a different server than the question, and +/// forwards the credential to the honest server. Comparing the response's `iss` +/// against the server the request went to is what breaks that. +/// +/// Fails closed on a missing `iss`: a response that cannot name its issuer proves +/// nothing about its origin, so it is refused rather than assumed benign. +/// +/// `response_iss` must be the **decoded** parameter value. It arrives in a redirect +/// URI query, where a `did:key:` issuer's colons are percent-encoded; comparing the +/// still-encoded form against a decoded issuer would fail every legitimate response +/// and pass none, so decoding is the caller's job and is not guessed at here. +pub fn check_authorization_issuer( + response_iss: Option<&str>, + expected_issuer: &Did, +) -> Result<(), OAuthError> { + let expected = did_key(expected_issuer); + match response_iss { + None => Err(OAuthError::AuthorizationIssuerMissing), + Some(got) if got == expected => Ok(()), + Some(got) => Err(OAuthError::AuthorizationIssuerMixUp { + expected, + got: got.to_string(), + }), + } } /// Project a verified [`SignedGrant`] into an RFC 9068 access-token JWT, signed @@ -280,6 +327,49 @@ mod tests { issue(sk, grant).unwrap() } + #[test] + fn authorization_issuer_matches_the_server_asked() { + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + assert!(check_authorization_issuer(Some(&did_key(&honest)), &honest).is_ok()); + } + + #[test] + fn authorization_issuer_catches_a_mix_up() { + // The client sent its request to `honest`, but the response names + // `attacker` — the credential was minted somewhere else and must not be + // forwarded to the honest server. + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + let attacker = Did::from_verifying_key(&SigningKey::from_bytes(&[3u8; 32]).verifying_key()); + let error = check_authorization_issuer(Some(&did_key(&attacker)), &honest) + .expect_err("a foreign issuer is rejected"); + match error { + OAuthError::AuthorizationIssuerMixUp { expected, got } => { + assert_eq!(expected, did_key(&honest)); + assert_eq!(got, did_key(&attacker)); + } + other => panic!("expected a mix-up error, got {other:?}"), + } + } + + #[test] + fn authorization_without_an_issuer_fails_closed() { + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + assert!(matches!( + check_authorization_issuer(None, &honest), + Err(OAuthError::AuthorizationIssuerMissing) + )); + } + + #[test] + fn a_still_encoded_issuer_does_not_pass() { + // Guards the documented contract: the caller decodes. A percent-encoded + // value must not be silently accepted, or the check would pass on a string + // that was never compared to the real issuer. + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + let encoded = did_key(&honest).replace(':', "%3A"); + assert!(check_authorization_issuer(Some(&encoded), &honest).is_err()); + } + #[test] fn base64url_roundtrips() { for case in [&b""[..], b"f", b"fo", b"foo", b"foob", b"any byte \x00\xff!"] { -- 2.54.0 (Apple Git-157) From 7e65cd33d8cfa81d454e73500825d6f38be4270a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 22 Jul 2026 01:38:43 -0400 Subject: [PATCH 4/8] =?UTF-8?q?jcp-schema:=20Add=20the=20flow=20lattice=20?= =?UTF-8?q?=E2=80=94=20propagated=20provenance=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JCP's Trust tag is a one-bit boundary marker: a frame is principal Control or untrusted Data, and a Data frame may never exercise a capability. That stops output from being executed as instructions, but it cannot express "this argument was derived from an earlier untrusted read" — the bit does not propagate through the values a run computes, so a laundering attack across tool calls slips past it. This is the propagated form: a Label is a point in a lattice over three axes — integrity (a total order, Untrusted up to Principal), the set of channel origins a value flowed from, and its readers. One join folds a derived value's label from its inputs; the order tells the runtime whether a value may pass a grant's ceiling. The direction is fixed once: bottom is the cleanest label (Principal, no origins, Public), and join moves toward more taint, so a ⊑ b reads "a is at least as clean as b" and taint on a derived value never drops below any input. The one-bit world is recovered exactly — Label::from(t).trust() == t, and the retraction is monotone, so refining a label never contradicts the tag and a clean run stays byte-identical. Sixteen lattice laws ship as tests rather than prose, because every layer above this — the runtime gate, the sealed verdict, replay — rests on the algebra being sound: idempotent/commutative/associative join with identity and absorbing top, the order agreeing with the join, a genuine partial order (antisymmetry needs the Any-saturation to be canonical, hence wire normalization), monotone join, no-laundering, downward-closed authorization, attenuation that can only narrow, and deterministic order-free digests. All collections are BTree, never Hash, so a label content-addresses identically on every platform. This is increment 1 of the FL-2 plan (FLOW_LATTICE_PLAN.md); the witness scan and its laws L17-L20, the runtime gate, the jcp/3 receipt seal, and the gateway relay follow in their own crates. Co-Authored-By: Claude Opus 4.8 --- .../crates/jcp-schema/FLOW_LATTICE_PLAN.md | 572 +++++++++++++++ jcp/jcp-rs/crates/jcp-schema/src/flow.rs | 674 ++++++++++++++++++ jcp/jcp-rs/crates/jcp-schema/src/lib.rs | 3 + 3 files changed, 1249 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md create mode 100644 jcp/jcp-rs/crates/jcp-schema/src/flow.rs diff --git a/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md b/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md new file mode 100644 index 000000000..d61c892b1 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md @@ -0,0 +1,572 @@ +# FL-2 / `jcp-flow` — flow-lattice provenance propagation design + +> Blueprint for upgrading JCP's one-bit `Trust::Control/Data` boundary into +> propagated provenance labels, sealed into JCR-1 receipts and the joule budget. +> Produced by a multi-agent design+adversarial-judging pass (4 designs × 3 attack +> lenses → synthesis), then verified against the real tree. +> +> **Status:** increment 1 of ~5 shipped — the flow lattice (`jcp-schema/src/flow.rs`, +> laws L1–L16). Remaining: witness/store/scan + L17–L20 (`jcp-flow`), the runtime +> gate (`jcp-runtime`), the `jcp/3` receipt seal (`jcp-receipt`), and the gateway +> Phase 0–4 relay (`jcp-gateway`). See §1's file list. + +--- + +Verified against the real tree: `jcp-flow` does not exist yet, the gateway depends on `jcp-schema/jcp-grant/jcp-runtime/jcp-receipt/jcp-bridge` (not `jcp-policy`), and the runtime signatures match the grounding exactly (`check` at :272, `authorize` :320, `charge` :354, `decide` :227, `decide_chained` :385). That confirms the two structural facts the plan must respect: the gateway seals only through `ReceiptLog`/jcp-2 and drives `authorize`→`charge`, never `decide` and never a `PolicyEngine`. + +Here is the synthesized plan. + +--- + +# FL-2 / `jcp-flow`: a sealed flow lattice with a replayable provenance witness + +**One-line thesis.** Keep FL-1's lattice-as-authorization core, but bind and seal the verdict on the path the gateway *actually runs* (`authorize`→`charge`→`ReceiptLog`, profile `jcp/3`), and make it replayable with PROV-1's content-addressed witness disclosed through a store — not through a `policy/1` sibling that has no emitter and not through a `store_root` that cannot be reopened. + +## 0. What was grafted, and every winner fatal-flaw resolved + +| Winner (FL-1) fatal flaw | Resolution in FL-2 | +|---|---| +| **F1** buzhash rolling hash is buggy; "Law L17" (39-byte carry guarantee) is false; short secrets below floor invisible | Replace the fragile rolling hash with an **exact `blake3` W-byte-window index** (TAS's mechanism, W=16, no rotate arithmetic to get wrong). Carry ≥ W bytes is *exactly* detectable. Add a **`SensitiveTerms` exact index** for short registered secrets (account numbers, recipients) so length-` u8 { self as u8 } + pub const fn weaker(self, o: Self) -> Self { if (self as u8) <= (o as u8) { self } else { o } } + pub const fn stronger(self, o: Self) -> Self { if (self as u8) >= (o as u8) { self } else { o } } +} +impl From for Integrity { // Data ↦ Untrusted, Control ↦ Principal + fn from(t: Trust) -> Self { match t { Trust::Control => Integrity::Principal, Trust::Data => Integrity::Untrusted } } +} + +/// Where a value came from: the CONTENT-ADDRESSED channel id (mcp_resource(server,tool)) +/// plus the principal inside it. Content-addressed, not string-named, so it is +/// forgery-resistant AND floor rules are authorable at startup from a known channel id. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Origin { pub channel: ContentId, + #[serde(skip_serializing_if="Option::is_none", default)] pub principal: Option } + +pub const MAX_ORIGINS: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="kebab-case", try_from="OriginsWire")] +pub enum Origins { Set(BTreeSet), Any } // Any = saturation top; sound over-approx +// (norm(): |set|>MAX_ORIGINS ⇒ Any. `try_from` on the wire re-imposes norm so ⊑ stays antisymmetric.) +impl Origins { + pub fn join(&self, o:&Self)->Self; // union, saturating + pub fn meet(&self, o:&Self)->Self; // intersection; Any is identity (for ceilings) + pub fn leq(&self, o:&Self)->bool; // subset; _ ⊑ Any + pub fn intersects(&self, mask:&Self)->bool; // used by TaintFloor + pub fn none()->Self; pub fn one(o:Origin)->Self; pub fn is_empty(&self)->bool; +} + +/// Confidentiality half — PRESENT & SEALED, enforcement is v2 (see §7). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="kebab-case")] +pub enum Readers { Public, Only(BTreeSet) } +impl Readers { pub fn join(&self,o:&Self)->Self /*intersection, Public=id*/; + pub fn meet(&self,o:&Self)->Self; pub fn leq(&self,o:&Self)->bool; + pub fn permits(&self, audience:&BTreeSet)->bool; } + +/// A point in the flow lattice. Private fields ⇒ equality is semantic ⇒ ⊑ antisymmetric. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Label { integrity: Integrity, origins: Origins, readers: Readers } +impl Label { + pub fn bottom()->Self; // (Principal, ∅, Public) ← neutral / pre-flow + pub fn top()->Self; // (Untrusted, Any, Only(∅)) ← unknown id resolves here + pub fn join(&self,o:&Self)->Self; // weaker integrity, ∪ origins, ∩ readers ← THE operator + pub fn meet(&self,o:&Self)->Self; // ceilings only + pub fn leq(&self,o:&Self)->bool; // rank≥ ∧ origins⊑ ∧ readers⊑ ← authorization + pub fn is_bottom(&self)->bool; + pub fn digest(&self)->ContentId; // Merkle root: canonical-CBOR-legal by construction + pub fn trust(&self)->Trust; // Untrusted↦Data else Control (retraction) + pub fn integrity(&self)->Integrity; pub fn origins(&self)->&Origins; pub fn readers(&self)->&Readers; +} +impl From for Label { /* (Integrity::from(t), none, Public) */ } +``` + +`crates/jcp-flow/src/witness.rs` — the replayable artifact (PROV-1's insight, made concrete): + +```rust +/// Evidence class of a taint edge — SEALED, so the receipt shows whether the +/// verdict rested on hard bytes or the conservative floor. +#[derive(Clone,Copy,Debug,PartialEq,Eq,PartialOrd,Ord,Serialize,Deserialize)] +pub enum EdgeKind { Witnessed, Declared, Ambient } + +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Edge { + pub parent: ContentId, // the ancestor payload's content id (its store key) + pub kind: EdgeKind, + #[serde(skip_serializing_if="u64_is_zero", default)] pub witnessed_bytes: u64, + // For a Witnessed edge, the fingerprints proving overlap — a third party + // rechecks these against BOTH endpoints' window sets (subset test). + #[serde(skip_serializing_if="BTreeSet::is_empty", default)] pub grams: BTreeSet, +} + +/// The MINIMAL ancestor cut + its edges + the folded label. Content-addressed, +/// self-contained, offline-replayable. The receipt commits only `id()`. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Witness { + pub schema: alloc::string::String, // "flow/1" + pub params: ScanParams, // W, and (later) sensitive-term set digest — pinned for exact replay + pub subject: ContentId, // == frame.payload_hash of the decided call + pub label: Label, // the folded effective label + pub cut: alloc::vec::Vec, // each direct ancestor: (content id, label, anchor) + pub edges: alloc::vec::Vec, +} +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct CutNode { pub id: ContentId, pub label: Label, pub anchor: ContentId } // anchor = ReceiptLog head when admitted +impl Witness { pub fn id(&self)->ContentId { ContentId::of(self).expect("canonical") } } + +/// What the runtime consumes at gate time. Bottom ⇒ every existing path is byte-identical. +#[derive(Clone,Debug,PartialEq,Eq)] +pub struct FlowFacts { + pub label: Label, + pub witness_id: ContentId, // commitment; the Witness itself lives in the WitnessStore + pub eval_uj: u64, // monitor's own metered cost — reported, never folded into cost_uj + pub eval_prov: jcp_schema::Provenance, +} +impl FlowFacts { pub fn bottom()->Self; pub fn is_bottom(&self)->bool { self.label.is_bottom() } } +``` + +`crates/jcp-flow/src/store.rs` — session state + the **exact** carry detector (F1 fix): + +```rust +pub const SCAN_W: usize = 16; // detection floor = exactly W bytes (blake3, not buzhash) +#[derive(Clone,Copy,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct ScanParams { pub w: u16, pub sensitive_root: ContentId } // pinned in every Witness + +/// Every W-byte window, DOMAIN-SEPARATED blake3 → u64. No rolling state, no +/// rotate arithmetic (the FL-1 bug lived there). Same bytes ⇒ same set, always. +fn windows(bytes: &[u8], w: usize) -> BTreeSet { /* blake3::keyed_hash("jcp/flow/window/v1", &bytes[i..i+w]) truncated */ } + +pub struct FlowStore { + labels: BTreeMap, + windows: BTreeMap>, // fingerprint → admitted ids containing it + sensitive: BTreeMap, BTreeSet>, // short registered terms → source ids + order: alloc::vec::Vec, // FIFO eviction (deterministic; evicted ⇒ ⊤ ⇒ stricter) + high_water: Label, // monotone session floor for Ambient mode + head: ContentId, // ReceiptLog head at last admission (the Witness anchor) + cap: usize, params: ScanParams, +} +impl FlowStore { + /// Boundary: admit a tool RESULT. Label = channel origin (Untrusted) ⊔ args label. + /// Also registers any adapter-declared sensitive terms for this output. + pub fn admit_output(&mut self, bytes:&[u8], channel:ContentId, principal:Option, + args:&Label, readers:Readers, terms:&[&[u8]], anchor:ContentId) -> (ContentId, Label); + pub fn admit_principal(&mut self, bytes:&[u8], anchor:ContentId) -> (ContentId, Label); // ⊥ + pub fn admit_attested(&mut self, bytes:&[u8], channel:ContentId, signer:Did, anchor:ContentId) -> (ContentId, Label); + + /// THE propagation entry point. Fold over declared ∪ window-found ∪ sensitive-found ∪ (Ambient? high_water), + /// force ⊤ for any named-but-absent id, admit the payload, emit a self-contained Witness. + pub fn label_payload(&mut self, bytes:&[u8], declared:&BTreeSet, mode:Mode, anchor:ContentId) + -> (ContentId, Label, Witness); + pub fn get(&self, id:&ContentId)->Option<&Label>; pub fn head(&self)->ContentId; +} +#[derive(Clone,Copy,Debug,PartialEq,Eq)] pub enum Mode { Explicit, HighWater } // HighWater = coarse implicit-flow floor +``` + +`crates/jcp-flow/src/ceiling.rs`: + +```rust +/// A grant's flow ceiling — greatest label whose values may exercise the capability. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct FlowCeiling { pub ceiling: Label, + #[serde(skip_serializing_if="Option::is_none", default)] pub tainted_budget_uj: Option } +impl FlowCeiling { + pub fn attenuate(&self, child:&FlowCeiling)->FlowCeiling; // meet on label, min on budget ⇒ amplification unrepresentable + pub fn effective_budget(&self, grant_uj:u64, l:&Label)->u64; +} +/// Endorsement — signed declassification (PROV-1). Bound to ONE exact (subject,label) pair. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Endorsement { pub endorser:Did, pub subject:ContentId, pub from_label:ContentId, + pub to_label:ContentId, pub sig:alloc::vec::Vec } +``` + +`crates/jcp-flow/src/replay.rs` — the actual deliverable (PROV-1's verifier): + +```rust +pub trait WitnessStore { fn get(&self, id:&ContentId) -> Option; } +pub struct ReplayReport { pub verified:usize, pub unjustified_edges:usize, pub ambient_only:usize } +pub enum ReplayError { Chain(usize), WitnessMismatch(ContentId), LabelMismatch(ContentId), + UnjustifiedEdge(ContentId), FoldMismatch(ContentId), Excised(ContentId), VerdictMismatch(ContentId) } +/// Re-verify a jcp/2+jcp/3 receipt stream against disclosed witnesses. See §4E. +pub fn replay(receipts:&[alloc::vec::Vec], store:&dyn WitnessStore, + steward:&str, pk:&[u8;32]) -> Result; +``` + +### 1b. `crates/jcp-schema/src/lib.rs` — two additive, encoding-preserving changes + +```rust +pub enum Caveat { /* …6 existing… */ + /// NEW (SPEC §4.9). Flow-dependent: holds()=>true; the real test is the runtime flow gate. + /// Additive to an externally-tagged enum ⇒ no grant that omits it moves its ContentId + /// (same property `absent_money_ceiling_does_not_change_the_encoding` pins for budget_funds). + FlowBelow(jcp_flow::FlowCeiling), +} +impl Caveat { pub fn is_flow_dependent(&self)->bool { matches!(self, Caveat::FlowBelow(_)) } } +// holds(): Caveat::FlowBelow(_) => true (never blocks the caveat path) + +pub enum DenyReason { /* …14 existing… */ + TaintedFlow, // label ⋢ ceiling, or resource floor refuses + TaintBudgetExceeded, // tainted sub-ceiling hit +} +// label(): "tainted-flow", "taint-budget-exceeded" + +pub struct Grant { /* … */ + #[serde(skip_serializing_if="Option::is_none", default)] pub tainted_budget_uj: Option, // omit-when-absent + /* … */ } +``` + +`Frame` is **not touched** (§5). `jcp-schema` gains a `jcp-flow` dep — acyclic (`jcp-flow` depends only on `jcp-schema`, so put `FlowCeiling` behind a `flow` feature *or* move `FlowCeiling`/`Label` into `jcp-schema` itself to avoid the cycle). **Decision: define `Label`/`Origins`/`Integrity`/`Readers`/`FlowCeiling` in `jcp-schema` (they are pure data types with no logic-heavy deps), and keep `FlowStore`/`Witness`/`replay`/scan in `jcp-flow`.** That removes the cycle cleanly and lets `Caveat::FlowBelow` reference the ceiling without a back-dep. + +### 1c. `crates/jcp-runtime/src/lib.rs` + +```rust +pub struct Runtime { /* …existing… */ + taint_floor: TaintFloor, // NEW — mirrors trust_roots (resource → forbidden origins) + spent_tainted_uj: HashMap, // NEW — mirrors spent_minor +} +pub struct TaintFloor { forbidden: BTreeMap, default_forbidden: Origins } +impl TaintFloor { fn refuses(&self, resource:&ContentId, l:&Label)->bool { + l.origins().intersects(self.forbidden.get(resource).unwrap_or(&self.default_forbidden)) } } +impl Runtime { + pub fn forbid_flow(&mut self, resource:ContentId, mask:Origins); // mirrors trust_root + pub fn forbid_flow_by_default(&mut self, mask:Origins); // fail-closed switch (off by default) + pub fn spent_tainted(&self, gid:&ContentId)->u64; + // flow-aware entry points; old ones delegate with facts=None (see §5). + pub fn authorize_flow(&mut self, frame:&Frame, sg:&SignedGrant, caller:Did, now:u64, facts:&FlowFacts)->Decision; + pub fn charge_flow(&mut self, grant:&Grant, measured:Energy, facts:&FlowFacts, flow_uj:u64)->Decision; + pub fn decide_flow(&mut self, frame:&Frame, sg:&SignedGrant, caller:Did, cost:Energy, now:u64, facts:&FlowFacts)->Decision; + pub fn decide_chained_flow(&mut self, frame:&Frame, chain:&[SignedGrant], caller:Did, cost:Energy, now:u64, facts:&FlowFacts)->Decision; +} +// check() gains one private param; it is private, so no public signature moves: +fn check(&self, frame:&Frame, sg:&SignedGrant, caller:Did, now:u64, facts:Option<&FlowFacts>) -> Option; +``` + +The gate helpers (private): + +```rust +fn ceiling_of(g:&Grant)->Option { + g.caveats.iter().fold(None,|a,c| match (a,c){ + (a,Caveat::FlowBelow(fc))=>Some(match a{Some(p)=>p.attenuate(fc),None=>fc.clone()}), (a,_)=>a }) +} +fn flow_gate(g:&Grant, floor:&TaintFloor, resource:&ContentId, facts:Option<&FlowFacts>) -> Option { + if let Some(f)=facts { if floor.refuses(resource,&f.label) { return Some(DenyReason::TaintedFlow); } } + match (ceiling_of(g), facts) { + (None,_) => None, // today's behaviour + (Some(_),None) => Some(DenyReason::TaintedFlow), // fail CLOSED + (Some(c),Some(f)) if f.label.leq(&c.ceiling) => None, + (Some(_),Some(_)) => Some(DenyReason::TaintedFlow), + } +} +``` + +### 1d. `crates/jcp-receipt/src/lib.rs` + +```rust +pub const PROFILE_FLOW: &str = "jcp/3"; +#[derive(Clone,Debug,PartialEq,Eq,Default)] +pub struct FlowSeal { pub facts: Option, pub ceiling: Option } // ceiling digest, if any +fn profile_for(seal:&FlowSeal)->&'static str { // jcp/2 unless the label is non-⊥ + match &seal.facts { Some(f) if !f.is_bottom() => PROFILE_FLOW, _ => PROFILE } } + +pub fn to_jcr1_wire_flow(frame:&Frame, grant:&Grant, decision:&Decision, steward:&str, seal:&FlowSeal)->Value; +pub fn seal_flow(frame:&Frame, grant:&Grant, decision:&Decision, sk:&SigningKey, steward:&str, seal:&FlowSeal)->Vec; +// verify() gains ONE arm covering {PROFILE, PROFILE_FLOW}; PROFILE_V1 unchanged. +// ReceiptLog::append_flow(frame, grant, decision, seal) — the gateway's new seal call. +``` + +### 1e. `crates/jcp-policy/src/lib.rs` — optional second gate + +```rust +#[non_exhaustive] pub enum Match { /* …4 existing… */ + FlowAbove(Label), OriginIn(BTreeSet), IntegrityAtMost(Integrity), + NotReadableBy(Did), // v2 (readers), present now + Caller(Vec), // closes the noted gap: ctx.caller is sealed but unmatchable +} +#[non_exhaustive] pub struct PolicyContext<'a> { /* …4 existing… */ pub flow: Option<&'a FlowFacts> } +// flow==None ⇒ every flow variant matches false ⇒ byte-identical policy/1 receipts. +``` + +--- + +## 2. The propagation rule as unit-testable algebraic law + +**The rule, once.** For a payload `p` with witness inputs `W(p)`: + +``` +label(p) = ⨆{ label(i) : i ∈ W(p) ∩ dom(store) } ⊔ (⊤ if W(p) ⊄ dom(store) else ⊥) +``` + +folded **eagerly at admission**, stored under `ContentId::of_bytes(p)`, and accompanied by a self-contained `Witness`. `W(p) = declared ∪ window-found ∪ sensitive-found ∪ (high_water if Mode::HighWater)`. + +The deliverable is a `proptest` suite over arbitrary `Label`s and a conformance vector set (so any FL-2 impl is checkable against the *laws*, not just fixtures): + +| Law | Statement | Note | +|---|---|---| +| L1 idempotent | `a ⊔ a = a` | | +| L2 commutative | `a ⊔ b = b ⊔ a` | replay need not reproduce fold order | +| L3 associative | `(a⊔b)⊔c = a⊔(b⊔c)` | | +| L4 identity | `a ⊔ ⊥ = a` | ⊥ = degeneracy hook | +| L5 absorbing top | `a ⊔ ⊤ = ⊤` | unknown-id fail-closed | +| L6 order/join | `a ⊑ b ⟺ a⊔b = b` | | +| L7 partial order | reflexive ∧ transitive ∧ **antisymmetric** | antisymmetry needs `Origins::norm`; hence private fields + `try_from` on the wire | +| L8 monotone join | `a⊑a' ∧ b⊑b' ⇒ a⊔b ⊑ a'⊔b'` | | +| L9 no-laundering | `∀ u ∈ ancestors(v). label(u) ⊑ label(v)` | taint never decreases | +| L10 confluence | store labels invariant under any valid topological admission order | eager ≡ lazy fold | +| L11 downward-closed allow | `allow(l) ∧ l'⊑l ⇒ allow(l')` | | +| L12 delegation | `⨅` over a longer chain `⊑` `⨅` over any prefix | amplification unrepresentable (meet) | +| L13 saturation sound | `Set(s) ⊑ Any` | saturation only denies more | +| L14 determinism | `digest(l)` pure; no `HashMap` on any path | | +| L15 refinement | `allow_FL2 ⊆ allow_pre` | never allows what JCP denies today | +| L16 retraction | `Label::from(t).trust() == t`; `l⊑m ⇒ trust(l) ≤ trust(m)` (Data) -> Option { + // 1 frame.trust==Data → UntrustedControl (UNMOVED, still first) + // 1b revocations → Revoked + // 1c root → UntrustedRoot + // 2 grant binding … 3 subject … 4 verify … 5 expiry … 6 capability (all unmoved) + // 6c FLOW — structural property of the arguments, decided before caveats & budget: + if let Some(r) = flow_gate(&sg.grant, &self.taint_floor, &frame.resource, facts) { return Some(r); } + None +} +``` + +Why exactly here — each pinned to something in the code: + +1. **After check 1, never before.** `frame.trust==Data ⇒ UntrustedControl` stays byte-first, so conformance pattern 3 (`jcp-conformance/src/lib.rs:953–1008`) and `gen_vectors.rs`'s `deny_f` still name `untrusted-control`. The one-bit **boundary** tag and the **propagated** label are different questions with different reasons. +2. **After check 4 (`verify`).** The ceiling lives inside the grant; judging flow against an unauthenticated grant would seal a provenance verdict over attacker-chosen policy. `check` places 6c well past the signature. +3. **Before caveats (6b in the callers) and before budget (7), therefore before any debit.** A tainted call must cost **zero joules** — the same invariant `tool_poisoning_is_refused_structurally` asserts (`spent==0`). Because 6c is inside `check`, and every caller runs `check` before `caveats_unsatisfied` and before the budget test, `TaintedFlow` returns before `spent_uj.insert` on every path. +4. **`&self`-compatible, one insertion.** `check` is `&self` and shared by all three non-chained entry points; `facts` is *pre-computed by the gateway* and arrives as `Option<&FlowFacts>`, so no fold happens inside the runtime and no public signature moves. The fail-closed backstop `flow_gate(Some(_),None)=TaintedFlow` is the same code — a caller reaching the *old* `authorize` with a *new* ceiling grant is denied, never silently unenforced. + +Reason precedence note (honest): a call that violates *both* a plain caveat and the flow ceiling now reports `tainted-flow` rather than `caveat-unsatisfied`. This changes **no** existing vector (no published grant carries `FlowBelow`), and origin-of-data is the more security-relevant fact, so this is a deliberate, documented choice. + +`decide_chained` needs its own mirrored insertion because it re-implements the leaf checks inline and never calls `check`; there the ceiling is `chain_ceiling(chain) = ⨅` over every link, so a chain whose links disagree is *stricter* than the leaf — fail-closed by the meet, not by an extra branch. (Note: `decide_chained` still does not evaluate caveats — a pre-existing gap; the `TaintFloor` half *does* apply there, so a delegated call cannot escape the enforcement-point-wide refusal, but a self-attenuating `FlowBelow` on a chained grant is not honoured until that gap is fixed separately. Stated, not smuggled.) + +**Why not `charge`.** `charge` takes a bare `&Grant`, no frame/caller/clock, and debits *before* it decides. The flow label is fully known at authorize time (a property of arguments, not execution), so it binds at authorize. Only the tainted **sub-budget** touches `charge_flow` (a real accumulator debit, §4C). + +--- + +## 4. Binding the verdict into the receipt + joule budget, and third-party replay + +### 4A. Where it seals — `jcp/3`, on the real gateway path + +The gateway seals through `ReceiptLog::append` (jcp/2) today. FL-2 adds `ReceiptLog::append_flow(frame, grant, decision, seal)`. `profile_for(seal)` mints **`jcp/2` byte-for-byte when the label is ⊥**, and **`jcp/3` only when tainted**. `verify` gets one arm for `{PROFILE, PROFILE_FLOW}` recomputing the flow core; `to_jcr1_wire_v2` is retained by subtraction (the exact `jcp/1→jcp/2` precedent at `jcp-receipt/src/lib.rs:41`). + +The `jcp/3` flow block (emitted iff `!label.is_bottom()`; all text/uint/array — no bool/null/float, so `cbor_value` accepts it; key order irrelevant, `signed_core_bytes` sorts length-first): + +```json +"jcp_receipt": "jcp/3", +"flow": { + "label": "", + "integrity": "untrusted|derived|attested|principal", + "origins": ["", …] | "any", + "readers": "public" | ["did:key:z…", …], + "witness": "", // commitment; the artifact is disclosed via WitnessStore + "ceiling": "", + "verdict": "below" | "above", + "flow_uj": , // monitor's own metered cost — SEPARATE field + "flow_energy_provenance": "HwShunt|ModelBased|Estimator" +} +``` + +This is the answer to F5/F6: the verdict binds where the gateway actually seals (jcp/3 via `ReceiptLog`), not in a `policy/1` that has no emitter, and not as a forbidden field under an unchanged `jcp/2` tag. `cost_uj` keeps its exact meaning (tool energy); `flow_uj` is a sibling with its own provenance (F3 fix). + +`policy/1` receives the same fields **only for deployments that run a `PolicyEngine`** (additive, `verify` recovers fields, old bytes unchanged) — a second, optional gate, no longer the primary binding. + +### 4B. Consent binds to provenance (FlowDAG/PROV-1 graft) + +`context_digest` currently mixes `action ‖ 0x00 ‖ resource ‖ caller ‖ H(content)`. FL-2 appends `label.digest() ‖ witness_id` **only when the label is non-⊥**. Every approval ever minted was over a ⊥-flow call, so **every existing signature verifies bit-for-bit**; going forward, a human's consent is bound to *where the data came from*, so an approval cannot be replayed against the same call carrying differently-sourced arguments. `Endorsement` (signed declassification) is the one-way-ratchet escape hatch, bound to one exact `(subject, from_label, to_label)`. + +### 4C. The joule budget — three couplings, all on paths the gateway runs + +`Gateway::relay` becomes: + +```rust +// Phase 0 — provenance, metered by the same PowerMeter the upstream call uses. +let (facts, tm) = self.meter.measure(|| { + let declared = parse_declared(¶ms); // optional params._jcp_witness + let (_id, label, witness) = self.flow.label_payload(&args_bytes, &declared, self.mode, self.log.head()); + self.witness_store.put(witness.clone()); // disclosure store + FlowFacts { label, witness_id: witness.id(), eval_uj: 0, eval_prov: self.meter.provenance() } +}); +let facts = FlowFacts { eval_uj: tm.total_uj(), ..facts }; +let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes); + +// Phase 1 — authorize_flow: flow gate + tainted sub-ceiling pre-check, no debit. +let auth = self.runtime.authorize_flow(&frame, &sg, self.caller, self.now, &facts); +if !auth.is_allow() { + let seal = FlowSeal { facts: Some(facts), ceiling: ceiling_digest(&grant) }; + self.log.append_flow(&frame, &grant, &auth, &seal); // tainted denial sealed, ZERO joules debited + return Ok(Relayed::Denied { /* … */ }); +} + +// Phase 2 — metered upstream call (unchanged). +let (response, m) = self.meter.measure(|| up.call(line)); +let measured_uj = m.total_uj(); + +// Phase 3 — charge_flow: debit tool joules AND (if tainted) the tainted sub-accumulator. +let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts, facts.eval_uj); +let seal = FlowSeal { facts: Some(facts), ceiling: ceiling_digest(&grant) }; +self.log.append_flow(&frame, &grant, &decision, &seal); + +// Phase 4 — the result becomes a labelled source (mcp_result_frame goes live, Trust::Data unchanged). +self.flow.admit_output(response.as_bytes(), mcp_resource(&self.server, tool), None, + &facts.label, readers_for(tool), &sensitive_terms_for(tool, &response), self.log.head()); +``` + +1. **Denied flows never debit.** 6c precedes budget/debit; `spent` unchanged on `TaintedFlow` — the exact `tool_poisoning_is_refused_structurally` assertion, extended to taint. +2. **Tainted work gets a smaller wallet — actually wired.** `authorize_flow` pre-checks `spent_tainted(gid) >= effective_tainted_budget` (mirroring the main budget's `remaining==0` degradation at authorize), denying `TaintBudgetExceeded` *before* the call; `charge_flow` debits `spent_tainted_uj` on the allow path. So "20 J clean / 200 mJ once you've touched the web" is a real, enforced control on the gateway path (F8 fix). `replay_spend` gains a `spent_tainted` reconstruction from the sealed `flow` block. +3. **The enforcer prices its own enforcement, honestly.** `flow_uj` (metered `PowerMeter` reading around `label_payload`, with its true `Provenance`) is **sealed as a separate field, not folded into `cost_uj`** (F3 + energy-tag fix). Default: reported, not debited. A deployment may opt to debit it against the tainted sub-budget via a flag; that choice is explicit, never a silent semantic change. + +### 4D. Fix the pre-existing `replay_spend` under-count (PROV-1 catch) + +`replay_spend`/`aggregate` filter `verdict=="allow"`, but `charge` debits then can return `Deny(CaveatUnsatisfied)` — so today the ledger under-counts by every cost-breached call. Fix both call sites to a debit predicate: `verdict=="allow" || deny_reason ∈ {"caveat-unsatisfied"}`. `tainted-flow` denies at authorize with zero debit, so it correctly stays in the non-debit branch. Ship this as its own commit (it is a real bug independent of FL-2). + +### 4E. Third-party replay — receipt **+ disclosed witness** (honest scope, F7 fix) + +`store_root` is gone. Replay is not "receipt-alone" (that was FL-1's false claim — `store_root` needed raw bytes). It is **receipt + the witnesses disclosed through a `WitnessStore`**, exactly the `jcp-cite::Citation` disclosure boundary: the receipt commits only `witness_id`; an entitled auditor fetches the `Witness` and rechecks it. Eight ordered checks (the ordering *is* the spec): + +1. **Chain** — `verify_log`: `seq[i]==i`, `prev[i]==H(env[i-1])`, genesis `0…0`. Profile-agnostic; a mixed `jcp/2`+`jcp/3` stream verifies end-to-end unchanged. +2. **Witness binding** — `Witness::id() == receipt.flow.witness`. +3. **Label binding** — `witness.label.digest() == receipt.flow.label`. +4. **Edge justification** — each `Witnessed` edge's `grams ⊆ windows(parent) ∩ windows(subject-cut-node)` (rechecked from the disclosed cut). `Declared` edges require a named artifact (a `jcp-cite` citation or `Endorsement`) to verify. `Ambient` edges are free-but-flagged. +5. **Fold correctness** — re-fold `witness.cut` over `witness.edges`; assert `== witness.label` (safe in any order by L2/L3). This is **L20**. +6. **Cut completeness (anti-excision)** — every `cut[j].anchor` must appear at a *strictly earlier* index in the already-verified chain. Dropping the tainting read breaks the chain at that point; step 1 catches the excision and step 6 catches reordering. *You cannot remove the untrusted read without breaking the chain.* +7. **Verdict determinism** — re-evaluate `witness.label.leq(&ceiling)` against the sealed `flow.ceiling`; assert `== receipt.flow.verdict`. Pure, no clock, no LLM. +8. **Energy reconciliation** — `Σ flow_uj` and `Σ` tainted debits reconcile against the corrected `replay_spend`. + +`ReplayReport` separately counts `unjustified_edges` and `ambient_only` verdicts, so an auditor can grade how much of a verdict rested on hard bytes versus the conservative floor — the on-wire evidence/floor distinction grafted from PROV-1/FlowDAG. + +**Honest limit stated inline:** step 4 rechecks *consistency* (grams ⊆ the disclosed cut's own window sets), not *truth* against the original bytes, unless the `WitnessStore` also discloses boundary bytes to the entitled auditor (a second disclosure tier). Against a fully-adversarial prover that under-populates a cut node's evidence, truth-verification requires disclosing the boundary bytes; that tier is available but off by default (it widens the disclosure surface). This is the PROV-1-security-judge concern, addressed by making the tier explicit rather than pretending consistency = truth. + +--- + +## 5. Backward-compatibility for the one-bit `Trust::Control/Data` path + +The one-bit path is a **fixed point**, enforced by construction, not by care. + +1. **`Frame` untouched.** It has no `skip_serializing_if` and no `impl` — any field is a hard wire break. FL-2 keys labels by `ContentId` (already how a frame names its payload) in a `Gateway.flow: FlowStore` side-store. Frame content ids, `Caveat::PayloadIs` bindings, every cross-language verifier: bit-unaffected. +2. **`Trust` embeds and retracts (L16).** `From for Integrity`: `Control↦Principal`, `Data↦Untrusted`; `Label::trust()`: `Untrusted↦Data`, else `Control`. `Label::from(t).trust()==t`, and `l⊑m ⇒ trust(l)≤trust(m)` under `Data` (`None` on legacy, exactly as `payload_hash: None` documents for `jcp/1`). `published_v1_receipts_still_verify` and every `jcp/2` fixture unchanged. +7. **Refinement (L15).** 6c is inserted after all existing checks and can only return `Deny`. `allow_FL2 ⊆ allow_pre`: FL-2 never allows a call JCP denies today. Worst case is over-denial — the correct direction for a reference monitor. +8. **`jcp-policy` is the one honest semver-major** (`#[non_exhaustive]` on `Match`/`PolicyContext` + a `flow` field). `ctx.flow==None` ⇒ every flow variant matches false ⇒ no new denies, byte-identical `policy/1`. Do it in one release; `#[non_exhaustive]` now makes it the last such break. `Match::matches` is private, so widening it is free. + +--- + +## 6. Concrete test list + +**Lattice laws (proptest + conformance vectors):** `l1_idempotent` … `l20_witness_refold`, one test each per §2, shipped as vectors in `jcp-conformance`. + +**Carry detector (F1 regression):** +- `carry_at_least_w_bytes_is_found` — a ≥16-byte verbatim run yields a `Witnessed` edge (L17). Directly replaces FL-1's broken buzhash guarantee. +- `blake3_window_index_is_platform_independent` — same bytes ⇒ same fingerprint set on two arches. +- `registered_sensitive_term_of_any_length_is_found` — an 11-char SSN registered by an adapter yields a `Witnessed` edge (L19). +- `sub_w_unregistered_carry_is_NOT_found` — a 12-byte unregistered token produces `⊥` from the scan alone; **documents the residual** so it can never be mistaken for a guarantee. + +**Compatibility (F2/F3/F5/F6):** +- `bottom_flow_call_is_byte_identical_to_pre_fl2` — clean frame ⇒ `jcp/2` bytes, COSE envelope, `prev`, and `context_digest` identical to today. (F3: because `flow_uj` is jcp/3-only, `cost_uj` is unmoved.) +- `ceiling_free_grant_is_a_noop_for_any_facts` — the compat law, over `None`/`Some(bottom)`/`Some(tainted)`. +- `ceiling_grant_on_old_authorize_fails_closed` — the fail-closed law. +- `pre_fl2_binary_rejects_flowbelow_grant` — serde parse failure ⇒ deny. +- `published_v1_and_v2_receipts_still_verify` — mixed `jcp/1`+`jcp/2`+`jcp/3` chain verifies end-to-end. +- `absent_tainted_budget_does_not_change_grant_id`. + +**Budget couplings (F8):** +- `tainted_flow_deny_debits_zero` — the `tool_poisoning` assertion, for `TaintedFlow`. +- `tainted_sub_ceiling_pre_checks_at_authorize` — `spent_tainted >= cap` ⇒ `TaintBudgetExceeded` before the upstream call. +- `charge_flow_debits_spent_tainted` — the accumulator actually accrues on the allow path (kills TAS's "accumulator stays 0" defect). +- `flow_uj_sealed_separately_with_own_provenance` — `cost_uj` unchanged; `flow_uj`/`flow_energy_provenance` present. + +**Replay (F7):** +- `replay_refolds_witness_to_sealed_label` (L20). +- `excised_untrusted_read_breaks_the_chain` — remove the admission ⇒ `ReplayError::Chain`/`Excised` (cut-completeness). +- `unjustified_witnessed_edge_is_rejected` — grams not ⊆ endpoints ⇒ `UnjustifiedEdge`. +- `ambient_only_verdict_is_flagged_in_report` — evidence-vs-floor grading. +- `mixed_profile_stream_replays` — jcp/2 and jcp/3 interleaved. + +**The multi-tool-call attack the security judges constructed (named `read_summarize_send_exfil`):** +The judges' canonical attack across the four designs is the *derived-from-untrusted-read exfiltration*: an agent reads an attacker-controlled source, then egresses attacker-chosen data two calls later, and `ContentContains` structurally cannot express the deny. FL-2's positive test: +- `read_email_then_send_email_denies_on_verbatim_carry`: + 1. `checkpoint`/principal turn at ⊥. + 2. `read_email` (attacker sender) → `admit_output` labels the result `(Untrusted, {Origin{channel=mcp_resource("mail","read_email"), principal=attacker}}, Public)`, indexes its windows, registers the exfil URL as a sensitive term. + 3. `summarize` (clean tool) → its args carry ≥16 verbatim bytes of the email → `label_payload` finds a `Witnessed` edge to the read; the summary is admitted tainted. + 4. `send_email(to=attacker, body=)` under a grant with `Caveat::FlowBelow{ ceiling: (integrity=Principal, origins=∅, readers=Public) }` → `send_email`'s args reach `Origin{mail.read_email, attacker}`, `label ⋢ ceiling`, 6c returns `TaintedFlow`, **zero joules debited**, `jcp/3` seals the `flow` block naming the origin, and the disclosed `Witness` lets a third party re-derive the deny. Assert: verdict `above`, `spent==0`, `verify` round-trips, `replay` re-folds. +- `send_money_recipient_must_reach_principal` — the banking shape: a `FlowBelow` ceiling requiring the recipient origin to be `Principal`; a recipient derived from a tool output is denied. +- `laundering_defeats_witness_but_high_water_holds` — the *negative* companion: base64/paraphrase the body so no window matches; assert the scan alone returns `⊥` (documents the residual), then assert `Mode::HighWater` on the egress sink denies via the session floor. This test exists precisely to prevent overclaiming §7. +- `short_account_number_needs_registration` — a 12-digit account number copied verbatim is caught **only** when the banking adapter registered it as a sensitive term; without registration it is invisible. Documents the F1 residual as a test, not a footnote. + +--- + +## 7. What this still does not stop (honest residuals) + +1. **Semantic laundering.** Base64, translation, summary-in-other-words, "spell the address with spaces" — all defeat the window index by construction. Against those, soundness rests on (a) harness declaration, (b) registered sensitive terms, or (c) `Mode::HighWater`'s coarse session floor. A monitor at the tool-call boundary cannot see the model's derivation; CaMeL sidesteps this only by owning the interpreter. This is the NeuroTaint thesis and FL-2 concedes it. **Accepted residual.** +2. **Sub-`SCAN_W` unregistered secrets.** An account number (8–16 chars) or bare email that is *not* registered by a boundary adapter and is below the 16-byte window floor is invisible to the monitor's own scan. Registration (L19) and declaration close it when a tool knows a field is sensitive; the fully-general case needs a sensitivity oracle JCP does not have. **Accepted residual, tested (`sub_w_unregistered_carry_is_NOT_found`).** +3. **Confidentiality (Readers) enforcement is v2.** `Readers` has correct algebra and is sealed, but JCP has no ACL model, so `readers_for(tool)` defaults to `Public` and the readers component is inert everywhere until per-tool audience oracles exist. v1 is **integrity × origins** only. The PROV-1-security-judge's readers-exfil (a restricted secret egressing with a clean verdict) is *not* solved in v1; the hook (`Match::NotReadableBy`, `permits(audience)`) is wired so v2 is additive. **Scoped, not claimed to work.** +4. **Implicit flows beyond `HighWater`.** No AST/interpreter, so branch-condition and loop-iterable dependencies are invisible; `Mode::HighWater` is a blunt over-approximation (everything after the first untrusted read joins the session label) that collapses utility within a few calls. Sound, not precise. **Accepted; opt-in per sink.** +5. **Replay is receipt + disclosed witness, and step-4 is consistency not truth against a hostile prover** unless boundary bytes are disclosed (second tier, off by default). Stated inline in §4E. **Accepted, explicit tier.** +6. **`FlowFacts` is in-process and unsigned** — it travels as a Rust argument from gateway to runtime; nothing binds it into the `Frame`. Fine in-process; the moment JCP goes cross-process over `jcp-wire`, the facts (or the `witness_id`) must ride the frame or a signed sidecar — the `Frame` wire break FL-2 deliberately avoids for now. **Deferred, not eliminated.** +7. **Boundary labellers are TCB.** Which channel/principal produced a byte, and which fields are sensitive, is ordinary adapter code with ordinary bugs; one adapter that mislabels an attacker sender as `Attested` is a silent bypass. The lattice laws (L1–L20) prove taint never decreases, authorization is downward-closed, delegation is monotone, and the verdict is reproducible — **none of them says "prompt injection cannot succeed."** Stating soundness precisely is not the same as being secure, and FL-2 says so rather than letting the algebra imply otherwise. +8. **Inherited non-goals** (CaMeL §3.1 / AuthGraph): pure text-to-text manipulation with no tool call, same-source poisoning (the attacker poisons the *authorized* channel's backend so the value legitimately carries the expected label), "read the email and do what it says" as a legitimate user instruction, and the ROP-style approximation of a denied call out of many allowed ones. + +--- + +### Files to create / touch (all absolute) + +- **New:** `/Users/dcharlot/data-share/vibe-coding/open-standards/jcp/jcp-rs/crates/jcp-flow/{Cargo.toml,src/{lib.rs,store.rs,witness.rs,replay.rs}}` (store/witness/scan/replay). +- **Edit `jcp-schema`** `/…/crates/jcp-schema/src/lib.rs`: define `Label/Origins/Integrity/Readers/FlowCeiling/Endorsement` here (breaks the dep cycle), add `Caveat::FlowBelow`, `DenyReason::{TaintedFlow,TaintBudgetExceeded}`, `Grant.tainted_budget_uj`. +- **Edit `jcp-runtime`** `/…/crates/jcp-runtime/src/lib.rs`: `check` gains `facts`; add `taint_floor`, `spent_tainted_uj`, `flow_gate`/`ceiling_of`/`chain_ceiling`, `authorize_flow`/`charge_flow`/`decide_flow`/`decide_chained_flow`, `forbid_flow*`; mirror 6c in `decide_chained`; fix `replay_spend`/`aggregate` debit predicate. +- **Edit `jcp-receipt`** `/…/crates/jcp-receipt/src/lib.rs`: `PROFILE_FLOW="jcp/3"`, `FlowSeal`, `to_jcr1_wire_flow`/`seal_flow`/`ReceiptLog::append_flow`, `to_jcr1_wire_v2` retention, one `verify` arm, `ReceiptCore.flow: Option`. +- **Edit `jcp-gateway`** `/…/crates/jcp-gateway/src/lib.rs`: add `flow: FlowStore`, `witness_store`, `mode`; the Phase 0–4 relay above; add `jcp-flow` to its `Cargo.toml`. +- **Edit `jcp-policy`** `/…/crates/jcp-policy/src/lib.rs`: `#[non_exhaustive]` + new `Match` variants + `PolicyContext.flow` + `context_digest` provenance append. +- **Edit `jcp-bridge`** `/…/crates/jcp-bridge/src/lib.rs`: no signature change — `mcp_result_frame` (currently dead, `:54`) goes live from the gateway, still `Trust::Data`. diff --git a/jcp/jcp-rs/crates/jcp-schema/src/flow.rs b/jcp/jcp-rs/crates/jcp-schema/src/flow.rs new file mode 100644 index 000000000..d4dc2d5fa --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-schema/src/flow.rs @@ -0,0 +1,674 @@ +//! The flow lattice (SPEC §4.10) — propagated provenance labels. +//! +//! JCP's [`Trust`](crate::Trust) tag is a one-*bit* boundary marker: a frame is +//! principal `Control` or untrusted `Data`, and a `Data` frame may never exercise +//! a capability. That stops output from being *executed*, but it cannot express +//! "this argument was **derived** from an earlier untrusted read": the bit does +//! not propagate through the values a run computes. +//! +//! This module is the propagated form. A [`Label`] is a point in a lattice with +//! three independent axes: +//! +//! - **integrity** — how believable the value is, a total order from `Untrusted` +//! up to `Principal`; +//! - **origins** — the set of channels a value's bytes flowed from; +//! - **readers** — who may see it (present and sealed, but *enforcement* is a +//! later increment — see the crate SPEC; today it only rides along). +//! +//! The one join `⊔` folds a derived value's label from its inputs. The whole +//! point is the ordering `⊑`: a value may exercise a capability only when its +//! label sits at or below the grant's [`FlowCeiling`]. Everything downstream (the +//! runtime gate, the sealed verdict, replay) is built on the algebra proven here, +//! so this module carries the lattice **laws** as tests rather than leaving them +//! implicit. +//! +//! Direction, fixed once: `⊥` (bottom) is the *cleanest* label — `Principal` +//! integrity, no origins, `Public` readers — and `join` moves *up* toward more +//! taint. So `a ⊑ b` reads "a is at least as clean as b", and taint on a derived +//! value never decreases below any of its inputs (the no-laundering law). +//! +//! All collections are `BTree*`, never `Hash*`: a label is content-addressed and +//! sealed into a receipt, so iteration order must be deterministic across runs and +//! platforms. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ContentId, Did, SchemaError, Trust}; + +/// The integrity axis: how believable a value is. A **total** order — higher is +/// more trusted. In the taint join two labels take the *weaker* (lower) integrity, +/// because a value derived from an untrusted input is no more believable than that +/// input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Integrity { + Untrusted, + Derived, + Attested, + Principal, +} + +impl Integrity { + /// The weaker (less believable) of the two — the taint join on this axis. + pub fn weaker(self, other: Self) -> Self { + if self <= other { + self + } else { + other + } + } + + /// The stronger (more believable) of the two — the meet, used when attenuating + /// a ceiling (a child ceiling may only *raise* the integrity it demands). + pub fn stronger(self, other: Self) -> Self { + if self >= other { + self + } else { + other + } + } +} + +/// Where a value's bytes came from: a **content-addressed** channel (the +/// `mcp_resource(server, tool)` id), optionally narrowed to the principal that +/// spoke on it. Content-addressed rather than string-named so an origin cannot be +/// forged by naming, and so a resource-keyed taint floor can be authored against a +/// known channel id at startup. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Origin { + pub channel: ContentId, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub principal: Option, +} + +impl Origin { + pub fn channel(channel: ContentId) -> Self { + Origin { channel, principal: None } + } + pub fn from(channel: ContentId, principal: Did) -> Self { + Origin { channel, principal: Some(principal) } + } +} + +/// Above this many distinct origins a label saturates to [`Origins::Any`]. A +/// bound is needed so a long-running session's label cannot grow without limit; +/// saturation is *sound* because `Any` is the top of the axis and only ever denies +/// more (a ceiling that excludes some origin also excludes `Any`). +pub const MAX_ORIGINS: usize = 64; + +/// The origins axis. `Any` is the top: it stands for "could be from anywhere", a +/// conservative over-approximation a ceiling can never satisfy unless it, too, is +/// `Any`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", try_from = "OriginsWire")] +pub enum Origins { + Set(BTreeSet), + Any, +} + +/// Wire form of [`Origins`] — deserialised through [`Origins::normalize`] so an +/// over-large `Set` on the wire becomes `Any`, keeping `⊑` antisymmetric (two +/// distinct encodings of the top would break it). +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +enum OriginsWire { + Set(BTreeSet), + Any, +} + +impl TryFrom for Origins { + type Error = core::convert::Infallible; + fn try_from(wire: OriginsWire) -> Result { + Ok(match wire { + OriginsWire::Set(set) => Origins::Set(set).normalized(), + OriginsWire::Any => Origins::Any, + }) + } +} + +impl Origins { + pub fn none() -> Self { + Origins::Set(BTreeSet::new()) + } + + pub fn one(origin: Origin) -> Self { + let mut set = BTreeSet::new(); + set.insert(origin); + Origins::Set(set) + } + + pub fn is_empty(&self) -> bool { + matches!(self, Origins::Set(set) if set.is_empty()) + } + + /// Collapse an over-large set to `Any`. Idempotent; `Any` stays `Any`. + pub fn normalized(self) -> Self { + match self { + Origins::Set(set) if set.len() > MAX_ORIGINS => Origins::Any, + other => other, + } + } + + /// Join — union, saturating to `Any`. The taint direction: a value drawn from + /// two sources carries both. + pub fn join(&self, other: &Self) -> Self { + match (self, other) { + (Origins::Any, _) | (_, Origins::Any) => Origins::Any, + (Origins::Set(a), Origins::Set(b)) => { + Origins::Set(a.union(b).cloned().collect()).normalized() + } + } + } + + /// Meet — intersection; `Any` is the identity (used when attenuating a + /// ceiling, where `Any` means "no origin restriction"). + pub fn meet(&self, other: &Self) -> Self { + match (self, other) { + (Origins::Any, x) | (x, Origins::Any) => x.clone(), + (Origins::Set(a), Origins::Set(b)) => { + Origins::Set(a.intersection(b).cloned().collect()) + } + } + } + + /// `self ⊑ other`: subset, with everything below `Any`. + pub fn leq(&self, other: &Self) -> bool { + match (self, other) { + (_, Origins::Any) => true, + (Origins::Any, Origins::Set(_)) => false, + (Origins::Set(a), Origins::Set(b)) => a.is_subset(b), + } + } + + /// Does this label's origin set touch any origin in `mask`? The taint floor's + /// test: a resource can forbid values that carry a given origin. `Any` touches + /// every non-empty mask (the conservative reading of "could be from anywhere"). + pub fn intersects(&self, mask: &Self) -> bool { + match (self, mask) { + (Origins::Any, m) => !m.is_empty() || matches!(m, Origins::Any), + (s, Origins::Any) => !s.is_empty() || matches!(s, Origins::Any), + (Origins::Set(a), Origins::Set(b)) => a.intersection(b).next().is_some(), + } + } +} + +/// The confidentiality axis: who may see a value. `Public` is the bottom (any +/// reader). Present and sealed so the wire is stable, but audience *enforcement* +/// is a later increment; today it only propagates. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Readers { + Public, + Only(BTreeSet), +} + +impl Readers { + /// Join — audience *intersection* (a value combining two secrets is readable + /// only by those cleared for both), so fewer readers is more taint. `Public` + /// is the identity. + pub fn join(&self, other: &Self) -> Self { + match (self, other) { + (Readers::Public, x) | (x, Readers::Public) => x.clone(), + (Readers::Only(a), Readers::Only(b)) => { + Readers::Only(a.intersection(b).copied().collect()) + } + } + } + + /// Meet — audience *union*; `Public` absorbs (used to attenuate a ceiling's + /// reader bound). + pub fn meet(&self, other: &Self) -> Self { + match (self, other) { + (Readers::Public, _) | (_, Readers::Public) => Readers::Public, + (Readers::Only(a), Readers::Only(b)) => { + Readers::Only(a.union(b).copied().collect()) + } + } + } + + /// `self ⊑ other`: `other`'s audience is a subset of `self`'s (more readable is + /// cleaner). `Public` is below every `Only`. + pub fn leq(&self, other: &Self) -> bool { + match (self, other) { + (Readers::Public, _) => true, + (Readers::Only(_), Readers::Public) => false, + (Readers::Only(a), Readers::Only(b)) => b.is_subset(a), + } + } +} + +/// A point in the flow lattice: the propagated provenance of a value. `⊥` (see +/// [`Label::bottom`]) is the cleanest; [`Label::join`] moves toward more taint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Label { + pub integrity: Integrity, + pub origins: Origins, + pub readers: Readers, +} + +impl Label { + /// `⊥` — a principal-authored value from no channel, readable by all. The label + /// of every value in a run that has touched no untrusted source, so a clean run + /// is byte-identical to the pre-lattice world. + pub fn bottom() -> Self { + Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public } + } + + /// `⊤` — maximally tainted: untrusted, from anywhere, readable by nobody. The + /// saturation point; rarely constructed directly but the identity for meet. + pub fn top() -> Self { + Label { integrity: Integrity::Untrusted, origins: Origins::Any, readers: Readers::Only(BTreeSet::new()) } + } + + pub fn is_bottom(&self) -> bool { + *self == Label::bottom() + } + + /// Fold two labels — the taint join, component-wise. This is the operation + /// that propagates provenance from a value's inputs onto the value. + pub fn join(&self, other: &Self) -> Self { + Label { + integrity: self.integrity.weaker(other.integrity), + origins: self.origins.join(&other.origins), + readers: self.readers.join(&other.readers), + } + } + + /// The meet — greatest lower bound. Used to attenuate a ceiling so a child can + /// only make it stricter. + pub fn meet(&self, other: &Self) -> Self { + Label { + integrity: self.integrity.stronger(other.integrity), + origins: self.origins.meet(&other.origins), + readers: self.readers.meet(&other.readers), + } + } + + /// `self ⊑ other` — is this label at least as clean as `other`? A value may + /// exercise a capability exactly when its label `⊑` the ceiling. + pub fn leq(&self, other: &Self) -> bool { + self.integrity >= other.integrity + && self.origins.leq(&other.origins) + && self.readers.leq(&other.readers) + } + + /// The content address of this label — its identity in a witness and the value + /// sealed into a receipt. Pure and order-free because every field is `BTree`, + /// so the same label digests identically on any platform (the determinism law). + pub fn digest(&self) -> Result { + ContentId::of(self) + } + + /// Retract the propagated label to the one-bit [`Trust`] tag: a value is + /// `Data` exactly when its integrity is `Untrusted`, else `Control`. This is + /// the coarsening the pre-lattice world sees, and it is monotone — refining a + /// label never contradicts the bit. + pub fn trust(&self) -> Trust { + match self.integrity { + Integrity::Untrusted => Trust::Data, + _ => Trust::Control, + } + } +} + +impl From for Label { + /// Embed the one-bit tag as a label: `Control` is a principal-clean value, + /// `Data` an untrusted one — both from no specific channel. The inverse of + /// [`Label::trust`] on this range, so `Label::from(t).trust() == t`. + fn from(trust: Trust) -> Self { + match trust { + Trust::Control => Label::bottom(), + Trust::Data => Label { + integrity: Integrity::Untrusted, + origins: Origins::none(), + readers: Readers::Public, + }, + } + } +} + +/// A grant's flow ceiling: the greatest (most tainted) label whose values may +/// still exercise the capability, plus an optional smaller joule budget that +/// applies once a call is tainted at all. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowCeiling { + pub ceiling: Label, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tainted_budget_uj: Option, +} + +impl FlowCeiling { + pub fn new(ceiling: Label) -> Self { + FlowCeiling { ceiling, tainted_budget_uj: None } + } + + /// Attenuate for a delegation hop: the child ceiling is the **meet** of the two + /// (so it can only get stricter) and the tainted budget the **min**. Because + /// both moves are monotone-down, a child can never widen what a parent allowed + /// — capability amplification is unrepresentable, not merely rejected. + pub fn attenuate(&self, child: &FlowCeiling) -> FlowCeiling { + let tainted_budget_uj = match (self.tainted_budget_uj, child.tainted_budget_uj) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, b) => b, + }; + FlowCeiling { ceiling: self.ceiling.meet(&child.ceiling), tainted_budget_uj } + } + + /// Does a value with label `l` pass this ceiling? + pub fn admits(&self, l: &Label) -> bool { + l.leq(&self.ceiling) + } +} + +/// Fold a slice of labels — the join over all of them, starting from `⊥`. The +/// label of a value derived from many inputs. Order-free (join is commutative and +/// associative), which is what lets replay re-fold a witness in any order. +pub fn fold(labels: &[Label]) -> Label { + labels.iter().fold(Label::bottom(), |acc, l| acc.join(l)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cid(byte: u8) -> ContentId { + ContentId([byte; 32]) + } + fn did(byte: u8) -> Did { + Did([byte; 32]) + } + + /// A representative spread of labels covering every axis extreme, so the + /// exhaustive law checks below actually exercise the corners (Any, empty + /// origins, Public, restricted readers, every integrity rank). + fn sample_labels() -> Vec