open-standards/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md
Transaction Science 7e65cd33d8 jcp-schema: Add the flow lattice — propagated provenance labels
JCP's Trust tag is a one-bit boundary marker: a frame is principal Control or
untrusted Data, and a Data frame may never exercise a capability. That stops
output from being executed as instructions, but it cannot express "this argument
was derived from an earlier untrusted read" — the bit does not propagate through
the values a run computes, so a laundering attack across tool calls slips past it.

This is the propagated form: a Label is a point in a lattice over three axes —
integrity (a total order, Untrusted up to Principal), the set of channel origins
a value flowed from, and its readers. One join folds a derived value's label from
its inputs; the order tells the runtime whether a value may pass a grant's ceiling.

The direction is fixed once: bottom is the cleanest label (Principal, no origins,
Public), and join moves toward more taint, so a ⊑ b reads "a is at least as clean
as b" and taint on a derived value never drops below any input. The one-bit world
is recovered exactly — Label::from(t).trust() == t, and the retraction is monotone,
so refining a label never contradicts the tag and a clean run stays byte-identical.

Sixteen lattice laws ship as tests rather than prose, because every layer above
this — the runtime gate, the sealed verdict, replay — rests on the algebra being
sound: idempotent/commutative/associative join with identity and absorbing top,
the order agreeing with the join, a genuine partial order (antisymmetry needs the
Any-saturation to be canonical, hence wire normalization), monotone join,
no-laundering, downward-closed authorization, attenuation that can only narrow, and
deterministic order-free digests. All collections are BTree, never Hash, so a label
content-addresses identically on every platform.

This is increment 1 of the FL-2 plan (FLOW_LATTICE_PLAN.md); the witness scan and
its laws L17-L20, the runtime gate, the jcp/3 receipt seal, and the gateway relay
follow in their own crates.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 02:08:26 -04:00

48 KiB
Raw Permalink Blame History

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:

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):

/// 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):

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:

/// 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):

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

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

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):

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

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

#[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 Labels 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).

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):

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

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:

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