jcp: a ModelIn caveat says which model a grant authorizes, and the frame names the model it runs #285

Merged
dcharlot merged 1 commit from feat/jcp-model-in-caveat into main 2026-10-17 09:22:11 -04:00
19 changed files with 167 additions and 6 deletions

View file

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

View file

@ -97,6 +97,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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 {

View file

@ -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,
}
}

View file

@ -204,6 +204,7 @@ mod tests {
action: "read".into(),
resource: r,
payload_hash: ContentId::of_bytes(b"q"),
model: None,
}
}

View file

@ -220,6 +220,7 @@ impl Citation {
action: "retrieve".into(),
resource,
payload_hash: self.id()?,
model: None,
})
}
}

View file

@ -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 {

View file

@ -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);

View file

@ -65,7 +65,7 @@ pub fn checks() -> Vec<Check> {
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())?;

View file

@ -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,

View file

@ -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();

View file

@ -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,
}
}

View file

@ -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<ContentId>),
/// 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<ContentId>,
}
/// 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);

View file

@ -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);

View file

@ -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()
}

View file

@ -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);

View file

@ -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,
}
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);