Land the FL-2 propagated-provenance stack (increments 1-5) plus RFC 9207 #46

Merged
dcharlot merged 10 commits from fl2-land into main 2026-07-27 18:08:29 -04:00
16 changed files with 3292 additions and 15 deletions

11
jcp/jcp-rs/Cargo.lock generated
View file

@ -598,12 +598,23 @@ dependencies = [
"nvml-wrapper",
]
[[package]]
name = "jcp-flow"
version = "0.1.0"
dependencies = [
"blake3",
"jcp-schema",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "jcp-gateway"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"jcp-bridge",
"jcp-flow",
"jcp-grant",
"jcp-receipt",
"jcp-runtime",

View file

@ -22,6 +22,7 @@ members = [
"crates/jcp-orchestrate",
"crates/jcp-intercept",
"crates/jcp-efficiency",
"crates/jcp-flow",
]
[workspace.package]

View file

@ -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!"] {

View file

@ -0,0 +1,17 @@
[package]
name = "jcp-flow"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Propagated provenance for JCP — the witness scan and session store that fold a derived value's flow Label from the tool outputs it was computed from, so authorization can be conditioned on where a value CAME FROM, not just its literal bytes. An exact blake3 window index detects verbatim carry across tool calls; each verdict emits a content-addressed, offline-checkable Witness."
[dependencies]
jcp-schema = { workspace = true }
blake3 = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,235 @@
//! Propagated provenance for JCP — the witness scan and session store.
//!
//! The [flow lattice](jcp_schema::flow) says what a label *is* and how two labels
//! fold. This crate computes labels from real bytes: as a run reads tool outputs
//! and derives new values, the [`FlowStore`] tracks where each value's bytes came
//! from and folds a derived value's label from its ancestors. The result is that
//! authorization can be conditioned on **where a value came from**, not merely on
//! its literal bytes — the gap JCP's one-bit `Trust` tag cannot express.
//!
//! Two things make this a reference monitor rather than a heuristic:
//!
//! - The carry detector is an **exact** `blake3` window index. Two values share a
//! fingerprint exactly when they share a verbatim [`SCAN_W`]-byte run — no
//! rolling-hash state to get wrong, and the same bytes fingerprint identically
//! on every platform.
//! - Every verdict emits a self-contained [`Witness`]: the ancestor cut, the taint
//! edges, and the folded label, content-addressed. A receipt commits only its
//! `id()`; an auditor re-folds it offline to re-derive the verdict
//! ([`verify_witness`]).
//!
//! **Scope.** This crate is the propagation and its offline witness check. The
//! runtime *gate* (does a label pass a grant's ceiling), the `jcp/3` receipt seal,
//! and the gateway relay that drives them are later layers; see the FL-2 plan.
//!
//! **Honest residuals.** The scan sees verbatim carry, not meaning: base64,
//! translation, or paraphrase defeat it, and soundness then rests on a registered
//! sensitive term or the `HighWater` floor. Sub-`SCAN_W` unregistered secrets are
//! invisible to the scan. `verify_witness` checks *consistency* (the cut folds to
//! the sealed label), not *truth* against original bytes — that needs an opt-in
//! disclosure tier. The lattice laws prove taint never decreases and a verdict is
//! reproducible; none of them says prompt injection cannot succeed.
pub mod store;
pub mod witness;
pub use store::{ambient_source, windows, FlowStore, Mode, ScanParams, SCAN_W};
pub use witness::{
verify_witness, CutNode, Edge, EdgeKind, Witness, WitnessError, WITNESS_SCHEMA,
};
#[cfg(test)]
mod tests {
use super::*;
use jcp_schema::flow::{FlowCeiling, Integrity, Label, Origins};
use jcp_schema::{ContentId, Did};
use std::collections::BTreeSet;
fn channel(byte: u8) -> ContentId {
ContentId::of_bytes(&[byte; 8])
}
fn anchor() -> ContentId {
ContentId([0u8; 32])
}
fn no_declared() -> BTreeSet<ContentId> {
BTreeSet::new()
}
#[test]
fn windows_are_platform_independent_and_exact() {
// Same bytes ⇒ same fingerprint set, regardless of who computes them.
let a = windows(b"the quick brown fox jumps", SCAN_W);
let b = windows(b"the quick brown fox jumps", SCAN_W);
assert_eq!(a, b);
assert!(!a.is_empty());
// Fewer than W bytes ⇒ no windows.
assert!(windows(b"short", SCAN_W).is_empty());
// A 25-byte string has 25-16+1 = 10 windows.
assert_eq!(windows(b"the quick brown fox jumps", SCAN_W).len(), 10);
}
#[test]
fn l17_carry_of_at_least_w_bytes_yields_a_witnessed_edge() {
let mut store = FlowStore::new(64);
let secret = b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij"; // > 16 bytes
let (email_id, email_label) = store.admit_output(
secret,
channel(1),
Some(Did([9u8; 32])),
&Label::bottom(),
jcp_schema::flow::Readers::Public,
&[],
anchor(),
);
assert_eq!(email_label.integrity, Integrity::Untrusted);
// A "summary" that carries a verbatim run of the email.
let summary = b"Summary: ATTACKER-CONTROLLED-PAYLOAD-abcdefghij was mentioned.";
let (_id, label, witness) = store.label_payload(summary, &no_declared(), Mode::Explicit, anchor());
let edge = witness.edges.iter().find(|e| e.parent == email_id).expect("edge to the email");
assert_eq!(edge.kind, EdgeKind::Witnessed, "L17: verbatim carry is witnessed");
assert!(!edge.grams.is_empty(), "the overlap is proven by grams");
// The taint propagated: the summary is now untrusted and carries the origin.
assert_eq!(label.integrity, Integrity::Untrusted);
assert!(!label.origins.is_empty(), "summary carries the email's origin");
assert!(verify_witness(&witness).is_ok());
}
#[test]
fn sub_w_unregistered_carry_is_not_found() {
// The documented residual, pinned as a test so it can never be mistaken for
// a guarantee: a short verbatim token below the window floor, not registered,
// produces no witnessed edge.
let mut store = FlowStore::new(64);
let (_id, _l) = store.admit_output(b"code=A1B2C3", channel(1), None, &Label::bottom(),
jcp_schema::flow::Readers::Public, &[], anchor());
let (_id, label, witness) =
store.label_payload(b"the value A1B2C3 appears here", &no_declared(), Mode::Explicit, anchor());
assert!(witness.edges.is_empty(), "a sub-W token yields no scan edge");
assert!(label.is_bottom(), "so nothing propagates");
}
#[test]
fn l19_a_registered_sensitive_term_of_any_length_is_found() {
let mut store = FlowStore::new(64);
// An 11-char account number, registered by the boundary adapter.
let (src_id, _l) = store.admit_output(b"balance page", channel(2), None, &Label::bottom(),
jcp_schema::flow::Readers::Public, &[b"12345678901"], anchor());
let (_id, label, witness) =
store.label_payload(b"transfer from 12345678901 now", &no_declared(), Mode::Explicit, anchor());
let edge = witness.edges.iter().find(|e| e.parent == src_id).expect("edge to the source");
assert_eq!(edge.kind, EdgeKind::Witnessed, "L19: a registered term taints even below W");
assert_eq!(edge.witnessed_bytes, 11);
assert!(!label.is_bottom());
assert!(verify_witness(&witness).is_ok());
}
#[test]
fn l18_a_shared_window_only_ever_adds_a_parent() {
// Over-approximation soundness: two admitted values sharing a window both
// become parents of anything carrying it — a match is never missed, and a
// (spurious) collision would only add taint, never remove it.
let mut store = FlowStore::new(64);
let run = b"SHARED-VERBATIM-RUN-0123456789";
let (a, _) = store.admit_output(run, channel(1), None, &Label::bottom(),
jcp_schema::flow::Readers::Public, &[], anchor());
let (b, _) = store.admit_output(run, channel(2), None, &Label::bottom(),
jcp_schema::flow::Readers::Public, &[], anchor());
// Distinct channels ⇒ distinct content? No — identical bytes ⇒ same id.
assert_eq!(a, b, "identical bytes content-address identically");
// Two *different* payloads sharing one window.
let mut store = FlowStore::new(64);
let (p, _) = store.admit_output(b"prefix SHARED-VERBATIM-RUN-0123456789 alpha",
channel(1), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor());
let (q, _) = store.admit_output(b"beta SHARED-VERBATIM-RUN-0123456789 suffix",
channel(2), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor());
assert_ne!(p, q);
let (_id, _label, witness) = store.label_payload(
b"carries SHARED-VERBATIM-RUN-0123456789 from both", &no_declared(), Mode::Explicit, anchor());
let parents: BTreeSet<ContentId> = witness.edges.iter().map(|e| e.parent).collect();
assert!(parents.contains(&p) && parents.contains(&q), "both sharers are parents, never missed");
}
#[test]
fn l20_verify_refolds_the_cut_and_catches_tampering() {
let mut store = FlowStore::new(64);
store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1), Some(Did([9u8; 32])),
&Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor());
let (_id, _label, witness) = store.label_payload(
b"echo ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", &no_declared(), Mode::Explicit, anchor());
assert!(verify_witness(&witness).is_ok(), "a well-formed witness verifies");
// Tamper the sealed label to look clean; the refold of the cut exposes it.
let mut forged = witness.clone();
forged.label = Label::bottom();
assert_eq!(verify_witness(&forged), Err(WitnessError::FoldMismatch));
}
#[test]
fn a_clean_payload_stays_bottom_and_is_byte_identical_ground() {
let mut store = FlowStore::new(64);
store.admit_principal(b"a principal instruction", anchor());
let (_id, label, witness) =
store.label_payload(b"an unrelated derived value", &no_declared(), Mode::Explicit, anchor());
assert!(label.is_bottom(), "no untrusted ancestor ⇒ ⊥");
assert!(witness.edges.is_empty());
assert!(verify_witness(&witness).is_ok());
}
#[test]
fn a_declared_but_absent_parent_fails_closed_to_top() {
let mut store = FlowStore::new(64);
let phantom = ContentId::of_bytes(b"a value never admitted");
let declared: BTreeSet<ContentId> = [phantom].into_iter().collect();
let (_id, label, witness) =
store.label_payload(b"derived from something we lost", &declared, Mode::Explicit, anchor());
assert_eq!(label, Label::top(), "a lost declared dependency folds to ⊤");
assert!(verify_witness(&witness).is_ok(), "the ⊤ fold is witness-consistent");
}
#[test]
fn the_attack_shape_a_ceiling_would_deny() {
// The store side of read_email → summarize → send_email: after summarizing
// an attacker email, the summary's label does not sit below a Principal-only
// ceiling, which is exactly what the runtime gate will reject.
let mut store = FlowStore::new(64);
store.admit_output(b"From attacker: EXFIL-THIS-SECRET-abcdefghij", channel(7),
Some(Did([3u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor());
let (_id, summary_label, _w) = store.label_payload(
b"The email said EXFIL-THIS-SECRET-abcdefghij", &no_declared(), Mode::Explicit, anchor());
// A send_email grant permitting only clean, principal-origin arguments.
let ceiling = FlowCeiling::new(Label {
integrity: Integrity::Principal,
origins: Origins::none(),
readers: jcp_schema::flow::Readers::Public,
});
assert!(!ceiling.admits(&summary_label), "the tainted summary must not pass the ceiling");
// And a bottom argument still would.
assert!(ceiling.admits(&Label::bottom()));
}
#[test]
fn high_water_floor_taints_a_laundered_payload_when_the_scan_cannot() {
// The negative companion to the carry test: base64/paraphrase defeats the
// window index, but HighWater's session floor still denies at the sink.
let mut store = FlowStore::new(64);
store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1),
Some(Did([9u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor());
// A laundered value sharing no window with the email.
let laundered = b"totally different words with no overlap whatsoever xyzzy";
let (_id, explicit_label, _w) =
store.label_payload(laundered, &no_declared(), Mode::Explicit, anchor());
assert!(explicit_label.is_bottom(), "the scan alone cannot see laundering (documented residual)");
// But HighWater joins the floor, which the untrusted read already raised.
let (_id, hw_label, witness) =
store.label_payload(laundered, &no_declared(), Mode::HighWater, anchor());
assert_eq!(hw_label.integrity, Integrity::Untrusted, "the floor still taints the sink");
assert!(witness.edges.iter().any(|e| e.kind == EdgeKind::Ambient));
}
}

View file

@ -0,0 +1,353 @@
//! The session store and the exact carry detector.
//!
//! The store holds every value a run has admitted — tool outputs, principal
//! messages — each with its flow label, indexed by an **exact** window
//! fingerprint. When a later value is computed, [`FlowStore::label_payload`] folds
//! its label from the ancestors it verbatim-carries bytes from, plus any the
//! caller declared, plus (optionally) a coarse session floor.
//!
//! The detector is an exact `blake3` window index, deliberately *not* a rolling
//! hash: a rolling hash carries state between windows and a carry-arithmetic bug
//! silently misses overlaps. Here each W-byte window is hashed independently, so
//! two byte sequences share a fingerprint exactly when they share a verbatim
//! W-byte run — provable, platform-independent, with no state to get wrong.
use std::collections::{BTreeMap, BTreeSet};
use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers};
use jcp_schema::{ContentId, Did};
use serde::{Deserialize, Serialize};
use crate::witness::{CutNode, Edge, EdgeKind, Witness, WITNESS_SCHEMA};
/// The detection floor: overlaps of at least this many verbatim bytes are found;
/// shorter runs are not (a registered sensitive term closes the sub-window gap).
pub const SCAN_W: usize = 16;
/// Domain separation for the window hash, so a fingerprint here cannot collide by
/// construction with any other `blake3` use in the system.
const WINDOW_DOMAIN: &[u8] = b"jcp/flow/window/v1";
/// The scan parameters pinned into every [`Witness`], so a replay uses the exact
/// window size and the exact sensitive-term commitment the verdict was made under.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanParams {
pub w: u16,
/// Content address of the registered sensitive-term set at emission time.
pub sensitive_root: ContentId,
}
/// Every W-byte window of `bytes`, each hashed independently with a domain-keyed
/// `blake3` and truncated to `u64`. Fewer than `w` bytes yields no windows — the
/// sub-window gap the sensitive-term index exists to cover.
pub fn windows(bytes: &[u8], w: usize) -> BTreeSet<u64> {
let mut out = BTreeSet::new();
if w == 0 || bytes.len() < w {
return out;
}
let key = *blake3::hash(WINDOW_DOMAIN).as_bytes();
for start in 0..=bytes.len() - w {
let digest = blake3::keyed_hash(&key, &bytes[start..start + w]);
let mut eight = [0u8; 8];
eight.copy_from_slice(&digest.as_bytes()[0..8]);
out.insert(u64::from_le_bytes(eight));
}
out
}
/// Naive verbatim substring search over bytes — the sub-window sensitive-term test.
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|window| window == needle)
}
/// Which mode [`FlowStore::label_payload`] folds in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// Only explicit evidence: verbatim carry, registered terms, and declarations.
Explicit,
/// Adds the monotone session floor — everything after the first untrusted read
/// joins it. A blunt over-approximation of implicit flow; sound, not precise.
HighWater,
}
/// What the store knows about one admitted value.
#[derive(Clone, Debug)]
struct Admitted {
label: Label,
/// Receipt-log head at admission — the anchor a receipt replay uses to prove
/// this value existed before anything derived from it.
anchor: ContentId,
/// This value's own window fingerprints, kept so eviction can remove them
/// without rescanning.
windows: BTreeSet<u64>,
}
/// The session's flow state: labels, the exact window index, registered sensitive
/// terms, and the coarse floor.
pub struct FlowStore {
admitted: BTreeMap<ContentId, Admitted>,
/// fingerprint → the admitted ids that contain that window.
window_index: BTreeMap<u64, BTreeSet<ContentId>>,
/// registered short/exact secret → the source ids that carry it.
sensitive: BTreeMap<Vec<u8>, BTreeSet<ContentId>>,
/// FIFO admission order, for deterministic eviction.
order: Vec<ContentId>,
/// The monotone `HighWater` floor.
high_water: Label,
/// Receipt-log head at the last admission — the next witness's anchor.
head: ContentId,
cap: usize,
w: usize,
}
/// The sentinel parent id an `Ambient` edge points at (the session floor has no
/// single source payload).
pub fn ambient_source() -> ContentId {
ContentId::of_bytes(b"jcp/flow/high-water")
}
impl FlowStore {
/// A store holding at most `cap` values (FIFO eviction past that) with window
/// size [`SCAN_W`].
pub fn new(cap: usize) -> Self {
FlowStore {
admitted: BTreeMap::new(),
window_index: BTreeMap::new(),
sensitive: BTreeMap::new(),
order: Vec::new(),
high_water: Label::bottom(),
head: ContentId([0u8; 32]),
cap: cap.max(1),
w: SCAN_W,
}
}
pub fn head(&self) -> ContentId {
self.head
}
pub fn get(&self, id: &ContentId) -> Option<&Label> {
self.admitted.get(id).map(|entry| &entry.label)
}
/// The content address of the registered sensitive-term set — pinned into a
/// witness so a replay knows which terms were in force.
pub fn sensitive_root(&self) -> ContentId {
let terms: Vec<&Vec<u8>> = self.sensitive.keys().collect();
ContentId::of(&terms).expect("term set is canonically serializable")
}
fn params(&self) -> ScanParams {
ScanParams { w: self.w as u16, sensitive_root: self.sensitive_root() }
}
fn register_terms(&mut self, id: ContentId, terms: &[&[u8]]) {
for term in terms {
if term.is_empty() {
continue;
}
self.sensitive.entry(term.to_vec()).or_default().insert(id);
}
}
/// Insert a value with a fixed label, indexing its windows and advancing the
/// floor. Shared by the boundary admissions and by `label_payload`.
fn admit(&mut self, bytes: &[u8], label: Label, anchor: ContentId) -> ContentId {
let id = ContentId::of_bytes(bytes);
let value_windows = windows(bytes, self.w);
for fingerprint in &value_windows {
self.window_index.entry(*fingerprint).or_default().insert(id);
}
// A re-admission of identical bytes keeps the earlier (already-anchored)
// entry; its label is content-determined and equal.
if !self.admitted.contains_key(&id) {
self.order.push(id);
}
self.admitted.insert(id, Admitted { label: label.clone(), anchor, windows: value_windows });
self.high_water = self.high_water.join(&label);
self.evict_if_needed();
id
}
fn evict_if_needed(&mut self) {
while self.order.len() > self.cap {
let victim = self.order.remove(0);
if let Some(entry) = self.admitted.remove(&victim) {
for fingerprint in &entry.windows {
if let Some(ids) = self.window_index.get_mut(fingerprint) {
ids.remove(&victim);
if ids.is_empty() {
self.window_index.remove(fingerprint);
}
}
}
}
// An evicted id, if later declared, is named-but-absent ⇒ folded to ⊤
// (fail closed). Its sensitive registrations are left; a term still
// points at a gone id, which `label_payload` treats as absent ⇒ ⊤.
}
}
/// Boundary: admit a tool **result**. Its label is the channel origin (always
/// `Untrusted`, joined with the args that produced the call and the tool's
/// reader policy), and any adapter-declared sensitive terms are registered.
pub fn admit_output(
&mut self,
bytes: &[u8],
channel: ContentId,
principal: Option<Did>,
args: &Label,
readers: Readers,
terms: &[&[u8]],
anchor: ContentId,
) -> (ContentId, Label) {
let origin = match principal {
Some(did) => Origin::from(channel, did),
None => Origin::channel(channel),
};
let channel_label = Label {
integrity: Integrity::Untrusted,
origins: Origins::one(origin),
readers,
};
let label = channel_label.join(args);
let id = self.admit(bytes, label.clone(), anchor);
self.register_terms(id, terms);
self.head = id;
(id, label)
}
/// Boundary: admit a **principal** message — a clean, bottom-labelled value.
pub fn admit_principal(&mut self, bytes: &[u8], anchor: ContentId) -> (ContentId, Label) {
let id = self.admit(bytes, Label::bottom(), anchor);
self.head = id;
(id, Label::bottom())
}
/// The propagation entry point: fold a payload's label from every ancestor it
/// verbatim-carries bytes from (`Witnessed`), every registered sensitive term
/// it contains (`Witnessed`), every caller-declared parent (`Declared`, or ⊤
/// if that parent is absent — fail closed), and — in `HighWater` mode — the
/// session floor (`Ambient`). Admits the payload under that label and returns a
/// self-contained [`Witness`].
pub fn label_payload(
&mut self,
bytes: &[u8],
declared: &BTreeSet<ContentId>,
mode: Mode,
anchor: ContentId,
) -> (ContentId, Label, Witness) {
let subject_windows = windows(bytes, self.w);
let params = self.params();
// 1. Window-found parents and the grams that prove each overlap.
let mut grams_by_parent: BTreeMap<ContentId, BTreeSet<u64>> = BTreeMap::new();
for fingerprint in &subject_windows {
if let Some(ids) = self.window_index.get(fingerprint) {
for id in ids {
grams_by_parent.entry(*id).or_default().insert(*fingerprint);
}
}
}
// 2. Sensitive-found parents and the longest matching term per parent.
let mut sensitive_bytes: BTreeMap<ContentId, usize> = BTreeMap::new();
for (term, sources) in &self.sensitive {
if contains(bytes, term) {
for source in sources {
let entry = sensitive_bytes.entry(*source).or_insert(0);
*entry = (*entry).max(term.len());
}
}
}
// 3. Assemble the cut and edges over the union of parents.
let mut parent_ids: BTreeSet<ContentId> = BTreeSet::new();
parent_ids.extend(grams_by_parent.keys().copied());
parent_ids.extend(sensitive_bytes.keys().copied());
parent_ids.extend(declared.iter().copied());
let mut cut: Vec<CutNode> = Vec::new();
let mut edges: Vec<Edge> = Vec::new();
for parent in &parent_ids {
match self.admitted.get(parent) {
Some(entry) => {
cut.push(CutNode { id: *parent, label: entry.label.clone(), anchor: entry.anchor });
if let Some(grams) = grams_by_parent.get(parent) {
// Verbatim overlap: at least one W-window matched, so at
// least W bytes are verbatim (a sound lower bound).
edges.push(Edge {
parent: *parent,
kind: EdgeKind::Witnessed,
witnessed_bytes: self.w as u64,
grams: grams.clone(),
});
} else if let Some(bytes_len) = sensitive_bytes.get(parent) {
// A registered term appears verbatim. Below the window floor
// there are no grams; the byte count is the evidence.
edges.push(Edge {
parent: *parent,
kind: EdgeKind::Witnessed,
witnessed_bytes: *bytes_len as u64,
grams: BTreeSet::new(),
});
} else {
// Declared with no verbatim overlap: verifiable only via a
// named artifact.
edges.push(Edge {
parent: *parent,
kind: EdgeKind::Declared,
witnessed_bytes: 0,
grams: BTreeSet::new(),
});
}
}
None => {
// Declared but absent (e.g. evicted): fail closed — fold ⊤ so
// the subject inherits maximal taint rather than silently
// dropping a dependency.
cut.push(CutNode { id: *parent, label: Label::top(), anchor: ContentId([0u8; 32]) });
edges.push(Edge {
parent: *parent,
kind: EdgeKind::Declared,
witnessed_bytes: 0,
grams: BTreeSet::new(),
});
}
}
}
// 4. HighWater: join the coarse session floor as an Ambient edge.
if mode == Mode::HighWater && !self.high_water.is_bottom() {
let ambient = ambient_source();
cut.push(CutNode { id: ambient, label: self.high_water.clone(), anchor: self.head });
edges.push(Edge {
parent: ambient,
kind: EdgeKind::Ambient,
witnessed_bytes: 0,
grams: BTreeSet::new(),
});
}
let label = jcp_schema::flow::fold(
&cut.iter().map(|node| node.label.clone()).collect::<Vec<_>>(),
);
let id = self.admit(bytes, label.clone(), anchor);
self.head = id;
let witness = Witness {
schema: WITNESS_SCHEMA.to_string(),
params,
subject: id,
label: label.clone(),
cut,
edges,
};
(id, label, witness)
}
}

View file

@ -0,0 +1,145 @@
//! The witness — the content-addressed, offline-checkable record of *why* a value
//! carries the label it does.
//!
//! A receipt commits only the witness's `id()`. An entitled auditor fetches the
//! witness and rechecks it: re-fold the disclosed ancestor cut and confirm it
//! reproduces the sealed label ([`verify_witness`]). That is what makes a
//! provenance verdict replayable by a third party rather than a claim to trust.
use std::collections::BTreeSet;
use jcp_schema::{flow::Label, ContentId};
use serde::{Deserialize, Serialize};
/// How strongly a taint edge is justified — sealed, so a receipt shows whether a
/// verdict rested on hard bytes or the conservative floor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EdgeKind {
/// A verbatim overlap the scan found between this value and an ancestor: the
/// grams prove it and a third party rechecks them. The strongest evidence.
Witnessed,
/// The caller (or a registered sensitive term) named this ancestor. Verifiable
/// only with the named artifact — a citation, an endorsement, or the term set.
Declared,
/// The coarse session floor (`HighWater` mode): everything after the first
/// untrusted read joins it. Sound but imprecise, and flagged as such in replay.
Ambient,
}
fn u64_is_zero(value: &u64) -> bool {
*value == 0
}
/// One taint edge: a derived value depends on an ancestor payload, and this says
/// how that was established.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edge {
/// The ancestor payload's content id — its key in the [`crate::FlowStore`] and
/// the cut node it justifies.
pub parent: ContentId,
pub kind: EdgeKind,
/// For a `Witnessed` edge, how many verbatim bytes were matched (informational;
/// the grams are the proof).
#[serde(skip_serializing_if = "u64_is_zero", default)]
pub witnessed_bytes: u64,
/// The shared window fingerprints proving the overlap. A verifier rechecks
/// these are a subset of both endpoints' window sets. Empty for `Declared` and
/// `Ambient` edges, and for a sub-window sensitive-term match.
#[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
pub grams: BTreeSet<u64>,
}
/// A direct ancestor in the cut: its content id, the label it was admitted with,
/// and the receipt-log head at admission time (the anchor a receipt replay uses to
/// prove the read happened before this value existed).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CutNode {
pub id: ContentId,
pub label: Label,
pub anchor: ContentId,
}
/// The minimal ancestor cut, its edges, and the folded label — self-contained and
/// content-addressed. The one artifact a third party needs (beyond the receipt
/// chain) to re-derive a flow verdict offline.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Witness {
/// Schema tag, `"flow/1"`.
pub schema: String,
/// The scan parameters in force when this witness was emitted — pinned so a
/// replay uses the exact window size and sensitive-term commitment.
pub params: crate::store::ScanParams,
/// The payload this witness explains (`== frame.payload_hash` of the call).
pub subject: ContentId,
/// The folded effective label — what the ceiling was tested against.
pub label: Label,
/// Each direct ancestor.
pub cut: Vec<CutNode>,
/// How each ancestor taints the subject.
pub edges: Vec<Edge>,
}
/// The witness schema tag.
pub const WITNESS_SCHEMA: &str = "flow/1";
impl Witness {
/// The content id a receipt commits to. Order-free because every field is
/// canonically serialized (`BTree`/`Vec` in insertion order the store fixes).
pub fn id(&self) -> ContentId {
ContentId::of(self).expect("witness is canonically serializable")
}
}
/// Why a witness failed to verify.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum WitnessError {
#[error("witness schema is not {WITNESS_SCHEMA}")]
BadSchema,
/// Re-folding the cut did not reproduce the sealed label — the witness does not
/// justify its own verdict (violates the fold law L20).
#[error("re-folded label does not match the sealed label")]
FoldMismatch,
/// An edge names a parent that is not in the cut.
#[error("edge parent {0:?} is not in the cut")]
DanglingEdge(ContentId),
/// A `Witnessed` edge carries no evidence (no grams and zero bytes).
#[error("witnessed edge to {0:?} carries no evidence")]
EmptyWitnessedEdge(ContentId),
}
/// Verify a witness against itself: the schema is right, every edge points at a
/// cut node, every `Witnessed` edge carries evidence, and — the load-bearing check
/// (law L20) — re-folding the cut's labels reproduces the sealed label.
///
/// This is the *consistency* tier: it proves the witness is internally coherent and
/// that its verdict follows from its own cut. It does **not** re-derive the grams
/// from the original bytes; that truth tier requires disclosing the endpoint bytes
/// and is a separate, opt-in check (see the crate residuals). Consistency is enough
/// to catch a witness that was tampered to soften its own verdict, because the
/// label is refolded from the cut rather than trusted.
pub fn verify_witness(witness: &Witness) -> Result<(), WitnessError> {
if witness.schema != WITNESS_SCHEMA {
return Err(WitnessError::BadSchema);
}
let cut_ids: BTreeSet<&ContentId> = witness.cut.iter().map(|node| &node.id).collect();
for edge in &witness.edges {
if !cut_ids.contains(&edge.parent) {
return Err(WitnessError::DanglingEdge(edge.parent));
}
if edge.kind == EdgeKind::Witnessed && edge.grams.is_empty() && edge.witnessed_bytes == 0 {
return Err(WitnessError::EmptyWitnessedEdge(edge.parent));
}
}
// L20 — re-fold the cut. Join is commutative and associative, so any order
// reproduces the same label; a tampered `label` field is caught here.
let refolded = jcp_schema::flow::fold(
&witness.cut.iter().map(|node| node.label.clone()).collect::<Vec<_>>(),
);
if refolded != witness.label {
return Err(WitnessError::FoldMismatch);
}
Ok(())
}

View file

@ -13,6 +13,7 @@ jcp-grant = { workspace = true }
jcp-runtime = { workspace = true }
jcp-receipt = { path = "../jcp-receipt" }
jcp-bridge = { path = "../jcp-bridge" }
jcp-flow = { path = "../jcp-flow" }
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }
ed25519-dalek = { workspace = true }
serde_json = { workspace = true }

View file

@ -29,14 +29,16 @@
//! stdio binary implements over a child process and tests implement as a mock.
use ed25519_dalek::SigningKey;
use jcp_bridge::{mcp_call_frame, mcp_resource};
use jcp_bridge::{mcp_call_frame, mcp_resource, mcp_result_frame};
use jcp_flow::{FlowStore, Mode, Witness};
use jcp_grant::issue;
use jcp_receipt::ReceiptLog;
use jcp_receipt::{FlowSeal, ReceiptLog};
use jcp_runtime::Runtime;
use jcp_schema::{Capability, Did, Energy, Grant, SignedGrant};
use jcp_schema::flow::{FlowCeiling, Readers};
use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, FlowFacts, Grant, SignedGrant};
use sandbox_meter::PowerMeter;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
/// The upstream MCP server, abstracted so the gateway's policy is testable
/// without spawning a process. `call` sends one JSON-RPC request line and
@ -69,6 +71,18 @@ pub struct Gateway {
meter: PowerMeter,
log: ReceiptLog,
now: u64,
/// Signs re-issued grants when a flow ceiling is attached to a tool. The same
/// fixed local issuer `new` uses — the consumer is its own grant authority.
issuer_sk: SigningKey,
/// Propagated provenance across this session's calls: a tool result is admitted
/// as a labelled source, and a later call's arguments are folded against it.
flow: FlowStore,
/// How flow is folded — explicit evidence only, or with the coarse `HighWater`
/// session floor.
mode: Mode,
/// The witnesses this session emitted, by id — the disclosure store an auditor
/// fetches to re-derive a sealed verdict.
witnesses: BTreeMap<ContentId, Witness>,
}
impl Gateway {
@ -105,9 +119,45 @@ impl Gateway {
meter: PowerMeter::detect(),
log: ReceiptLog::new(steward_sk, format!("did:jcp:gateway:{server}")),
now: 0,
issuer_sk: issuer,
flow: FlowStore::new(4096),
mode: Mode::Explicit,
witnesses: BTreeMap::new(),
}
}
/// Attach a flow ceiling to a tool: re-issue its grant with a
/// [`Caveat::FlowBelow`], so a call whose arguments carry provenance above the
/// ceiling is denied at the gate. A no-op if the tool is not granted.
pub fn set_flow_ceiling(&mut self, tool: &str, ceiling: FlowCeiling) {
let Some(sg) = self.grants.get(tool) else { return };
let mut grant = sg.grant.clone();
grant.caveats.retain(|c| c.flow_ceiling().is_none());
grant.caveats.push(Caveat::FlowBelow(ceiling));
if let Ok(reissued) = issue(&self.issuer_sk, grant) {
self.grants.insert(tool.to_string(), reissued);
}
}
/// Turn on the coarse `HighWater` session floor — every call after the first
/// untrusted read joins the session's taint. A blunt over-approximation of
/// implicit flow; off by default.
pub fn enable_high_water(&mut self) {
self.mode = Mode::HighWater;
}
/// Forbid values carrying `mask`'s origins from reaching `tool`, regardless of
/// grant — an enforcement-point taint floor (SPEC §4.10).
pub fn forbid_flow(&mut self, tool: &str, mask: jcp_schema::flow::Origins) {
self.runtime.forbid_flow(mcp_resource(&self.server, tool), mask);
}
/// The witness for a sealed flow verdict, by id — the disclosure an auditor
/// fetches to re-derive the verdict offline.
pub fn witness(&self, id: &ContentId) -> Option<&Witness> {
self.witnesses.get(id)
}
/// The granted tool names, sorted.
pub fn tools(&self) -> Vec<&str> {
self.grants.keys().map(String::as_str).collect()
@ -159,11 +209,34 @@ impl Gateway {
let grant = sg.grant.clone();
let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes);
// Phase 1: authorize without debiting.
let auth = self.runtime.authorize(&frame, &sg, self.caller, self.now);
// Phase 0: fold the argument's provenance. The scan finds any verbatim
// carry from a tool result admitted earlier this session; the monitor's own
// energy for this is metered and sealed separately, never as tool cost.
let declared = declared_witness_ids(&params);
let anchor = self.log.head();
let mode = self.mode;
let (witness, eval) = {
let flow = &mut self.flow;
let ((_id, _label, witness), m) =
self.meter.measure(|| flow.label_payload(&args_bytes, &declared, mode, anchor));
(witness, m)
};
let facts = FlowFacts {
label: witness.label.clone(),
witness_id: witness.id(),
eval_uj: eval.total_uj(),
eval_prov: eval_provenance(&self.meter),
};
let seal = FlowSeal { facts: Some(facts.clone()), ceiling: ceiling_digest(&grant) };
// Keep the witness discoverable for a later audit of this verdict.
self.witnesses.insert(facts.witness_id, witness);
// Phase 1: authorize with the flow facts — a tainted argument above the
// ceiling is denied here, before any debit (zero joules).
let auth = self.runtime.authorize_flow(&frame, &sg, self.caller, self.now, &facts);
if !auth.is_allow() {
let reason = deny_reason_of(&auth);
self.log.append(&frame, &grant, &auth);
self.log.append_flow(&frame, &grant, &auth, &seal);
return Ok(Relayed::Denied {
response: jsonrpc_error(id, -32001, &format!("JCP denied: {reason}")),
reason,
@ -181,9 +254,25 @@ impl Gateway {
};
let measured_uj = measurement.total_uj();
// Phase 3: debit the MEASURED joules; seal the accounting decision.
let decision = self.runtime.charge(&grant, Energy::measured(measured_uj));
self.log.append(&frame, &grant, &decision);
// Phase 3: debit the measured joules (and the tainted sub-budget, if the
// call was tainted); seal the accounting decision as jcp/3 when tainted.
let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts);
self.log.append_flow(&frame, &grant, &decision, &seal);
// Phase 4: the result becomes a labelled source. Its frame is Trust::Data
// (an injected instruction in it can never exercise a capability), and it
// is admitted so a later call carrying its bytes is folded against it — the
// whole point: derived taint that outlives this one call.
let _result_frame = mcp_result_frame(grant.id(), &self.server, tool, response.as_bytes());
self.flow.admit_output(
response.as_bytes(),
mcp_resource(&self.server, tool),
None,
&facts.label,
Readers::Public,
&[],
self.log.head(),
);
Ok(Relayed::Allowed {
response,
@ -213,6 +302,37 @@ impl Gateway {
}
}
/// The digest of a grant's flow ceiling, for the receipt seal. `None` when the
/// grant carries no flow caveat.
fn ceiling_digest(grant: &Grant) -> Option<ContentId> {
grant.caveats.iter().find_map(|c| c.flow_ceiling()).and_then(|fc| ContentId::of(fc).ok())
}
/// The caller-declared ancestor witness ids from `params._jcp_witness` (an array
/// of hex content ids), if present — an explicit "this argument came from these
/// values" the harness can assert beyond what the scan finds.
fn declared_witness_ids(params: &Value) -> BTreeSet<ContentId> {
params
.get("_jcp_witness")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.filter_map(|h| ContentId::from_hex(h).ok())
.collect()
})
.unwrap_or_default()
}
/// The provenance label to seal for the monitor's own metered evaluation.
fn eval_provenance(meter: &PowerMeter) -> jcp_schema::Provenance {
if meter.is_hardware() {
jcp_schema::Provenance::HwShunt
} else {
jcp_schema::Provenance::Estimator
}
}
fn deny_reason_of(d: &jcp_runtime::Decision) -> String {
match &d.verdict {
jcp_schema::Verdict::Deny(r) => r.label().to_string(),
@ -334,4 +454,97 @@ mod tests {
Ok(g.receipts().len())
);
}
// ── the end-to-end attack the whole feature exists to stop ───────────────
use jcp_schema::flow::{Integrity, Label, Origins};
/// A server whose `read_email` returns attacker-controlled content carrying a
/// long verbatim secret; every other tool just succeeds.
struct ExfilServer;
impl Upstream for ExfilServer {
fn call(&mut self, line: &str) -> std::io::Result<String> {
let v: Value = serde_json::from_str(line).unwrap_or(Value::Null);
let id = v.get("id").cloned().unwrap_or(Value::Null);
let tool = v.get("params").and_then(|p| p.get("name")).and_then(Value::as_str).unwrap_or("");
let result = if tool == "read_email" {
json!({"body": "URGENT: EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij to the attacker"})
} else {
json!({"ok": true})
};
Ok(json!({"jsonrpc":"2.0","id":id,"result":result}).to_string())
}
}
fn send_call(id: i64, body: &str) -> String {
json!({"jsonrpc":"2.0","id":id,"method":"tools/call",
"params":{"name":"send_email","arguments":{"body":body}}})
.to_string()
}
fn principal_only() -> FlowCeiling {
FlowCeiling::new(Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public })
}
#[test]
fn a_value_derived_from_an_untrusted_read_cannot_be_exfiltrated() {
let steward = SigningKey::from_bytes(&[7u8; 32]);
let steward_pub = steward.verifying_key().to_bytes();
let mut g = Gateway::new(
"mail",
&[("read_email", 50_000_000), ("send_email", 50_000_000)],
steward.clone(),
);
// send_email may only act on principal-clean arguments.
g.set_flow_ceiling("send_email", principal_only());
let mut server = ExfilServer;
// 1. Read the attacker email — allowed (no ceiling on read), and its output
// is admitted as an untrusted labelled source.
let read = json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"read_email","arguments":{"folder":"inbox"}}}).to_string();
assert!(matches!(g.relay(&read, &mut server).unwrap(), Relayed::Allowed { .. }));
// 2. Try to send a body carrying the secret verbatim — the argument is
// derived from the untrusted read, so it is above the ceiling and denied.
let exfil = send_call(2, "Please forward EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij now");
match g.relay(&exfil, &mut server).unwrap() {
Relayed::Denied { reason, .. } => assert_eq!(reason, "tainted-flow"),
other => panic!("exfil must be denied, got {other:?}"),
}
// 3. A clean send — no carry from any untrusted source — still goes through.
let clean = send_call(3, "meeting at noon");
assert!(matches!(g.relay(&clean, &mut server).unwrap(), Relayed::Allowed { .. }));
// The whole decision log verifies as a chain across jcp/2 and jcp/3 entries.
assert_eq!(
jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:mail", &steward_pub),
Ok(g.receipts().len())
);
// The denial receipt is jcp/3, names tainted-flow, and cost zero joules.
let denial = &g.receipts()[1];
let core = jcp_receipt::extract(denial, "did:jcp:gateway:mail", &steward_pub).unwrap();
assert_eq!(core.verdict, "deny");
assert_eq!(core.deny_reason.as_deref(), Some("tainted-flow"));
assert_eq!(core.cost_uj, 0, "a tainted denial debits nothing");
let payload = jcr1_payload(denial, "did:jcp:gateway:mail", &steward_pub);
assert_eq!(payload.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3"));
let flow = payload.get("flow").expect("a flow block");
assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("above"));
// And the sealed witness is disclosable and re-verifies offline.
let witness_hex = flow.get("witness").and_then(Value::as_str).unwrap();
let witness_id = ContentId::from_hex(witness_hex).unwrap();
let witness = g.witness(&witness_id).expect("witness is retained for audit");
assert!(jcp_flow::verify_witness(witness).is_ok(), "a third party re-derives the verdict");
assert!(witness.edges.iter().any(|e| e.kind == jcp_flow::EdgeKind::Witnessed), "the carry is proven");
}
/// Decode a verified receipt envelope to its JSON payload (test helper).
fn jcr1_payload(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Value {
let core = jcp_receipt::jcr1::verify_cose_sign1(envelope, steward, pubkey).unwrap();
jcp_schema::from_canonical_cbor(&core).unwrap()
}
}

View file

@ -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<jcp_schema::FlowFacts>,
/// The ceiling's content id ([`jcp_schema::FlowCeiling`] digest), if any.
pub ceiling: Option<jcp_schema::ContentId>,
}
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<Vec<u8>, 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<u8> {
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<u8>] {
&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");
}
}

View file

@ -9,9 +9,10 @@
//! loop — which is the entire point: the inverse-scaling-law result means a more
//! capable model is a more dangerous one, so the gate cannot be the model.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use jcp_grant::{verify, verify_chain, GrantError, RevocationSet};
use jcp_schema::flow::{FlowCeiling, FlowFacts, Origins};
use jcp_schema::{ContentId, DenyReason, Did, Energy, Frame, Grant, SignedGrant, SignedRevocation, Trust, Verdict};
use serde::{Deserialize, Serialize};
@ -65,9 +66,107 @@ pub struct Runtime {
/// (open); once any root is configured for a resource, a chain whose root
/// issuer isn't listed is denied `untrusted-root`.
trust_roots: HashMap<ContentId, Vec<Did>>,
/// The enforcement-point taint floor (SPEC §4.10) — the flow analogue of
/// `trust_roots`. Default-open, so an unconfigured runtime behaves exactly as
/// before.
taint_floor: TaintFloor,
/// Joules spent against each grant's *tainted* sub-budget — the tighter wallet
/// for calls carrying untrusted provenance. Tracked separately from
/// `spent_uj`, which it never converts into.
spent_tainted_uj: HashMap<ContentId, u64>,
}
/// An enforcement-point-wide taint floor (SPEC §4.10): resources whose calls may
/// not carry values from certain origins, regardless of any grant. The flow
/// analogue of [`Runtime`]'s trust roots — an operator control that needs no new
/// grants and cannot be attenuated away by a delegated ceiling.
#[derive(Clone, Debug)]
pub struct TaintFloor {
forbidden: BTreeMap<ContentId, Origins>,
default_forbidden: Origins,
}
impl Default for TaintFloor {
fn default() -> Self {
// Nothing forbidden by default: an empty origin mask intersects nothing,
// so an unconfigured floor is a no-op and every existing deployment is
// unaffected.
TaintFloor { forbidden: BTreeMap::new(), default_forbidden: Origins::none() }
}
}
impl TaintFloor {
/// Does this floor refuse a value with label `l` on `resource`? True when the
/// label's origins touch the resource's forbidden mask (or the default mask,
/// where the resource has none of its own).
fn refuses(&self, resource: &ContentId, l: &jcp_schema::Label) -> bool {
let mask = self.forbidden.get(resource).unwrap_or(&self.default_forbidden);
l.origins.intersects(mask)
}
}
/// The grant's effective flow ceiling: the [`FlowCeiling::attenuate`] fold over
/// every [`FlowBelow`](jcp_schema::Caveat::FlowBelow) caveat it carries, so
/// multiple ceilings compose to the strictest. `None` when the grant carries no
/// flow caveat — the case that behaves exactly as before this feature existed.
fn ceiling_of(g: &Grant) -> Option<FlowCeiling> {
g.caveats.iter().filter_map(|c| c.flow_ceiling()).fold(None, |acc, fc| {
Some(match acc {
Some(parent) => parent.attenuate(fc),
None => fc.clone(),
})
})
}
/// The flow gate (SPEC §4.10, check 6c). A value may exercise the grant only when
/// its label sits at or below the grant's ceiling **and** the resource's taint
/// floor does not refuse its origins.
///
/// The `facts` argument distinguishes three cases deliberately:
/// - `None` — provenance was **not computed** (an old, un-migrated caller). With a
/// ceiling present this **fails closed**; a ceiling-free grant is unaffected.
/// - `Some(bottom)` — provenance computed and **clean**; passes any ceiling.
/// - `Some(tainted)` — tested against the ceiling and the floor.
fn flow_gate(
g: &Grant,
floor: &TaintFloor,
resource: &ContentId,
facts: Option<&FlowFacts>,
) -> Option<DenyReason> {
// The enforcement-point floor applies whenever facts exist, independent of any
// grant ceiling — an operator refusal a delegated grant cannot widen.
if let Some(f) = facts {
if floor.refuses(resource, &f.label) {
return Some(DenyReason::TaintedFlow);
}
}
match (ceiling_of(g), facts) {
// No ceiling: today's behaviour, for any facts including None.
(None, _) => None,
// Ceiling present but provenance not computed: fail closed.
(Some(_), None) => Some(DenyReason::TaintedFlow),
(Some(ceiling), Some(f)) => {
if ceiling.admits(&f.label) {
None
} else {
Some(DenyReason::TaintedFlow)
}
}
}
}
/// The effective ceiling across a delegation chain — the attenuation fold of every
/// link's ceiling, so a chain is at most as permissive as its strictest link.
fn chain_ceiling(chain: &[SignedGrant]) -> Option<FlowCeiling> {
chain.iter().filter_map(|sg| ceiling_of(&sg.grant)).fold(None, |acc, fc| {
Some(match acc {
Some(parent) => parent.attenuate(&fc),
None => fc,
})
})
}
/// Whether every caveat carried inside the grant holds (SPEC §4.9).
///
/// `cost_uj` is `None` in the two-phase metered path ([`Runtime::authorize`]),
@ -140,7 +239,7 @@ impl Runtime {
};
// Validity checks 1–6, in the same fixed order as `decide`.
if let Some(r) = self.check(frame, sg, caller, now) {
if let Some(r) = self.check(frame, sg, caller, now, None) {
return deny(r);
}
// 6b. Caveats (§4.9).
@ -220,6 +319,25 @@ impl Runtime {
}
}
/// Forbid values carrying any origin in `mask` from reaching `resource` (SPEC
/// §4.10) — the flow analogue of [`trust_root`](Self::trust_root). An
/// enforcement-point control that applies regardless of any grant's ceiling and
/// cannot be attenuated away.
pub fn forbid_flow(&mut self, resource: ContentId, mask: Origins) {
self.taint_floor.forbidden.insert(resource, mask);
}
/// Forbid `mask` on every resource without its own floor rule — a fail-closed
/// default, off unless set.
pub fn forbid_flow_by_default(&mut self, mask: Origins) {
self.taint_floor.default_forbidden = mask;
}
/// Joules spent against a grant's tainted sub-budget.
pub fn spent_tainted(&self, grant_id: &ContentId) -> u64 {
*self.spent_tainted_uj.get(grant_id).unwrap_or(&0)
}
/// Decide a frame against the grant it references and a metered `cost`, for
/// `caller` at time `now`. On `Allow`, debits the cost from the grant's
/// budget. Pure and deterministic — the checks run in a fixed order and the
@ -242,7 +360,7 @@ impl Runtime {
};
// Validity checks 1–6, in fixed order (first failure names the reason).
if let Some(r) = self.check(frame, sg, caller, now) {
if let Some(r) = self.check(frame, sg, caller, now, None) {
return deny(r);
}
// 6b. Caveats carried inside the grant (§4.9) — the open attenuation axis.
@ -269,7 +387,14 @@ impl Runtime {
/// Validity checks 1–6 shared by [`decide`](Self::decide) and
/// [`authorize`](Self::authorize): everything except the cost-specific
/// budget test. `None` means the frame may proceed to a budget decision.
fn check(&self, frame: &Frame, sg: &SignedGrant, caller: Did, now: u64) -> Option<DenyReason> {
fn check(
&self,
frame: &Frame,
sg: &SignedGrant,
caller: Did,
now: u64,
facts: Option<&FlowFacts>,
) -> Option<DenyReason> {
let g = &sg.grant;
let gid = g.id();
// 1. Untrusted output can never exercise control — anti-tool-poisoning.
@ -307,6 +432,17 @@ impl Runtime {
if !g.capability.actions.iter().any(|a| a == &frame.action) {
return Some(DenyReason::ActionNotGranted);
}
// 6c. Flow (SPEC §4.10): the argument's propagated provenance must sit at
// or below the grant's ceiling, and the resource's taint floor must not
// refuse its origins. Placed here on purpose — after capability (the
// ceiling lives inside an authenticated grant, so judging flow before
// the signature would seal a verdict over attacker-chosen policy) and
// before caveats and budget (a tainted call must cost zero joules, the
// same invariant tool-poisoning denial holds). A ceiling-free grant
// with no facts reaches here unchanged.
if let Some(r) = flow_gate(g, &self.taint_floor, &frame.resource, facts) {
return Some(r);
}
None
}
@ -321,7 +457,7 @@ impl Runtime {
let g = &sg.grant;
let gid = g.id();
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
if let Some(r) = self.check(frame, sg, caller, now)
if let Some(r) = self.check(frame, sg, caller, now, None)
.or_else(|| caveats_unsatisfied(g, frame, caller, None, now))
{
return Decision {
@ -372,6 +508,109 @@ impl Runtime {
}
}
/// The flow-aware [`authorize`](Self::authorize): the same two-phase gate, but
/// the flow facts are checked (check 6c) so a tainted argument is denied
/// `TaintedFlow` **before** any budget is touched, and a call already at its
/// tainted sub-budget is denied `TaintBudgetExceeded` before the tool runs.
/// `authorize` itself delegates here conceptually with `facts = None`; a
/// gateway that computes provenance calls this with real facts.
pub fn authorize_flow(
&mut self,
frame: &Frame,
sg: &SignedGrant,
caller: Did,
now: u64,
facts: &FlowFacts,
) -> Decision {
let g = &sg.grant;
let gid = g.id();
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
let deny = |r: DenyReason| Decision {
verdict: Verdict::Deny(r),
cost: Energy::measured(0),
budget_remaining_uj: remaining,
};
if let Some(r) = self
.check(frame, sg, caller, now, Some(facts))
.or_else(|| caveats_unsatisfied(g, frame, caller, None, now))
{
return deny(r);
}
if remaining == 0 {
return Decision {
verdict: Verdict::Deny(DenyReason::BudgetExceeded),
cost: Energy::measured(0),
budget_remaining_uj: 0,
};
}
// Tainted work gets the smaller wallet. Pre-checked at authorize (like the
// main budget's `remaining == 0` degradation) so a call that has exhausted
// its tainted sub-budget is denied before the upstream tool runs.
if !facts.is_bottom() {
if let Some(cap) = ceiling_of(g).and_then(|fc| fc.tainted_budget_uj) {
if self.spent_tainted(&gid) >= cap {
return deny(DenyReason::TaintBudgetExceeded);
}
}
}
Decision { verdict: Verdict::Allow, cost: Energy::measured(0), budget_remaining_uj: remaining }
}
/// The flow-aware [`charge`](Self::charge): debit the measured tool joules as
/// usual, and — when the call was tainted — also accrue them against the
/// grant's tainted sub-budget, so the tighter wallet actually depletes. The
/// monitor's own evaluation cost (`facts.eval_uj`) is **not** folded in here;
/// it is reported and sealed separately so a call's energy keeps its meaning.
pub fn charge_flow(&mut self, grant: &Grant, measured: Energy, facts: &FlowFacts) -> Decision {
let decision = self.charge(grant, measured);
if !facts.is_bottom() {
let gid = grant.id();
let accrued = self.spent_tainted(&gid).saturating_add(measured.uj());
self.spent_tainted_uj.insert(gid, accrued);
}
decision
}
/// The flow-aware [`decide`](Self::decide): the one-shot path with flow facts
/// checked at 6c. A tainted argument is denied `TaintedFlow` before the budget
/// test, so — like a tool-poisoning denial — it debits zero joules.
pub fn decide_flow(
&mut self,
frame: &Frame,
sg: &SignedGrant,
caller: Did,
cost: Energy,
now: u64,
facts: &FlowFacts,
) -> Decision {
let g = &sg.grant;
let gid = g.id();
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
let deny = |r: DenyReason| Decision {
verdict: Verdict::Deny(r),
cost,
budget_remaining_uj: remaining,
};
if let Some(r) = self.check(frame, sg, caller, now, Some(facts)) {
return deny(r);
}
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
return deny(r);
}
let spent = self.spent(&gid);
let c = cost.uj();
if spent.saturating_add(c) > g.budget_uj {
return deny(DenyReason::BudgetExceeded);
}
let new_spent = spent + c;
self.spent_uj.insert(gid, new_spent);
if !facts.is_bottom() {
let accrued = self.spent_tainted(&gid).saturating_add(c);
self.spent_tainted_uj.insert(gid, accrued);
}
Decision { verdict: Verdict::Allow, cost, budget_remaining_uj: g.budget_uj - new_spent }
}
/// Decide a frame against a full delegation chain, **root first, leaf
/// last** — multi-hop enforcement (SPEC §4.1).
///
@ -389,6 +628,35 @@ impl Runtime {
caller: Did,
cost: Energy,
now: u64,
) -> Decision {
// No flow facts: a chain whose links carry a flow ceiling fails closed
// (the un-migrated path), exactly as `decide` does with `check(.., None)`.
self.decide_chained_inner(frame, chain, caller, cost, now, None)
}
/// The flow-aware [`decide_chained`](Self::decide_chained): the chain path with
/// the argument's provenance checked against the chain's attenuated ceiling and
/// the resource's taint floor, denied before any link is debited.
pub fn decide_chained_flow(
&mut self,
frame: &Frame,
chain: &[SignedGrant],
caller: Did,
cost: Energy,
now: u64,
facts: &FlowFacts,
) -> Decision {
self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts))
}
fn decide_chained_inner(
&mut self,
frame: &Frame,
chain: &[SignedGrant],
caller: Did,
cost: Energy,
now: u64,
facts: Option<&FlowFacts>,
) -> Decision {
let leaf = match chain.last() {
Some(l) => l,
@ -445,6 +713,27 @@ impl Runtime {
if !leaf.grant.capability.actions.iter().any(|a| a == &frame.action) {
return deny(DenyReason::ActionNotGranted);
}
// 6c (chain form): flow against the chain's attenuated ceiling and the
// resource's taint floor. The ceiling is the meet over every link, so a
// chain is at most as permissive as its strictest hop, and a facts-less
// call under a ceilinged chain fails closed — mirroring the leaf gate.
{
let chained = chain_ceiling(chain);
if let Some(f) = facts {
if self.taint_floor.refuses(&frame.resource, &f.label) {
return deny(DenyReason::TaintedFlow);
}
}
match (chained, facts) {
(None, _) => {}
(Some(_), None) => return deny(DenyReason::TaintedFlow),
(Some(ceiling), Some(f)) => {
if !ceiling.admits(&f.label) {
return deny(DenyReason::TaintedFlow);
}
}
}
}
// Energy conservation: the cost must fit every link's remaining budget.
let c = cost.uj();
for sg in chain {
@ -459,6 +748,14 @@ impl Runtime {
let s = self.spent(&gid) + c;
self.spent_uj.insert(gid, s);
}
// A tainted chained call also depletes each link's tainted sub-budget.
if facts.map(|f| !f.is_bottom()).unwrap_or(false) {
for sg in chain {
let gid = sg.grant.id();
let accrued = self.spent_tainted(&gid).saturating_add(c);
self.spent_tainted_uj.insert(gid, accrued);
}
}
Decision {
verdict: Verdict::Allow,
cost,
@ -918,4 +1215,199 @@ mod tests {
let f = leaf_frame(&chain_a, r);
assert_eq!(rt.decide_chained(&f, &[], sub, Energy::measured(1), 10).verdict, Verdict::Deny(DenyReason::ChainBroken));
}
// ── flow gate (SPEC §4.10, check 6c) ─────────────────────────────────────
use jcp_schema::flow::{FlowCeiling, FlowFacts, Integrity, Label, Origin, Origins, Readers};
use jcp_schema::Provenance;
fn flow_grant(caveats: Vec<Caveat>) -> (SignedGrant, Did, ContentId) {
let issuer = sk(1);
let holder = sk(2);
let resource = ContentId::of_bytes(b"db");
let g = Grant {
issuer: did(&issuer),
subject: did(&holder),
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats,
expiry: 1000,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
(issue(&issuer, g).unwrap(), did(&holder), resource)
}
/// A tainted argument: untrusted, carrying `channel`'s origin.
fn tainted(channel: u8) -> FlowFacts {
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 ceiling that admits only principal-clean, origin-free values.
fn principal_only() -> FlowCeiling {
FlowCeiling::new(Label {
integrity: Integrity::Principal,
origins: Origins::none(),
readers: Readers::Public,
})
}
/// A permissive ceiling that admits any label but has a small tainted budget.
fn permissive_with_tainted_budget(budget: u64) -> FlowCeiling {
FlowCeiling { ceiling: Label::top(), tainted_budget_uj: Some(budget) }
}
#[test]
fn ceiling_free_grant_is_a_noop_for_any_facts() {
// Every grant that exists today is ceiling-free; it must behave identically
// whether facts are absent, clean, or tainted.
let (sg, holder, r) = flow_grant(Vec::new());
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
assert!(rt.decide(&f, &sg, holder, Energy::measured(10), 10).is_allow());
let mut rt = Runtime::new();
assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &FlowFacts::bottom()).is_allow());
let mut rt = Runtime::new();
assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(3)).is_allow());
}
#[test]
fn a_bottom_flow_call_is_byte_identical_to_the_plain_path() {
let (sg, holder, r) = flow_grant(Vec::new());
let f = frame(&sg, r, "read", Trust::Control);
let mut plain = Runtime::new();
let mut flow = Runtime::new();
assert_eq!(
plain.decide(&f, &sg, holder, Energy::measured(30), 10),
flow.decide_flow(&f, &sg, holder, Energy::measured(30), 10, &FlowFacts::bottom()),
);
assert_eq!(plain.spent(&sg.grant.id()), flow.spent(&sg.grant.id()));
}
#[test]
fn clean_facts_pass_a_ceiling() {
let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]);
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &FlowFacts::bottom()).is_allow());
}
#[test]
fn a_tainted_argument_is_denied_by_a_ceiling_with_zero_debit() {
// The core property: a value from an untrusted origin cannot exercise a
// principal-only grant, and the denial precedes the budget so it costs no
// joules — the same invariant tool poisoning holds.
let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]);
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
let d = rt.decide_flow(&f, &sg, holder, Energy::measured(30), 10, &tainted(7));
assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintedFlow));
assert_eq!(rt.spent(&sg.grant.id()), 0, "a tainted deny debits nothing");
}
#[test]
fn a_ceiling_grant_on_the_old_path_fails_closed() {
// An un-migrated caller (no facts) handed a ceilinged grant is denied, never
// silently unenforced.
let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]);
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
assert_eq!(
rt.decide(&f, &sg, holder, Energy::measured(10), 10).verdict,
Verdict::Deny(DenyReason::TaintedFlow),
);
assert_eq!(rt.spent(&sg.grant.id()), 0);
}
#[test]
fn the_taint_floor_refuses_a_forbidden_origin_regardless_of_ceiling() {
// An enforcement-point refusal that no grant carries and none can widen.
let (sg, holder, r) = flow_grant(Vec::new()); // no ceiling at all
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
let forbidden = Origins::one(Origin::channel(ContentId::of_bytes(&[7u8; 4])));
rt.forbid_flow(r, forbidden);
let d = rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(7));
assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintedFlow));
// A value from a different origin is unaffected.
assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(9)).is_allow());
}
#[test]
fn the_tainted_sub_budget_pre_checks_at_authorize() {
// A permissive ceiling admits the label, but the tighter tainted wallet
// denies once spent — before the upstream tool runs.
let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(permissive_with_tainted_budget(20))]);
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
// First tainted call is admitted and depletes the tainted wallet.
assert!(rt.authorize_flow(&f, &sg, holder, 10, &tainted(3)).is_allow());
rt.charge_flow(&sg.grant, Energy::measured(20), &tainted(3));
assert_eq!(rt.spent_tainted(&sg.grant.id()), 20);
// The wallet is now spent: the next tainted call is refused before running.
let d = rt.authorize_flow(&f, &sg, holder, 10, &tainted(3));
assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintBudgetExceeded));
// A clean call still passes — the sub-budget only bounds tainted work.
assert!(rt.authorize_flow(&f, &sg, holder, 10, &FlowFacts::bottom()).is_allow());
}
#[test]
fn charge_flow_only_depletes_the_tainted_wallet_for_tainted_calls() {
let (sg, _holder, _r) = flow_grant(Vec::new());
let mut rt = Runtime::new();
rt.charge_flow(&sg.grant, Energy::measured(15), &FlowFacts::bottom());
assert_eq!(rt.spent_tainted(&sg.grant.id()), 0, "a clean call spends no tainted budget");
rt.charge_flow(&sg.grant, Energy::measured(15), &tainted(1));
assert_eq!(rt.spent_tainted(&sg.grant.id()), 15, "a tainted call does");
}
#[test]
fn a_chained_grant_gates_flow_and_fails_closed_without_facts() {
// A delegated grant carrying a ceiling: the plain chain path fails closed,
// and the flow path denies a tainted argument.
let issuer = sk(1);
let holder = sk(2);
let resource = ContentId::of_bytes(b"db");
let root = Grant {
issuer: did(&issuer),
subject: did(&holder),
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: vec![Caveat::FlowBelow(principal_only())],
expiry: 1000,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
let root_sg = issue(&issuer, root).unwrap();
let f = frame(&root_sg, resource, "read", Trust::Control);
let mut rt = Runtime::new();
// Plain chain path: no facts under a ceiling ⇒ fail closed.
assert_eq!(
rt.decide_chained(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10).verdict,
Verdict::Deny(DenyReason::TaintedFlow),
);
// Flow chain path: a tainted argument is denied.
assert_eq!(
rt.decide_chained_flow(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10, &tainted(7)).verdict,
Verdict::Deny(DenyReason::TaintedFlow),
);
// A clean argument passes.
assert!(rt
.decide_chained_flow(&f, &[root_sg], did(&holder), Energy::measured(10), 10, &FlowFacts::bottom())
.is_allow());
}
}

View file

@ -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-`<W` values are caught when a boundary adapter registers them. Sub-W, unregistered, non-high-water carry is a **stated residual** (§7). |
| **F2** `decide ≡ decide_flow(bottom)` self-contradicts the fail-closed rule `flow_gate(Some(c),None)=deny` | **Drop the false equivalence law.** Replace with an honest compat *pair* (§5): ceiling-free grants → gate is a no-op for *any* facts (byte-identical); ceiling grants on a facts-less caller → **fail closed**. `Some(bottom)` (computed-clean) and `None` (not-computed) are deliberately different inputs. |
| **F3** `eval_uj` folded into `cost_uj` breaks byte-identity and mutates a field's meaning | `flow_uj` is a **separate jcp/3-only field with its own `flow_energy_provenance`**, never folded into `cost_uj`. Clean frames stay `jcp/2` with `cost_uj` = tool cost, byte-for-byte. |
| **F4** Readers (confidentiality) half is inert; no ACL source of truth | Ship **integrity × origins** as load-bearing. `Readers` stays in the type and is sealed, but *enforcement over readers is explicitly v2*, gated behind a per-tool audience oracle. Stated as a scoped residual, not claimed to work. |
| **F5** `policy/1` has no emitter on the gateway path; verdict sealed nowhere | Seal into **`jcp/3` via `ReceiptLog`** — the profile-bump path the gateway already uses. `policy/1` additions become an *optional* second gate for deployments that run a `PolicyEngine`, not the primary binding. |
| **F6** `jcp/2` cannot carry a flow block (PayloadMismatch on recompute) | Correct — so **bump to `jcp/3`** and recompute under the new tag, retaining `to_jcr1_wire_v2` as the subtraction projection (exact `jcp/1→jcp/2` precedent). Clean frames still mint `jcp/2`. |
| **F7** `store_root` unrecomputable from the receipt (needs raw bytes) | Replace the running-hash `store_root` with a **self-contained `Witness`** (the minimal ancestor cut + edges + evidence), content-addressed, committed by id in the receipt, and **disclosed through a `WitnessStore`** (the `jcp-cite::Citation` pattern). Replay is honestly "receipt **+ disclosed witness**," not receipt-alone. |
| **F8** budget couplings live in `decide`/`decide_priced` (zero production callers); no `charge_flow` | Add **`authorize_flow`** (pre-checks the tainted sub-ceiling, like the main budget's `remaining==0`) and **`charge_flow`** (debits `spent_tainted_uj`). Both are wired into `Gateway::relay`. |
Grafted, beyond the flaw fixes:
- **PROV-1**: eager, `id()`-bearing, order-free `Label`; the `Witness`/edge-proof/replay-verifier; **cut-completeness anti-excision**; `Endorsement` (signed declassification); the `replay_spend` under-count bug fix.
- **FlowDAG**: content-addressed node id = `blake3` over parents ⇒ a `ValueId`/`Label::digest()` is a Merkle root, memoisable with no invalidation; `context_digest` binding consent to exact ancestry.
- **TAS**: exact W-window index (kills F1); the resource-keyed `TaintFloor` shaped like `trust_roots` (an enforcement-point-wide control needing no new grants); origins content-addressed by `mcp_resource` id (fixes TAS's own order-dependent-bit defect — an operator can author floor rules at startup).
---
## 1. Exact Rust types and where they live
### 1a. New crate `crates/jcp-flow/` (the lattice, store, witness, scan)
`Cargo.toml`: `jcp-schema = { workspace = true }`, `serde = { workspace = true }`, `blake3` (default-features=false). `#![cfg_attr(not(feature="std"), no_std)] #![forbid(unsafe_code)]`, `extern crate alloc`. **`BTreeMap`/`BTreeSet` only** — no `HashMap` anywhere (hash iteration order would make `store` roots and receipts non-reproducible).
`crates/jcp-flow/src/lattice.rs`:
```rust
use jcp_schema::{ContentId, Did, Trust};
use serde::{Deserialize, Serialize};
use alloc::collections::{BTreeMap, BTreeSet};
/// Integrity chain (total order); higher rank = more believable. Join in the
/// TAINT order takes the WEAKER (min) — same shape as jcp_trace::weaker.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Integrity { Untrusted = 0, Derived = 1, Attested = 2, Principal = 3 }
impl Integrity {
pub const fn rank(self) -> 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<Trust> 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<Did> }
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<Origin>), 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<Did>) }
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<Did>)->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<Trust> 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<u64>,
}
/// 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<CutNode>, // each direct ancestor: (content id, label, anchor)
pub edges: alloc::vec::Vec<Edge>,
}
#[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<u64> { /* blake3::keyed_hash("jcp/flow/window/v1", &bytes[i..i+w]) truncated */ }
pub struct FlowStore {
labels: BTreeMap<ContentId, Label>,
windows: BTreeMap<u64, BTreeSet<ContentId>>, // fingerprint → admitted ids containing it
sensitive: BTreeMap<alloc::vec::Vec<u8>, BTreeSet<ContentId>>, // short registered terms → source ids
order: alloc::vec::Vec<ContentId>, // 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<Did>,
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<ContentId>, 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<u64> }
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<u8> }
```
`crates/jcp-flow/src/replay.rs` — the actual deliverable (PROV-1's verifier):
```rust
pub trait WitnessStore { fn get(&self, id:&ContentId) -> Option<Witness>; }
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<u8>], store:&dyn WitnessStore,
steward:&str, pk:&[u8;32]) -> Result<ReplayReport, ReplayError>;
```
### 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<u64>, // 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<ContentId, u64>, // NEW — mirrors spent_minor
}
pub struct TaintFloor { forbidden: BTreeMap<ContentId, Origins>, 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<DenyReason>;
```
The gate helpers (private):
```rust
fn ceiling_of(g:&Grant)->Option<FlowCeiling> {
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<DenyReason> {
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<FlowFacts>, pub ceiling: Option<ContentId> } // 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<u8>;
// 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<Origin>), IntegrityAtMost(Integrity),
NotReadableBy(Did), // v2 (readers), present now
Caller(Vec<Did>), // 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<Control) | one-bit path is a coarsening |
| **L17 carry (fixed)** | verbatim carry of `≥ SCAN_W` bytes ⇒ a `Witnessed` edge exists | now provable: exact `blake3` window match, no rolling-hash bug |
| L18 index over-approx | a fingerprint collision *adds* an origin, never removes one | collisions sound |
| **L19 sensitive-term** | any registered term (any length) appearing verbatim ⇒ a `Witnessed` edge | closes the sub-W gap for registered secrets |
| **L20 witness-fold** | `re-fold(witness.cut, witness.edges) == witness.label` | the replay anchor |
L17's proof is now clean: for a run of ≥ W verbatim bytes, at least one W-byte window is byte-identical between output and args, so its `blake3` fingerprint is equal and lands in the intersection — no dependence on stride/rotate arithmetic.
---
## 3. Where the check slots into `Runtime::decide`'s ordering, and why
**Position: the final step inside `check` (after capability 6b), reached by `decide`/`decide_priced`/`authorize` in one edit; mirrored once in `decide_chained` after its inline trust test (line 432).**
```rust
fn check(&self, frame:&Frame, sg:&SignedGrant, caller:Did, now:u64, facts:Option<&FlowFacts>) -> Option<DenyReason> {
// 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": "<hex Label::digest()>",
"integrity": "untrusted|derived|attested|principal",
"origins": ["<hex channel id>", …] | "any",
"readers": "public" | ["did:key:z…", …],
"witness": "<hex Witness::id()>", // commitment; the artifact is disclosed via WitnessStore
"ceiling": "<hex FlowCeiling.ceiling.digest()>",
"verdict": "below" | "above",
"flow_uj": <uint>, // 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(&params); // 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<Trust> 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<Control`. The one-bit tag is exactly the coarsening; FL-2 refines, never contradicts. `Trust`'s definition/derives/serde are unchanged.
3. **Check 1 stays first and unchanged**, position 6 in `decide_chained`. Every `Trust::Data` frame is denied `untrusted-control`, same position, whether or not flow is configured.
4. **Grants without a ceiling behave exactly as today.** `ceiling_of(g)=None`, `TaintFloor` default-open ⇒ `flow_gate` is a no-op. Adding `Caveat::FlowBelow` to an externally-tagged enum changes the encoding of no grant that omits it (`absent_money_ceiling_does_not_change_the_encoding` precedent), so every published grant keeps its `ContentId`.
5. **The honest compat pair (F2 fix — replaces FL-1's false equivalence law).**
- **Compat law:** `ceiling_of(g)==None ⟹ ∀ facts. flow_gate(g,·,·,facts)==None` — ceiling-free grants are a no-op for *any* facts, including `None` and `Some(bottom)`. Byte-identical verdicts and receipts. Every grant that exists today is ceiling-free, so *every* existing deployment is unaffected.
- **Fail-closed law:** `ceiling_of(g)==Some(_) ∧ facts==None ⟹ flow_gate==TaintedFlow`. An un-migrated caller (old `authorize`) handed a *new* ceiling grant is denied, never silently unenforced.
- `Some(bottom)` (provenance computed, clean) allows against a ceiling; `None` (provenance not computed) fails closed. These are **different inputs and should differ** — that is the resolution of FL-1's contradiction. A *pre-FL-2 binary* is stricter still: serde cannot deserialize the unknown `FlowBelow` variant, so the grant fails to parse and the call is denied. Every direction of version skew fails closed.
6. **Receipts.** `to_jcr1_wire` untouched; `jcp/3` minted only when tainted; `verify_log` reads only `seq`/`prev`; `ReceiptCore` gains `flow: Option<FlowReceipt>` (`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=<summary>)` 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<FlowReceipt>`.
- **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`.

View file

@ -0,0 +1,710 @@
//! 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<Did>,
}
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<Origin>),
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<Origin>),
Any,
}
impl TryFrom<OriginsWire> for Origins {
type Error = core::convert::Infallible;
fn try_from(wire: OriginsWire) -> Result<Self, Self::Error> {
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<Did>),
}
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, SchemaError> {
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<Trust> 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<u64>,
}
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))
}
/// What the runtime consumes at gate time: a value's folded [`Label`], a
/// commitment to the [witness](../../jcp_flow) that justifies it, and the monitor's
/// own metered cost of computing it (reported separately, never folded into the
/// call's energy).
///
/// A `bottom` facts value means "provenance computed, and clean" — distinct from
/// *no* facts (`None` at the gate), which fails closed against a ceiling. This is
/// deliberately not a wire type: it travels in-process from the gateway to the
/// runtime, and what gets sealed is the label and `witness_id`, via the receipt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FlowFacts {
pub label: Label,
pub witness_id: ContentId,
/// The monitor's own energy for computing the label (microjoules).
pub eval_uj: u64,
pub eval_prov: crate::Provenance,
}
impl FlowFacts {
/// Clean facts — a value with no untrusted provenance and no monitor cost.
pub fn bottom() -> Self {
FlowFacts {
label: Label::bottom(),
witness_id: ContentId([0u8; 32]),
eval_uj: 0,
eval_prov: crate::Provenance::Estimator,
}
}
/// Is this a clean (`⊥`) label? The gate treats bottom facts as "no ceiling
/// concern"; it is *not* the same as facts being absent.
pub fn is_bottom(&self) -> bool {
self.label.is_bottom()
}
}
#[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<Label> {
let integrities =
[Integrity::Untrusted, Integrity::Derived, Integrity::Attested, Integrity::Principal];
let origins = [
Origins::none(),
Origins::one(Origin::channel(cid(1))),
Origins::one(Origin::from(cid(1), did(9))),
{
let mut s = BTreeSet::new();
s.insert(Origin::channel(cid(1)));
s.insert(Origin::channel(cid(2)));
Origins::Set(s)
},
Origins::Any,
];
let readers = [
Readers::Public,
Readers::Only(BTreeSet::new()),
Readers::Only([did(5)].into_iter().collect()),
Readers::Only([did(5), did(6)].into_iter().collect()),
];
let mut out = Vec::new();
for i in &integrities {
for o in &origins {
for r in &readers {
out.push(Label { integrity: *i, origins: o.clone(), readers: r.clone() });
}
}
}
out
}
#[test]
fn l1_l2_l4_join_is_idempotent_commutative_with_identity() {
let bottom = Label::bottom();
for a in sample_labels() {
assert_eq!(a.join(&a), a, "L1 idempotent: {a:?}");
assert_eq!(a.join(&bottom), a, "L4 identity: {a:?}");
for b in sample_labels() {
assert_eq!(a.join(&b), b.join(&a), "L2 commutative: {a:?} {b:?}");
}
}
}
#[test]
fn l3_join_is_associative() {
let labels = sample_labels();
// A representative triple sweep (full cube is ~10^5, this samples every
// third to stay fast while still crossing axis extremes).
for (index, a) in labels.iter().enumerate() {
for b in labels.iter().step_by(3) {
for c in labels.iter().skip(index % 3).step_by(5) {
assert_eq!(
a.join(b).join(c),
a.join(&b.join(c)),
"L3 associative: {a:?} {b:?} {c:?}"
);
}
}
}
}
#[test]
fn l5_top_absorbs() {
let top = Label::top();
for a in sample_labels() {
assert_eq!(a.join(&top), top, "L5 absorbing top: {a:?}");
}
}
#[test]
fn l6_order_agrees_with_join() {
// a ⊑ b ⟺ a ⊔ b = b — the defining bridge between the order and the join.
for a in sample_labels() {
for b in sample_labels() {
assert_eq!(a.leq(&b), a.join(&b) == b, "L6: {a:?} ⊑ {b:?}");
}
}
}
#[test]
fn l7_order_is_a_partial_order() {
let labels = sample_labels();
for a in &labels {
assert!(a.leq(a), "reflexive: {a:?}");
}
for a in &labels {
for b in &labels {
// antisymmetry
if a.leq(b) && b.leq(a) {
assert_eq!(a, b, "antisymmetric: {a:?} {b:?}");
}
if a.leq(b) {
for c in labels.iter().step_by(2) {
if b.leq(c) {
assert!(a.leq(c), "transitive: {a:?} ⊑ {b:?} ⊑ {c:?}");
}
}
}
}
}
}
#[test]
fn l8_join_is_monotone() {
// a⊑a' ∧ b⊑b' ⇒ a⊔b ⊑ a'⊔b'
let labels = sample_labels();
for a in labels.iter().step_by(2) {
for a2 in labels.iter().step_by(3) {
if !a.leq(a2) {
continue;
}
for b in labels.iter().step_by(5) {
for b2 in labels.iter().step_by(7) {
if b.leq(b2) {
assert!(
a.join(b).leq(&a2.join(b2)),
"L8 monotone: {a:?}⊑{a2:?}, {b:?}⊑{b2:?}"
);
}
}
}
}
}
}
#[test]
fn l9_join_never_launders() {
// Each input is ⊑ the fold of the inputs: taint never decreases.
let labels = sample_labels();
for a in labels.iter().step_by(2) {
for b in labels.iter().step_by(3) {
let derived = a.join(b);
assert!(a.leq(&derived), "L9: input {a:?} ⊑ derived {derived:?}");
assert!(b.leq(&derived), "L9: input {b:?} ⊑ derived {derived:?}");
}
}
}
#[test]
fn l11_allow_is_downward_closed() {
// If a ceiling admits l, it admits everything cleaner than l.
let ceilings = sample_labels();
let values = sample_labels();
for ceiling in ceilings.iter().step_by(3) {
for l in values.iter().step_by(2) {
if l.leq(ceiling) {
for cleaner in values.iter().step_by(5) {
if cleaner.leq(l) {
assert!(cleaner.leq(ceiling), "L11: {cleaner:?} ⊑ {l:?} ⊑ {ceiling:?}");
}
}
}
}
}
}
#[test]
fn l12_attenuation_only_narrows() {
// A child ceiling is never wider than its parent: everything the attenuated
// ceiling admits, the parent admits too. Amplification is unrepresentable.
let labels = sample_labels();
for parent in labels.iter().step_by(2) {
let parent_c = FlowCeiling::new(parent.clone());
for child in labels.iter().step_by(3) {
let attenuated = parent_c.attenuate(&FlowCeiling::new(child.clone()));
for value in labels.iter().step_by(5) {
if attenuated.admits(value) {
assert!(
parent_c.admits(value),
"L12: parent {parent:?} must admit what child-attenuated {:?} admits ({value:?})",
attenuated.ceiling
);
}
}
}
}
// And the tainted budget only shrinks.
let a = FlowCeiling { ceiling: Label::bottom(), tainted_budget_uj: Some(500) };
let b = FlowCeiling { ceiling: Label::bottom(), tainted_budget_uj: Some(200) };
assert_eq!(a.attenuate(&b).tainted_budget_uj, Some(200));
assert_eq!(b.attenuate(&a).tainted_budget_uj, Some(200));
}
#[test]
fn l13_saturation_is_sound() {
// A saturated origin set is above the set it came from: it only denies more.
let mut set = BTreeSet::new();
for byte in 0..=(MAX_ORIGINS as u16) {
set.insert(Origin::channel(cid(byte as u8)));
}
let saturated = Origins::Set(set).normalized();
assert_eq!(saturated, Origins::Any, "over-large set saturates");
assert!(Origins::one(Origin::channel(cid(1))).leq(&Origins::Any), "L13: Set ⊑ Any");
}
#[test]
fn l14_digest_is_deterministic_and_order_free() {
// The same label, built two ways with reader/origin insertion in different
// orders, digests identically.
let a = Label {
integrity: Integrity::Derived,
origins: {
let mut s = BTreeSet::new();
s.insert(Origin::channel(cid(2)));
s.insert(Origin::channel(cid(1)));
Origins::Set(s)
},
readers: Readers::Only([did(6), did(5)].into_iter().collect()),
};
let b = Label {
integrity: Integrity::Derived,
origins: {
let mut s = BTreeSet::new();
s.insert(Origin::channel(cid(1)));
s.insert(Origin::channel(cid(2)));
Origins::Set(s)
},
readers: Readers::Only([did(5), did(6)].into_iter().collect()),
};
assert_eq!(a.digest().unwrap(), b.digest().unwrap(), "L14 determinism");
// Distinct labels digest distinctly.
assert_ne!(a.digest().unwrap(), Label::bottom().digest().unwrap());
}
#[test]
fn l16_trust_retraction_round_trips_and_is_monotone() {
// The one-bit tag embeds and retracts exactly.
assert_eq!(Label::from(Trust::Control).trust(), Trust::Control);
assert_eq!(Label::from(Trust::Data).trust(), Trust::Data);
// Monotone: a cleaner label never retracts to a *less* trusted bit. With
// Control the trusted tag, l ⊑ m (l cleaner) ⇒ trust(l) is at least as
// trusted as trust(m). Encode "at least as trusted" as Control > Data.
fn rank(t: Trust) -> u8 {
match t {
Trust::Data => 0,
Trust::Control => 1,
}
}
for l in sample_labels() {
for m in sample_labels() {
if l.leq(&m) {
assert!(
rank(l.trust()) >= rank(m.trust()),
"L16 monotone: cleaner {l:?} must not be less trusted than {m:?}"
);
}
}
}
}
#[test]
fn l15_refinement_bottom_is_the_clean_world() {
// A bottom label passes any ceiling — a clean run is never newly denied.
for ceiling in sample_labels() {
assert!(
FlowCeiling::new(ceiling.clone()).admits(&Label::bottom()),
"L15: ⊥ admitted by every ceiling {ceiling:?}"
);
}
}
#[test]
fn fold_is_the_join_over_inputs() {
let a = Label { integrity: Integrity::Attested, ..Label::bottom() };
let b = Label {
integrity: Integrity::Untrusted,
origins: Origins::one(Origin::channel(cid(3))),
readers: Readers::Public,
};
assert_eq!(fold(&[a.clone(), b.clone()]), a.join(&b));
assert_eq!(fold(&[]), Label::bottom());
}
#[test]
fn origins_wire_normalizes_on_deserialize() {
// An over-large Set arriving on the wire must come back as Any, or two
// encodings of the top would exist and break antisymmetry (L7).
let mut set = BTreeSet::new();
for byte in 0..=(MAX_ORIGINS as u16) {
set.insert(Origin::channel(cid(byte as u8)));
}
let wire = crate::cbor::canonical_cbor(&Origins::Set(set)).unwrap();
let back: Origins = crate::cbor::from_canonical_cbor(&wire).unwrap();
assert_eq!(back, Origins::Any);
}
}

View file

@ -20,6 +20,9 @@ use serde::{Deserialize, Serialize};
mod cbor;
pub use cbor::{canonical_cbor, from_canonical_cbor};
pub mod flow;
pub use flow::{FlowCeiling, FlowFacts, Integrity, Label, Origin, Origins, Readers};
/// Errors from canonical serialization, content addressing, or DID parsing.
#[derive(Debug, thiserror::Error)]
pub enum SchemaError {
@ -253,6 +256,13 @@ pub enum Caveat {
/// `budget_uj`, which is cumulative: this bounds any single call, so a
/// delegate cannot spend the whole remaining budget in one shot.
MaxCostUj(u64),
/// A **flow ceiling** (SPEC §4.10): the greatest-tainted provenance label
/// whose values may exercise this grant. Unlike the other caveats, this one
/// cannot be decided from the frame alone — it needs the argument's propagated
/// label, which the runtime supplies as flow facts. So [`holds`](Self::holds)
/// always returns `true` for it (it never blocks the caveat path); the real
/// test is the runtime's flow gate, which reads this ceiling.
FlowBelow(flow::FlowCeiling),
}
impl Caveat {
@ -282,6 +292,17 @@ impl Caveat {
Caveat::NotBefore(t) => now >= *t,
Caveat::NotAfter(t) => now <= *t,
Caveat::MaxCostUj(max) => cost_uj <= *max,
// Flow is decided by the runtime gate against the argument's label,
// not from the frame — so as a caveat it never blocks.
Caveat::FlowBelow(_) => true,
}
}
/// The flow ceiling this caveat carries, if it is a [`Caveat::FlowBelow`].
pub fn flow_ceiling(&self) -> Option<&flow::FlowCeiling> {
match self {
Caveat::FlowBelow(ceiling) => Some(ceiling),
_ => None,
}
}
}
@ -477,6 +498,15 @@ pub enum DenyReason {
/// resource (SPEC §7) — the enforcement point honors only the root keys it
/// was configured with, `authorized_keys`-style.
UntrustedRoot,
/// The argument's propagated provenance label does not sit at or below the
/// grant's flow ceiling, or the resource's taint floor refuses its origins
/// (SPEC §4.10). The one denial that is about *where a value came from* rather
/// than what it is.
TaintedFlow,
/// A tainted call would exceed the grant's smaller, once-tainted joule budget
/// (SPEC §4.10) — a tighter wallet for work that has touched an untrusted
/// source, distinct from the cumulative `BudgetExceeded`.
TaintBudgetExceeded,
}
impl DenyReason {
@ -496,6 +526,8 @@ impl DenyReason {
DenyReason::Revoked => "revoked",
DenyReason::ChainBroken => "chain-broken",
DenyReason::UntrustedRoot => "untrusted-root",
DenyReason::TaintedFlow => "tainted-flow",
DenyReason::TaintBudgetExceeded => "taint-budget-exceeded",
}
}
}
@ -624,6 +656,31 @@ mod tests {
assert!(String::from_utf8_lossy(&bytes).contains("budget_funds"));
}
#[test]
fn absent_flow_ceiling_does_not_change_the_encoding() {
// A grant with no FlowBelow caveat is byte-identical to one minted before
// the flow feature existed: adding a variant to an externally-tagged enum
// changes the encoding of no value that omits it.
let g = grant_without_funds();
let bytes = crate::cbor::canonical_cbor(&g).expect("canonical");
assert!(!String::from_utf8_lossy(&bytes).contains("FlowBelow"));
assert!(!String::from_utf8_lossy(&bytes).contains("caveats"), "no caveats ⇒ field omitted");
assert_eq!(g.id(), ContentId::of(&g).unwrap());
}
#[test]
fn a_flow_caveat_changes_identity_and_carries_its_ceiling() {
use crate::flow::{FlowCeiling, Label};
let plain = grant_without_funds();
let mut ceilinged = grant_without_funds();
ceilinged.caveats = vec![Caveat::FlowBelow(FlowCeiling::new(Label::bottom()))];
assert_ne!(plain.id(), ceilinged.id(), "a flow ceiling is part of authority");
// The ceiling round-trips through canonical CBOR.
let bytes = crate::cbor::canonical_cbor(&ceilinged).expect("canonical");
let back: Grant = crate::cbor::from_canonical_cbor(&bytes).expect("decode");
assert_eq!(back.caveats[0].flow_ceiling(), Some(&FlowCeiling::new(Label::bottom())));
}
#[test]
fn funds_currency_compare_is_case_insensitive() {
let f = Funds::new("USD", 500);

View file

@ -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<f32, GenerateError> {
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<f32, GenerateError> {
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 => {

View file

@ -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 `<think>` 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>: {instruct}\n<Query>: {query}\n<Document>: {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");
}