From 289d584b02821c4dea6c763daeaab381f9f90c96 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 04:10:08 -0400 Subject: [PATCH] jcp: a ModelIn caveat says which model a grant authorizes, and the frame names the model it runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grant's capability names one resource by content address. When that resource is an inference endpoint, the model it runs was chosen inside the payload, and the decision point sees only the payload's hash. So a grant could authorize "this endpoint" but never "this endpoint, running these models". Only PayloadIs came close, and it makes the grant single-use. JCP enforced a model's integrity (refuse wrong bytes) but not authorization over which model runs. - `Frame.model: Option` is the content address of the model the action runs. The dispatching runtime fills it, since it knows what it will load. It is omitted from the encoding when absent, so frames and every receipt and vector over them keep their bytes. - `Caveat::ModelIn(Vec)` requires the frame to name one of these models. It fails closed: a frame naming no model is denied `caveat-unsatisfied`, so a dispatcher that does not say what it will run cannot exercise the grant. A delegate narrows the set by adding a smaller ModelIn beside the inherited one; both must hold, so the intersection governs. This is authorization by content address, not a registry (SPEC §12). - SPEC §4.9 and §6 describe both, and C24's conformance check now covers ModelIn. - Every Frame literal in the repository gains `model: None`. That is 28 sites across jcp-rs, joule-code-rs, joule-ui-rs, jouleclaw-rs and sandbox-rs. Checked on the build host: - jcp-rs: 496 tests pass, and the conformance suite passes all checks, including C24 with ModelIn. - Every vector gen-vectors writes is byte-identical to the published one. - All eight workspaces that depend on jcp-schema pass `cargo check --all-targets`. The consumers jouleclaw-jcp, joule-code-jcp, sandbox-jcp and joule-ui pass their tests. - Mutations: letting a frame with no model pass, and encoding an absent model, each fail tests. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 24 ++++++++-- .../jcp-action-receipt/examples/end_to_end.rs | 1 + jcp/jcp-rs/crates/jcp-bridge/src/lib.rs | 4 ++ jcp/jcp-rs/crates/jcp-channel/src/lib.rs | 1 + jcp/jcp-rs/crates/jcp-cite/src/lib.rs | 1 + jcp/jcp-rs/crates/jcp-cli/src/main.rs | 4 +- .../jcp-conformance/src/bin/gen_vectors.rs | 2 + jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 31 ++++++++++++- jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs | 2 + jcp/jcp-rs/crates/jcp-receipt/src/lib.rs | 3 ++ jcp/jcp-rs/crates/jcp-runtime/src/lib.rs | 46 +++++++++++++++++++ jcp/jcp-rs/crates/jcp-schema/src/lib.rs | 46 +++++++++++++++++++ jcp/jcp-rs/crates/jcp-threshold/src/lib.rs | 1 + jcp/jcp-rs/crates/jcp-wire/src/lib.rs | 1 + .../crates/joule-code-jcp/src/lib.rs | 1 + .../examples/jcp_metered.rs | 1 + .../crates/jouleclaw-jcp/src/lib.rs | 2 + .../crates/jouleclaw-jcp/src/stack.rs | 1 + .../sandbox-rs/crates/sandbox-jcp/src/lib.rs | 1 + 19 files changed, 167 insertions(+), 6 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index c6f6030a4..90b6245bc 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -369,6 +369,7 @@ CallerIs(did:key) ; the caller must be this principal NotBefore(uint) ; unix seconds NotAfter(uint) ; unix seconds — a window narrower than `expiry` MaxCostUj(uint) ; a ceiling on ONE invocation, distinct from the cumulative budget +ModelIn([content-id]) ; the frame must name its model (§6), and it must be one of these ``` Every predicate ranges only over what an enforcement point holds when it decides: @@ -378,11 +379,21 @@ so a condition on content is expressible only as `PayloadIs`, binding an exact payload rather than inspecting it. An implementation MUST NOT invent a predicate that requires data the decision point does not have. -Two of these express authority the fixed axes cannot. `PayloadIs` makes a grant +Three of these express authority the fixed axes cannot. `PayloadIs` makes a grant **one-shot**: it authorizes one specific call, so a leaked grant cannot be replayed against different arguments. `MaxCostUj` bounds a **single invocation**, where `budget_uj` bounds the cumulative total — so a delegate cannot spend an -entire remaining budget in one call. +entire remaining budget in one call. `ModelIn` says **which model** the grant +authorizes. The capability's `resource` names the endpoint, and one endpoint can +run many models; `ModelIn` names the model content addresses this grant may +run there. It ranges over the frame's `model` field (§6), which the runtime that +dispatches the call fills with the content address of the model it will load, +and it **fails closed**: a frame that names no model does not satisfy it, so a +dispatcher that does not say what it will run cannot exercise the grant. This is +authorization by content address, not a registry (§12): the grant names the +bytes it permits, and nothing has to be looked up. A delegate narrows the set +by adding a smaller `ModelIn` beside the inherited one; both must hold, so the +intersection governs. **The metered path (REQUIRED).** A caveat whose predicate ranges over the invocation's cost — `MaxCostUj` — cannot be evaluated in the two-phase metered @@ -1007,9 +1018,16 @@ Frame { action: string resource: content-id payload_hash: content-id + model: content-id? ; OPTIONAL: the model the action runs (§4.9 ModelIn) } ``` +`model` is filled by the runtime that dispatches the call, which knows the +content address of the model it will load. It is absent for an action that runs +no model, and an absent `model` is omitted from the canonical encoding, so such +a frame, and every receipt and vector over it, is byte-identical to one built +before the field existed. + `trust = data` marks untrusted output. A conforming runtime MUST refuse to exercise any capability from a `data` frame (§7), regardless of its content. Tool and agent outputs MUST be tagged `data`. @@ -1220,6 +1238,6 @@ gate. - **C20 — standing grants:** an event-triggered firing mints a strictly-weaker child that traces to the standing grant; the wrong event class, an exhausted firing count, and a firing past the cumulative budget are each refused without advancing the ledger; a policy that would amplify its grant is refused at author time. - **C21 — checkpoints + amendments:** an agent checkpoints its own state; only the grant's issuer — a principal distinct from the agent — may amend, only to tighten, and only up to what the grant has **left** at that checkpoint; an agent steering itself, a non-issuer amending, a widening amendment, an amendment above the remainder of a checkpoint that stated its spend, and an amendment bound to a different checkpoint are each refused. - **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one. -- **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. +- **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; `ModelIn` admits only a frame naming one of its models, and denies a frame naming another or none; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. - **C25 — flow:** a value's propagated label must sit at or below the grant's ceiling and clear the resource's taint floor; an argument whose provenance was **not computed** fails closed against a ceilinged grant rather than passing as clean; several ceilings in a chain compose to the strictest and attenuation never widens one; a flow denial debits nothing, so a tainted call costs zero joules; and a grant with no flow caveat encodes byte-identically to one minted before flow existed. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs b/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs index 42a06b98a..a5b977fa0 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs +++ b/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs @@ -97,6 +97,7 @@ fn main() -> Result<(), Box> { action: "read".into(), resource, payload_hash: ContentId::of_bytes(b"input/quarterly-report"), + model: None, }; let decision = rt.decide(&frame, &delegated, sub_did, authorised_cost(), 0); let verdict = match decision.verdict { diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index 65368cd89..48c05e0fe 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -45,6 +45,7 @@ pub fn mcp_call_frame(grant_id: ContentId, server: &str, tool: &str, args: &[u8] action: tool.to_string(), resource: mcp_resource(server, tool), payload_hash: ContentId::of_bytes(args), + model: None, } } @@ -59,6 +60,7 @@ pub fn mcp_result_frame(grant_id: ContentId, server: &str, tool: &str, output: & action: tool.to_string(), resource: mcp_resource(server, tool), payload_hash: ContentId::of_bytes(output), + model: None, } } @@ -71,6 +73,7 @@ pub fn a2a_task_frame(grant_id: ContentId, agent: &str, method: &str, payload: & action: method.to_string(), resource: a2a_resource(agent, method), payload_hash: ContentId::of_bytes(payload), + model: None, } } @@ -92,6 +95,7 @@ pub fn x402_pay_frame(grant_id: ContentId, network: &str, pay_to: &str, payload: action: "pay".to_string(), resource: x402_resource(network, pay_to), payload_hash: ContentId::of_bytes(payload), + model: None, } } diff --git a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs index f78936958..d39d4e896 100644 --- a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs @@ -204,6 +204,7 @@ mod tests { action: "read".into(), resource: r, payload_hash: ContentId::of_bytes(b"q"), + model: None, } } diff --git a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs index 2b62bd4a6..7d396d545 100644 --- a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs @@ -220,6 +220,7 @@ impl Citation { action: "retrieve".into(), resource, payload_hash: self.id()?, + model: None, }) } } diff --git a/jcp/jcp-rs/crates/jcp-cli/src/main.rs b/jcp/jcp-rs/crates/jcp-cli/src/main.rs index 238d41ff8..7cb2724f6 100644 --- a/jcp/jcp-rs/crates/jcp-cli/src/main.rs +++ b/jcp/jcp-rs/crates/jcp-cli/src/main.rs @@ -160,10 +160,10 @@ fn script(sg: &SignedGrant, db: ContentId, prod_secrets: ContentId, agent: Did) } fn call(sg: &SignedGrant, resource: ContentId, action: &str, trust: Trust) -> Frame { - Frame { kind: FrameKind::ToolCall, trust, grant_id: sg.grant.id(), action: action.into(), resource, payload_hash: ContentId::of_bytes(b"q") } + Frame { kind: FrameKind::ToolCall, trust, grant_id: sg.grant.id(), action: action.into(), resource, payload_hash: ContentId::of_bytes(b"q"), model: None } } fn result(sg: &SignedGrant, resource: ContentId, action: &str, trust: Trust) -> Frame { - Frame { kind: FrameKind::ToolResult, trust, grant_id: sg.grant.id(), action: action.into(), resource, payload_hash: ContentId::of_bytes(b"out") } + Frame { kind: FrameKind::ToolResult, trust, grant_id: sg.grant.id(), action: action.into(), resource, payload_hash: ContentId::of_bytes(b"out"), model: None } } fn truncate(s: &str, n: usize) -> String { diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs index 92e329f76..791a88f90 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs @@ -42,6 +42,7 @@ fn main() { action: action.into(), resource, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; let mut rt = Runtime::new(); @@ -165,6 +166,7 @@ fn main() { action: "read".into(), resource, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; let mut drt = Runtime::new(); let d_allow = drt.decide_priced(&dual_f, &dual_sg, holder_did, Energy::measured(10), &Price::new("USD", 300), 10); diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index dee97256d..09e0e2f08 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -65,7 +65,7 @@ pub fn checks() -> Vec { Check { id: "C21 checkpoint + amendment under separation of duties", run: c21_checkpoint }, Check { id: "C22 dual ceilings: money beside joules, no rate invented", run: c22_dual_ceilings }, Check { id: "C23 receipt binds its payload + jcp/1 still verifies", run: c23_payload_binding }, - Check { id: "C24 caveats: one-shot, per-call cost, monotone attenuation", run: c24_caveats }, + Check { id: "C24 caveats: one-shot, per-call cost, model, monotone attenuation", run: c24_caveats }, Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] @@ -102,6 +102,7 @@ fn frame(sg: &SignedGrant, resource: ContentId, action: &str, trust: Trust) -> F action: action.into(), resource, payload_hash: ContentId::of_bytes(b"p"), + model: None, } } @@ -356,6 +357,34 @@ fn c24_caveats() -> Result<(), String> { return Err("clearing caveats counted as narrowing".into()); } + // ModelIn: the frame must name one of the grant's models; a frame naming + // another, or none, is denied — the dispatcher must say what it will run. + let approved = ContentId::of_bytes(b"approved weights"); + let mut gm = grant(&sk(1), holder, r, &["read"], 1_000_000, 0).grant; + gm.caveats = vec![Caveat::ModelIn(vec![approved])]; + let sgm = issue(&sk(1), gm).map_err(|e| e.to_string())?; + let mut rtm = Runtime::new(); + let mut named = frame(&sgm, r, "read", Trust::Control); + named.model = Some(approved); + if !rtm.decide(&named, &sgm, holder, Energy::measured(1), 10).is_allow() { + return Err("a frame naming the granted model was denied".into()); + } + let mut other = frame(&sgm, r, "read", Trust::Control); + other.model = Some(ContentId::of_bytes(b"other weights")); + if rtm.decide(&other, &sgm, holder, Energy::measured(1), 10).verdict != Verdict::Deny(DenyReason::CaveatUnsatisfied) { + return Err("a frame naming another model was not denied".into()); + } + if rtm.decide(&frame(&sgm, r, "read", Trust::Control), &sgm, holder, Energy::measured(1), 10).verdict + != Verdict::Deny(DenyReason::CaveatUnsatisfied) + { + return Err("a frame naming no model passed a ModelIn caveat".into()); + } + // A frame naming no model encodes without the field. + let bytes = canonical_cbor(&frame(&sgm, r, "read", Trust::Control)).map_err(|e| e.to_string())?; + if String::from_utf8_lossy(&bytes).contains("model") { + return Err("an absent model must not appear in the frame's encoding".into()); + } + // No caveats ⇒ the field is absent from the encoding entirely. let plain = grant(&sk(1), holder, r, &["read"], 100, 0); let bytes = canonical_cbor(&plain.grant).map_err(|e| e.to_string())?; diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs index 416f3c59d..ec301d3c0 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs @@ -478,6 +478,7 @@ impl Orchestrator { action: task.kind.clone(), resource: harness_resource(&id), payload_hash: ContentId::of_bytes(&task.payload), + model: None, }; let decision = self.runtime.decide(&frame, &grant, self.caller, Energy::estimator(cost), self.now); let receipt = seal(&frame, &grant.grant, &decision, &self.steward_sk, &self.steward) @@ -602,6 +603,7 @@ impl Orchestrator { action: task.kind.clone(), resource: harness_resource(harness), payload_hash: ContentId::of_bytes(&task.payload), + model: None, }; self.runtime.decide( &frame, diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 8446364cc..9a9131969 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -836,6 +836,7 @@ mod tests { action: "read".into(), resource, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; let mut rt = Runtime::new(); let d = rt.decide(&frame, &sg, grant.subject, jcp_schema::Energy::measured(30), 10); @@ -869,6 +870,7 @@ mod tests { action: "read".into(), resource, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; let mut rt = Runtime::new(); let allow_f = mk(Trust::Control); @@ -1039,6 +1041,7 @@ mod tests { action: "read".into(), resource, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; let steward_sk = sk(7); let pubkey = steward_sk.verifying_key().to_bytes(); diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index b4d3439fe..b6e01258b 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -862,6 +862,7 @@ mod tests { action: action.into(), resource, payload_hash: ContentId::of_bytes(b"payload"), + model: None, } } @@ -1054,6 +1055,50 @@ mod tests { assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); } + /// Which model a grant authorizes. The capability's resource names the + /// endpoint; `ModelIn` names the model content addresses that endpoint may + /// run for this grant. A frame naming one of them is allowed, a frame + /// naming another is denied, and a frame naming none is denied too, so a + /// dispatcher that does not say what it will run cannot exercise the grant. + /// A denial debits nothing. + #[test] + fn model_in_admits_only_a_named_model_and_fails_closed() { + let approved = ContentId::of_bytes(b"nli weights, revision 7"); + let other = ContentId::of_bytes(b"some other weights"); + let (sg, holder, r) = caveated(vec![Caveat::ModelIn(vec![approved])]); + let mut rt = Runtime::new(); + + let mut f = frame(&sg, r, "read", Trust::Control); + f.model = Some(approved); + assert!(rt.decide(&f, &sg, holder, Energy::measured(10), 10).is_allow()); + + let mut wrong = frame(&sg, r, "read", Trust::Control); + wrong.model = Some(other); + let d = rt.decide(&wrong, &sg, holder, Energy::measured(10), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + + let unnamed = frame(&sg, r, "read", Trust::Control); + assert_eq!(unnamed.model, None); + let d = rt.decide(&unnamed, &sg, holder, Energy::measured(10), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + assert_eq!(rt.spent(&sg.grant.id()), 10, "only the allowed call was debited"); + } + + /// A delegate narrows the models by adding a smaller `ModelIn` beside the + /// inherited one; both must hold, so only their intersection is allowed. + #[test] + fn model_in_narrows_by_intersection() { + let (a, b) = (ContentId::of_bytes(b"model a"), ContentId::of_bytes(b"model b")); + let (sg, holder, r) = caveated(vec![Caveat::ModelIn(vec![a, b]), Caveat::ModelIn(vec![b])]); + let mut rt = Runtime::new(); + let mut fa = frame(&sg, r, "read", Trust::Control); + fa.model = Some(a); + assert_eq!(rt.decide(&fa, &sg, holder, Energy::measured(1), 10).verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + let mut fb = frame(&sg, r, "read", Trust::Control); + fb.model = Some(b); + assert!(rt.decide(&fb, &sg, holder, Energy::measured(1), 10).is_allow()); + } + /// A per-invocation ceiling, distinct from the cumulative budget: a delegate /// cannot burn the whole remaining budget in one call. #[test] @@ -1260,6 +1305,7 @@ mod tests { action: "read".into(), resource: r, payload_hash: ContentId::of_bytes(b"p"), + model: None, } } diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index 813826069..35b6ccfd9 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -334,6 +334,14 @@ 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), + /// The frame must name the model its action runs ([`Frame::model`]), and + /// that model must be one of these content addresses: **which** model a + /// grant authorizes, where the capability's `resource` names only the + /// endpoint that runs it. It fails closed: a frame naming no model does + /// not satisfy it, so a dispatcher that does not say what it will run + /// cannot exercise the grant. This is authorization by content address, + /// not a registry (SPEC §12): the grant names the bytes it permits. + ModelIn(Vec), /// 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 @@ -370,6 +378,7 @@ impl Caveat { Caveat::NotBefore(t) => now >= *t, Caveat::NotAfter(t) => now <= *t, Caveat::MaxCostUj(max) => cost_uj <= *max, + Caveat::ModelIn(set) => frame.model.as_ref().is_some_and(|m| set.contains(m)), // 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, @@ -537,6 +546,14 @@ pub struct Frame { pub action: String, pub resource: ContentId, pub payload_hash: ContentId, + /// The content address of the model this frame's action runs, named by + /// the runtime that dispatches it, which knows what it will load. Checked + /// by [`Caveat::ModelIn`]. Absent for an action that runs no model, and + /// then omitted from the encoding, so a frame without one is + /// byte-identical to one built before the field existed, as is every + /// receipt and vector over it. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub model: Option, } /// Why the runtime denied an action (SPEC §7). A deterministic, enumerable set — @@ -638,6 +655,35 @@ mod tests { Did::from_verifying_key(&SigningKey::from_bytes(&[seed; 32]).verifying_key()) } + /// A frame that names no model encodes exactly as one built before the + /// field existed, so its hash and every receipt over it are unchanged; a + /// frame that names one encodes differently. `ModelIn` holds only for a + /// frame naming a model in its set. + #[test] + fn a_frame_without_a_model_keeps_its_bytes() { + #[derive(Serialize)] + struct Before { + kind: FrameKind, + trust: Trust, + grant_id: ContentId, + action: String, + resource: ContentId, + payload_hash: ContentId, + } + let (g, r, p) = (ContentId::of_bytes(b"g"), ContentId::of_bytes(b"r"), ContentId::of_bytes(b"p")); + let f = Frame { kind: FrameKind::ToolCall, trust: Trust::Control, grant_id: g, action: "read".into(), resource: r, payload_hash: p, model: None }; + let before = Before { kind: FrameKind::ToolCall, trust: Trust::Control, grant_id: g, action: "read".into(), resource: r, payload_hash: p }; + assert_eq!(crate::cbor::canonical_cbor(&f).unwrap(), crate::cbor::canonical_cbor(&before).unwrap()); + + let m = ContentId::of_bytes(b"model weights"); + let named = Frame { model: Some(m), ..f.clone() }; + assert_ne!(crate::cbor::canonical_cbor(&named).unwrap(), crate::cbor::canonical_cbor(&f).unwrap()); + let c = Caveat::ModelIn(vec![m]); + assert!(c.holds(&named, did(1), 0, 0)); + assert!(!c.holds(&f, did(1), 0, 0), "a frame naming no model fails closed"); + assert!(!c.holds(&Frame { model: Some(ContentId::of_bytes(b"other")), ..f }, did(1), 0, 0)); + } + #[test] fn did_key_roundtrips() { let d = did(7); diff --git a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs index 2b7e4b181..b7cf439f3 100644 --- a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs @@ -279,6 +279,7 @@ mod tests { action: "delete".into(), resource: r, payload_hash: ContentId::of_bytes(b"x"), + model: None, }; let chain = vec![sg]; let cost = Energy::estimator(100); diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index de4effdc3..ef4c90c68 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -327,6 +327,7 @@ mod tests { action: "read".into(), resource: r, payload_hash: ContentId::of_bytes(b"q"), + model: None, }; WireRequest::sign(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), 10).unwrap() } diff --git a/joule-code/joule-code-rs/crates/joule-code-jcp/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-jcp/src/lib.rs index fa4c7b4a0..bc79cca27 100644 --- a/joule-code/joule-code-rs/crates/joule-code-jcp/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-jcp/src/lib.rs @@ -164,6 +164,7 @@ impl JcpGate { action: tier_action(tier).to_string(), resource: leaf.capability.resource, payload_hash: ContentId::of_bytes(format!("{}\n{}", req.prior, req.intent).as_bytes()), + model: None, }; let cost = Energy::estimator((inner.costs)(tier)); let (caller, now) = (inner.caller, inner.now); diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs index 0fc77d90b..5350560c9 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/examples/jcp_metered.rs @@ -54,6 +54,7 @@ impl JcpAuthorizer { action: action.to_string(), resource: jcp_schema::ContentId::of_bytes(action.as_bytes()), payload_hash: jcp_schema::ContentId::of_bytes(b""), + model: None, } } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs index 762e7fb03..6595939a2 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs @@ -243,6 +243,7 @@ impl JcpGate { action: action.to_string(), resource, payload_hash: payload, + model: None, }; let cost = Energy::estimator(cost_uj); let (caller, now) = (inner.caller, inner.now); @@ -277,6 +278,7 @@ impl JcpGate { action: tool_id.to_string(), resource: tool_resource(tool_id), payload_hash: ContentId::of_bytes(request), + model: None, }; let cost = Energy::estimator(cost_uj); let (caller, now) = (inner.caller, inner.now); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/stack.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/stack.rs index e5b67d6d8..36265ceb4 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/stack.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/stack.rs @@ -159,6 +159,7 @@ impl JcpGate { action: action.to_string(), resource: leaf.capability.resource, payload_hash: payload, + model: None, }; let cost = Energy::estimator(cost_uj); let (caller, now) = (inner.caller, inner.now); diff --git a/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs index e1d15efc4..4e6e976cc 100644 --- a/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-jcp/src/lib.rs @@ -113,6 +113,7 @@ impl JcpGate { action: EXECUTE_ACTION.to_string(), resource, payload_hash: resource, + model: None, }; let cost = Energy::estimator(ceiling_uj); let decision = self.runtime.decide_chained(&frame, &self.chain, self.caller, cost, self.now); -- 2.54.0 (Apple Git-157)