jcp: caveats — the open attenuation axis (SPEC §4.9, C24) #30

Merged
dcharlot merged 2 commits from jcp/grant-caveats into main 2026-07-20 01:11:58 -04:00
20 changed files with 543 additions and 10 deletions

View file

@ -108,6 +108,7 @@ Grant {
capability: { resource: content-id, actions: [string] }
budget_uj: uint ; the joule ceiling this grant may ever spend
budget_funds: { currency: string, minor: uint }? ; OPTIONAL money ceiling (§4.8)
caveats: [caveat] ; OPTIONAL embedded conditions (§4.9); omitted when empty
expiry: uint ; unix seconds; 0 = no expiry
parent: content-id? ; the grant this was attenuated from
delegate_depth: uint? ; further hops permitted; absent = unlimited, 0 = leaf-only (§4.4)
@ -303,6 +304,76 @@ cannot be shown to narrow without a rate) MUST be refused. A plain attenuation
that names no money ceiling **inherits** the parent's unchanged — it is never
silently dropped, which would amplify. Conformance class **C22**.
### 4.9 Caveats: the open attenuation axis (OPTIONAL)
The axes of §4.1 narrow by set-subtraction — remove actions, lower a ceiling,
shorten an expiry. They cannot express a condition the issuer did not anticipate.
A grant MAY therefore carry **caveats**: conditions embedded *inside* the signed
token that MUST all hold for a decision to be allowed.
The point is that a caveat travels **with the capability**. Any enforcement point
holding the signed grant checks it offline — no call back to the issuer, no
service-side policy to consult, no shared configuration. That is what
distinguishes a caveat from a policy engine: policy is something the verifier
must be told; a caveat is something the token carries.
A conforming implementation MUST support:
```
ActionIn([string]) ; the action must be in this set — MAY narrow below the capability
PayloadIs(content-id) ; the frame's payload must be exactly this — a ONE-SHOT binding
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
```
Every predicate ranges only over what an enforcement point holds when it decides:
the frame, the caller, the clock, and the metered cost. There is deliberately **no
content predicate** — the runtime sees a frame's `payload_hash`, never its bytes —
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
**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.
**The metered path (REQUIRED).** A caveat whose predicate ranges over the
invocation's cost — `MaxCostUj` — cannot be evaluated in the two-phase metered
flow at authorization time, because the cost is not yet known. Such a caveat MUST
be enforced at the **debit point** instead, and an implementation MUST NOT report
a grant as authorized in a way that implies a cost-dependent caveat has passed.
Every cost-independent caveat MUST be enforced in both phases.
When a measured cost breaches a cost-dependent caveat at the debit point, the
work has already run and the joules are real: the implementation MUST still debit
them — energy that was spent is always accounted — and MUST record the breach in
the verdict, so the sealed receipt is evidence of it. Debiting silently, or
suppressing the debit to make the record look clean, are both non-conforming.
**Attenuation (REQUIRED).** Caveats only accumulate. A child MUST carry every
caveat of its parent; a plain attenuation inherits them unchanged, and a
caveat-adding attenuation appends. Dropping an inherited caveat is an
amplification and MUST be refused.
Caveats compare by **exact identity**, not by an ordering over their parameters:
a child retains a parent's caveat only by carrying that identical caveat. There is
deliberately no notion of "a tighter `MaxCostUj` supersedes a looser one" — such
an ordering would have to be defined per variant, and a verifier that got it
wrong would silently widen authority. A delegator that wants a tighter bound adds
it **alongside** the inherited one; since all caveats must hold, the tighter of
the two governs. This is the conservative reading: it can never widen, and it may
be relaxed later without breaking existing grants, whereas the reverse is not
true. A decision whose grant carries an unsatisfied
caveat is denied `caveat-unsatisfied`, sealed like any other denial.
An empty caveat set is omitted from the canonical encoding, so a grant without
caveats is byte-identical to one minted before they existed — its content id and
signature are unchanged. Conformance class **C24**.
## 5. Energy (REQUIRED)
Every metered cost is integer microjoules with a provenance tag: `HwShunt`
@ -555,4 +626,5 @@ 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; an agent steering itself, a non-issuer amending, a widening amendment, 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.
- **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

@ -300,6 +300,7 @@ impl A2aProfile {
capability: cap,
budget_uj,
budget_funds: budget_funds.clone(),
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -17,6 +17,7 @@ fn main() {
capability: Capability::new(ContentId::of_bytes(b"resource"), ["read", "write"]),
budget_uj: 5_000_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: Some(3),

View file

@ -268,6 +268,7 @@ impl LocalToolGate {
budget_uj,
// An MCP tool call carries no payment context — joules alone bound it.
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -366,6 +367,7 @@ mod tests {
capability: Capability::new(mcp_resource("fs", "read_file"), ["read_file"]),
budget_uj: 100_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -409,6 +411,7 @@ mod tests {
capability: Capability::new(a2a_resource("planner", "plan"), ["plan"]),
budget_uj: 10_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -271,6 +271,7 @@ mod tests {
capability: Capability::new(resource(), ["read", "write"]),
budget_uj: 5_000_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: Some(3),

View file

@ -185,6 +185,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 10_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -34,6 +34,7 @@ fn main() {
capability: Capability::new(db, ["read"]),
budget_uj: 100_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -27,6 +27,7 @@ fn main() {
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -83,6 +84,7 @@ fn main() {
capability: standing_cap,
budget_uj: 1_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -100,7 +102,7 @@ fn main() {
issuer: Did::from_verifying_key(&issuer.verifying_key()),
subject: agent_did,
capability: Capability::new(resource, ["read", "write"]),
budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None,
budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None, caveats: Vec::new(),
}).expect("run grant");
let cp = checkpoint(&agent, &run_grant, ContentId::of_bytes(b"state-1"), 0, [7; 16]).expect("cp");
let am = amend(&issuer, &run_grant, &cp, 400, Some(ContentId::of_bytes(b"instr")), [8; 16]).expect("amend");
@ -129,6 +131,7 @@ fn main() {
capability: Capability::new(resource, ["read"]),
budget_uj: 1_000_000,
budget_funds: Some(Funds::new("USD", 500)),
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -27,11 +27,11 @@
//! the precise reason; canonical CBOR rejects floats/bools.
use ed25519_dalek::SigningKey;
use jcp_grant::{attenuate, attenuate_priced, issue, revoke, verify as verify_grant, verify_chain, GrantError};
use jcp_grant::{attenuate, attenuate_priced, caveats_narrow, issue, revoke, verify as verify_grant, verify_chain, GrantError};
use jcp_runtime::{Price, Runtime};
use jcp_schema::{
canonical_cbor, from_canonical_cbor, Capability, ContentId, DenyReason, Did, Energy, Frame,
FrameKind, Funds, Grant, SignedGrant, Trust, Verdict,
Caveat, FrameKind, Funds, Grant, SignedGrant, Trust, Verdict,
};
/// A single conformance check.
@ -65,6 +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: "C-neg expired / forged / wrong-caller", run: cneg_denials },
Check { id: "C-neg canonical rejects floats", run: cneg_canonical },
]
@ -84,6 +85,7 @@ fn grant(issuer: &SigningKey, holder: Did, resource: ContentId, actions: &[&str]
capability: Capability::new(resource, actions.iter().copied()),
budget_uj: budget,
budget_funds: None,
caveats: Vec::new(),
expiry,
parent: None,
delegate_depth: None,
@ -291,6 +293,78 @@ fn c23_payload_binding() -> Result<(), String> {
Ok(())
}
/// C24 — caveats (SPEC §4.9): conditions carried inside the grant, checked
/// offline by the decision point, accumulating monotonically down a chain.
fn c24_caveats() -> Result<(), String> {
let r = ContentId::of_bytes(b"db");
let holder = did(&sk(2));
let intended = ContentId::of_bytes(b"approved-args");
let mut g = grant(&sk(1), holder, r, &["read", "write"], 1_000_000, 0).grant;
g.caveats = vec![Caveat::PayloadIs(intended), Caveat::MaxCostUj(500)];
let sg = issue(&sk(1), g).map_err(|e| e.to_string())?;
let mut rt = Runtime::new();
// The authorized call: right payload, within the per-call cap.
let mut ok = frame(&sg, r, "read", Trust::Control);
ok.payload_hash = intended;
if !rt.decide(&ok, &sg, holder, Energy::measured(100), 10).is_allow() {
return Err("the caveat-satisfying call was denied".into());
}
// One-shot: same grant, same action, different arguments → refused.
let mut replay = frame(&sg, r, "read", Trust::Control);
replay.payload_hash = ContentId::of_bytes(b"attacker-args");
if rt.decide(&replay, &sg, holder, Energy::measured(1), 10).verdict
!= Verdict::Deny(DenyReason::CaveatUnsatisfied)
{
return Err("a replay with different arguments was not denied".into());
}
// Per-call ceiling bites even though the cumulative budget has ample room.
if rt.decide(&ok, &sg, holder, Energy::measured(900), 10).verdict
!= Verdict::Deny(DenyReason::CaveatUnsatisfied)
{
return Err("MaxCostUj did not bound a single invocation".into());
}
// The metered path: a cost-dependent caveat cannot bind at authorize (the
// cost is unknown), so it binds at the debit point — and the joules are
// still accounted, because they were really spent.
let mut rt2 = Runtime::new();
if !rt2.authorize(&ok, &sg, holder, 10).is_allow() {
return Err("authorize failed a caveat it cannot evaluate".into());
}
if rt2.spent(&sg.grant.id()) != 0 {
return Err("authorize debited".into());
}
let charged = rt2.charge(&sg.grant, Energy::measured(900));
if charged.verdict != Verdict::Deny(DenyReason::CaveatUnsatisfied) {
return Err("a cost-dependent caveat did not bind at the debit point".into());
}
if rt2.spent(&sg.grant.id()) != 900 {
return Err("spent joules were hidden after a caveat breach".into());
}
// Attenuation inherits every parent caveat, and dropping one is refused.
let child = attenuate(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1_000, 0, [4; 16])
.map_err(|e| e.to_string())?;
if !caveats_narrow(&sg.grant.caveats, &child.grant.caveats) {
return Err("a plain attenuation dropped a parent caveat".into());
}
if caveats_narrow(&sg.grant.caveats, &[]) {
return Err("clearing caveats counted as narrowing".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())?;
if String::from_utf8_lossy(&bytes).contains("caveats") {
return Err("an empty caveat set must not appear in the encoding".into());
}
Ok(())
}
fn c3_budget() -> Result<(), String> {
let r = ContentId::of_bytes(b"db");
let holder = did(&sk(2));
@ -482,6 +556,7 @@ fn c10_delegation_control() -> Result<(), String> {
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: Some(1),

View file

@ -87,6 +87,7 @@ impl Gateway {
capability: Capability::new(mcp_resource(server, tool), [*tool]),
budget_uj: *budget,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -231,6 +231,7 @@ pub fn apply_amendment(
capability: grant.grant.capability.clone(),
budget_uj: sa.amendment.new_budget_uj,
budget_funds: None,
caveats: Vec::new(),
expiry: grant.grant.expiry,
parent: Some(grant.grant.id()),
delegate_depth: grant.grant.delegate_depth,
@ -259,6 +260,7 @@ mod tests {
capability: cap,
budget_uj: 1_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -304,7 +306,7 @@ mod tests {
let cap = Capability::new(ContentId::of_bytes(b"t"), ["read"]);
let self_grant = Grant {
issuer: did(&agent), subject: did(&agent), capability: cap,
budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(),
};
let g = issue(&agent, self_grant).unwrap();
let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]).unwrap();

View file

@ -14,7 +14,7 @@ use std::collections::HashSet;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
use jcp_schema::{
canonical_cbor, Capability, ContentId, Did, Funds, Grant, KeyEvent, Revocation, SchemaError,
canonical_cbor, Capability, Caveat, ContentId, Did, Funds, Grant, KeyEvent, Revocation, SchemaError,
SignedGrant, SignedKeyEvent, SignedRevocation,
};
@ -109,6 +109,10 @@ pub fn attenuate(
subject: to_subject,
capability,
budget_uj,
// Caveats are INHERITED wholesale (§4.9). A child carries every one of
// its parent's; `attenuate_caveated` is the path that adds more. Losing
// one here would silently widen a delegate's authority.
caveats: p.caveats.clone(),
// The money ceiling is INHERITED, never dropped. Defaulting this to
// `None` would silently hand a child unbounded spending authority where
// its parent was bounded — an amplification, and precisely the bug a
@ -165,6 +169,44 @@ pub fn attenuate_priced(
issue(delegator_sk, sg.grant)
}
/// Whether a child's caveat set is no weaker than its parent's — it must retain
/// every parent caveat. Adding more only narrows (SPEC §4.9).
pub fn caveats_narrow(parent: &[Caveat], child: &[Caveat]) -> bool {
parent.iter().all(|c| child.contains(c))
}
/// Attenuate while **adding** caveats — the open attenuation axis (SPEC §4.9).
///
/// The child keeps every caveat of its parent and gains `add`, so authority
/// narrows monotonically: a delegate can constrain further but never relax what
/// it was handed. Unlike the fixed axes, the added condition is arbitrary and
/// travels *inside* the signed grant, so any verifier checks it offline.
#[allow(clippy::too_many_arguments)]
pub fn attenuate_caveated(
parent: &SignedGrant,
delegator_sk: &SigningKey,
to_subject: Did,
capability: Capability,
budget_uj: u64,
add: Vec<Caveat>,
expiry: u64,
nonce: [u8; 16],
) -> Result<SignedGrant, GrantError> {
let mut sg = attenuate(parent, delegator_sk, to_subject, capability, budget_uj, expiry, nonce)?;
// `attenuate` already inherited the parent's caveats; append the new ones,
// skipping duplicates so the set stays canonical.
for c in add {
if !sg.grant.caveats.contains(&c) {
sg.grant.caveats.push(c);
}
}
// Belt and braces: the result must still be no weaker than the parent.
if !caveats_narrow(&parent.grant.caveats, &sg.grant.caveats) {
return Err(GrantError::Amplifies("caveats weaker than parent"));
}
issue(delegator_sk, sg.grant)
}
/// Split a grant into sibling shards for **distributed enforcement points**
/// (SPEC §4.5). Each shard is an ordinary attenuated child; the helper's value
/// is the conservation check at mint time: **the shard budgets must sum to no
@ -454,6 +496,7 @@ mod tests {
capability: Capability::new(ContentId::of_bytes(b"db"), ["read", "write"]),
budget_uj: 10_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 1000,
parent: None,
delegate_depth: None,
@ -540,6 +583,7 @@ mod tests {
capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]),
budget_uj: 1,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -576,6 +620,62 @@ mod tests {
assert_eq!(child.grant.parent, Some(parent.grant.id()));
}
/// Caveats are inherited by a plain attenuation — never silently dropped,
/// which would widen the delegate's authority.
#[test]
fn attenuate_inherits_caveats() {
let issuer = sk(1);
let holder = sk(2);
let mut g = root(&issuer, did(&holder)).grant;
g.caveats = vec![Caveat::MaxCostUj(500)];
let parent = issue(&issuer, g).unwrap();
let child = attenuate(
&parent, &holder, did(&sk(3)),
Capability::new(ContentId::of_bytes(b"db"), ["read"]), 5_000, 500, [2; 16],
)
.unwrap();
assert_eq!(child.grant.caveats, vec![Caveat::MaxCostUj(500)]);
}
/// The open axis: a delegator ADDS an arbitrary condition the delegate
/// cannot remove, and the result still carries the parent's.
#[test]
fn attenuate_caveated_only_narrows() {
let issuer = sk(1);
let holder = sk(2);
let r = ContentId::of_bytes(b"db");
let mut g = root(&issuer, did(&holder)).grant;
g.caveats = vec![Caveat::MaxCostUj(500)];
let parent = issue(&issuer, g).unwrap();
let child = attenuate_caveated(
&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, vec![Caveat::PayloadIs(ContentId::of_bytes(b"one-shot"))], 500, [2; 16],
)
.unwrap();
assert!(verify(&child).is_ok());
// Keeps the parent's AND gains the new one.
assert!(child.grant.caveats.contains(&Caveat::MaxCostUj(500)));
assert!(child.grant.caveats.contains(&Caveat::PayloadIs(ContentId::of_bytes(b"one-shot"))));
assert!(caveats_narrow(&parent.grant.caveats, &child.grant.caveats));
// Adding a duplicate does not grow the set.
let twice = attenuate_caveated(
&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, vec![Caveat::MaxCostUj(500)], 500, [3; 16],
)
.unwrap();
assert_eq!(twice.grant.caveats.len(), 1);
}
#[test]
fn caveats_narrow_rejects_a_dropped_condition() {
let parent = vec![Caveat::MaxCostUj(500), Caveat::NotAfter(900)];
assert!(caveats_narrow(&parent, &[Caveat::MaxCostUj(500), Caveat::NotAfter(900), Caveat::CallerIs(did(&sk(4)))]));
assert!(!caveats_narrow(&parent, &[Caveat::MaxCostUj(500)]), "dropping a parent caveat must not count as narrowing");
assert!(!caveats_narrow(&parent, &[]), "clearing caveats must not count as narrowing");
}
#[test]
fn attenuation_cannot_amplify() {
let issuer = sk(1);
@ -687,6 +787,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: Some(1),
@ -710,6 +811,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: Some(child.grant.id()),
delegate_depth: None,

View file

@ -213,6 +213,7 @@ mod tests {
capability: cap.clone(),
budget_uj: 1_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -297,7 +298,7 @@ mod tests {
let cap = Capability::new(ContentId::of_bytes(b"r"), ["read"]);
let grant = Grant {
issuer: did(&issuer), subject: did(&issuer), capability: cap.clone(),
budget_uj: 250, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
budget_uj: 250, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(),
};
let sg = crate::issue(&issuer, grant).unwrap();
let policy = issue_policy(
@ -320,7 +321,7 @@ mod tests {
let cap = Capability::new(ContentId::of_bytes(b"r"), ["read"]);
let grant = Grant {
issuer: did(&issuer), subject: did(&issuer), capability: cap.clone(),
budget_uj: 100, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
budget_uj: 100, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(),
};
let sg = crate::issue(&issuer, grant).unwrap();
// per-fire budget over the grant's ceiling is refused at author time.

View file

@ -228,6 +228,7 @@ impl Orchestrator {
capability: Capability::new(harness_resource(&id), kinds.iter().copied()),
budget_uj,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -479,6 +479,7 @@ mod tests {
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -511,6 +512,7 @@ mod tests {
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -601,6 +603,7 @@ mod tests {
capability: Capability::new(resource, ["read"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -67,6 +67,31 @@ pub struct Runtime {
trust_roots: HashMap<ContentId, Vec<Did>>,
}
/// Whether every caveat carried inside the grant holds (SPEC §4.9).
///
/// `cost_uj` is `None` in the two-phase metered path ([`Runtime::authorize`]),
/// where the true cost is not yet known: cost-dependent caveats are skipped
/// there and bind later at [`Runtime::decide`] time. Every other caveat is
/// enforced in both paths.
fn caveats_unsatisfied(
g: &jcp_schema::Grant,
frame: &Frame,
caller: Did,
cost_uj: Option<u64>,
now: u64,
) -> Option<DenyReason> {
for c in &g.caveats {
if c.is_cost_dependent() && cost_uj.is_none() {
continue; // not yet knowable; binds at decide time
}
if !c.holds(frame, caller, cost_uj.unwrap_or(0), now) {
return Some(DenyReason::CaveatUnsatisfied);
}
}
None
}
impl Runtime {
pub fn new() -> Self {
Self::default()
@ -118,6 +143,10 @@ impl Runtime {
if let Some(r) = self.check(frame, sg, caller, now) {
return deny(r);
}
// 6b. Caveats (§4.9).
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
return deny(r);
}
// 7. The JOULE ceiling. Checked first so that when both would bind, the
// receipt names energy — JCP's primary dimension.
@ -216,6 +245,10 @@ impl Runtime {
if let Some(r) = self.check(frame, sg, caller, now) {
return deny(r);
}
// 6b. Caveats carried inside the grant (§4.9) — the open attenuation axis.
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
return deny(r);
}
// 7. Budget: prior spend + this cost must not exceed the ceiling.
let spent = self.spent(&gid);
let c = cost.uj();
@ -288,7 +321,9 @@ impl Runtime {
let g = &sg.grant;
let gid = g.id();
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
if let Some(r) = self.check(frame, sg, caller, now) {
if let Some(r) = self.check(frame, sg, caller, now)
.or_else(|| caveats_unsatisfied(g, frame, caller, None, now))
{
return Decision {
verdict: Verdict::Deny(r),
cost: Energy::measured(0),
@ -322,8 +357,16 @@ impl Runtime {
let debited = measured.uj().min(grant.budget_uj.saturating_sub(spent));
let new_spent = spent + debited;
self.spent_uj.insert(gid, new_spent);
// Cost-dependent caveats (§4.9) could not be evaluated at `authorize` —
// the cost was unknown there. They bind HERE. The work has already run
// and the joules are real, so the debit stands either way: what the
// breach changes is the VERDICT, so the sealed receipt is evidence that
// the invocation exceeded its authorized per-call ceiling rather than
// silently absorbing it. Hiding the breach would be the dishonest move,
// exactly as capping-without-recording would be for a budget overrun.
let breached = grant.caveats.iter().any(|c| c.cost_breached(measured.uj()));
Decision {
verdict: Verdict::Allow,
verdict: if breached { Verdict::Deny(DenyReason::CaveatUnsatisfied) } else { Verdict::Allow },
cost: measured,
budget_remaining_uj: grant.budget_uj - new_spent,
}
@ -428,7 +471,7 @@ impl Runtime {
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use jcp_schema::Funds;
use jcp_schema::{Caveat, Funds};
use jcp_grant::issue;
use jcp_schema::{Capability, FrameKind, Grant};
@ -449,6 +492,7 @@ mod tests {
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 1000,
parent: None,
delegate_depth: None,
@ -535,6 +579,7 @@ mod tests {
capability: Capability::new(resource, ["read"]),
budget_uj,
budget_funds: Some(Funds::new("USD", minor)),
caveats: Vec::new(),
expiry: 1000,
parent: None,
delegate_depth: None,
@ -617,6 +662,142 @@ mod tests {
assert_eq!(rt.spent_minor(&sg.grant.id()), 0); // nothing tracked
}
fn caveated(cavs: Vec<Caveat>) -> (SignedGrant, Did, ContentId) {
let issuer = sk(1);
let holder = sk(2);
let resource = ContentId::of_bytes(b"db");
let g = Grant {
issuer: did(&issuer),
subject: did(&holder),
capability: Capability::new(resource, ["read", "write"]),
budget_uj: 1_000_000,
budget_funds: None,
caveats: cavs,
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
(issue(&issuer, g).unwrap(), did(&holder), resource)
}
/// A one-shot grant: bound to an exact payload, so a leaked grant cannot be
/// replayed against different arguments. This is the capability the fixed
/// axes cannot express — `capability` narrows to an ACTION, never to a call.
#[test]
fn payload_is_makes_a_grant_one_shot() {
let intended = ContentId::of_bytes(b"the-approved-args");
let (sg, holder, r) = caveated(vec![Caveat::PayloadIs(intended)]);
let mut rt = Runtime::new();
let mut f = frame(&sg, r, "read", Trust::Control);
f.payload_hash = intended;
assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 10).is_allow());
// Same grant, same action, different arguments → refused.
let mut replay = frame(&sg, r, "read", Trust::Control);
replay.payload_hash = ContentId::of_bytes(b"attacker-args");
let d = rt.decide(&replay, &sg, holder, Energy::measured(1), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
}
/// A per-invocation ceiling, distinct from the cumulative budget: a delegate
/// cannot burn the whole remaining budget in one call.
#[test]
fn max_cost_bounds_a_single_call_not_the_total() {
let (sg, holder, r) = caveated(vec![Caveat::MaxCostUj(500)]);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
// Well within the 1_000_000 cumulative budget, but over the per-call cap.
let d = rt.decide(&f, &sg, holder, Energy::measured(900), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
assert_eq!(rt.spent(&sg.grant.id()), 0);
// Under the cap → allowed, and repeatable.
assert!(rt.decide(&f, &sg, holder, Energy::measured(400), 10).is_allow());
assert!(rt.decide(&f, &sg, holder, Energy::measured(400), 10).is_allow());
assert_eq!(rt.spent(&sg.grant.id()), 800);
}
#[test]
fn time_window_and_action_and_caller_caveats_bind() {
let (sg, holder, r) = caveated(vec![Caveat::NotBefore(100), Caveat::NotAfter(200)]);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 50).verdict != Verdict::Allow);
assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 150).is_allow());
assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 250).verdict != Verdict::Allow);
// ActionIn narrows BELOW the capability, which permits read+write.
let (sg2, holder2, r2) = caveated(vec![Caveat::ActionIn(vec!["read".into()])]);
let mut rt2 = Runtime::new();
let w = frame(&sg2, r2, "write", Trust::Control);
assert_eq!(
rt2.decide(&w, &sg2, holder2, Energy::measured(1), 10).verdict,
Verdict::Deny(DenyReason::CaveatUnsatisfied),
"a caveat must be able to narrow below the capability"
);
// CallerIs pins the principal.
let (sg3, _h3, r3) = caveated(vec![Caveat::CallerIs(did(&sk(9)))]);
let mut rt3 = Runtime::new();
let f3 = frame(&sg3, r3, "read", Trust::Control);
assert_eq!(
rt3.decide(&f3, &sg3, did(&sk(2)), Energy::measured(1), 10).verdict,
Verdict::Deny(DenyReason::CaveatUnsatisfied)
);
}
/// A grant with no caveats must behave exactly as before — the field is
/// absent from the encoding, so identity and signature are unchanged.
#[test]
fn no_caveats_is_byte_identical_and_unaffected() {
let (plain, holder, r) = setup();
let bytes = jcp_schema::canonical_cbor(&plain.grant).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("caveats"));
let mut rt = Runtime::new();
assert!(rt.decide(&frame(&plain, r, "read", Trust::Control), &plain, holder, Energy::measured(1), 10).is_allow());
}
/// The two-phase metered path: a cost-dependent caveat is unevaluable at
/// authorize (the cost is unknown), so it MUST bind at the debit point —
/// and the joules are still debited, because they were really spent.
#[test]
fn cost_caveat_binds_at_the_debit_point() {
let (sg, holder, r) = caveated(vec![Caveat::MaxCostUj(500)]);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
// Phase one: authorize cannot know the cost, so it does not pretend to.
let a = rt.authorize(&f, &sg, holder, 10);
assert!(a.is_allow(), "authorize must not fail a caveat it cannot evaluate");
assert_eq!(rt.spent(&sg.grant.id()), 0, "authorize never debits");
// Phase two: the measured cost breaches the per-call ceiling.
let d = rt.charge(&sg.grant, Energy::measured(900));
assert_eq!(
d.verdict,
Verdict::Deny(DenyReason::CaveatUnsatisfied),
"a cost-dependent caveat must bind at the debit point"
);
// …and the energy is STILL accounted: it was really consumed.
assert_eq!(rt.spent(&sg.grant.id()), 900, "spent joules must never be hidden");
// Within the ceiling, charge allows and debits normally.
let d2 = rt.charge(&sg.grant, Energy::measured(100));
assert!(d2.is_allow());
assert_eq!(rt.spent(&sg.grant.id()), 1_000);
}
/// Cost-INdependent caveats are enforced in both phases, including authorize.
#[test]
fn cost_independent_caveats_bind_at_authorize() {
let (sg, _h, r) = caveated(vec![Caveat::CallerIs(did(&sk(9)))]);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let a = rt.authorize(&f, &sg, did(&sk(2)), 10);
assert_eq!(a.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
}
#[test]
fn expired_grant_denied() {
let (sg, holder, r) = setup(); // expiry 1000
@ -664,6 +845,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -221,6 +221,71 @@ impl Funds {
}
}
/// A **caveat** — an extra condition carried *inside* the grant that MUST hold
/// for a decision to be allowed (SPEC §4.9).
///
/// Attenuation's fixed axes (capability, budgets, expiry, depth) narrow by
/// set-subtraction: they can only remove actions or lower ceilings. A caveat is
/// the open axis — a delegator embeds an arbitrary condition the delegate cannot
/// remove, and **any** verifier holding the signed grant can check it offline,
/// with no call back to the issuer and no service-side policy to consult.
///
/// Every variant predicates only over what an enforcement point actually holds
/// when it decides: the frame, the caller, the clock, and the metered cost.
/// Notably there is **no content predicate** — the runtime sees a frame's
/// `payload_hash`, never its bytes — so a content condition is expressible only
/// as [`Caveat::PayloadIs`], binding an exact payload rather than inspecting it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Caveat {
/// The action must be one of these — narrower than the capability's set.
ActionIn(Vec<String>),
/// The frame's payload must be exactly this content id: a **one-shot**
/// binding. The grant authorizes this specific call and nothing else, so a
/// leaked grant cannot be replayed against different arguments.
PayloadIs(ContentId),
/// The caller must be this principal.
CallerIs(Did),
/// Not valid before this unix second.
NotBefore(u64),
/// Not valid after this unix second — a window narrower than `expiry`.
NotAfter(u64),
/// A ceiling on **one invocation's** cost, in µJ. Distinct from the grant's
/// `budget_uj`, which is cumulative: this bounds any single call, so a
/// delegate cannot spend the whole remaining budget in one shot.
MaxCostUj(u64),
}
impl Caveat {
/// Whether this caveat can only be evaluated once the invocation's cost is
/// known. Such a caveat is unevaluable in the two-phase metered path at
/// authorize time and binds at the debit point instead (SPEC §4.9).
pub fn is_cost_dependent(&self) -> bool {
matches!(self, Caveat::MaxCostUj(_))
}
/// Whether a cost-dependent caveat is breached by a measured cost. Needs no
/// frame or clock, so it is checkable at the debit point.
pub fn cost_breached(&self, cost_uj: u64) -> bool {
match self {
Caveat::MaxCostUj(max) => cost_uj > *max,
_ => false,
}
}
/// Whether this caveat holds. `cost_uj` is the metered cost of the
/// invocation being decided; `now` the enforcement point's clock.
pub fn holds(&self, frame: &Frame, caller: Did, cost_uj: u64, now: u64) -> bool {
match self {
Caveat::ActionIn(set) => set.iter().any(|a| a == &frame.action),
Caveat::PayloadIs(id) => *id == frame.payload_hash,
Caveat::CallerIs(did) => *did == caller,
Caveat::NotBefore(t) => now >= *t,
Caveat::NotAfter(t) => now <= *t,
Caveat::MaxCostUj(max) => cost_uj <= *max,
}
}
}
/// The **metered capability** (SPEC §4) — JCP's central object. A signed,
/// content-addressed token binding a `subject` to a `capability` *and* a joule
/// `budget`. A `parent` links it into an attenuation chain (object-capability
@ -241,6 +306,15 @@ pub struct Grant {
/// first to bind denies.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub budget_funds: Option<Funds>,
/// Conditions carried **inside** the grant that MUST all hold (SPEC §4.9).
///
/// Empty = no caveats, and the field is omitted from the canonical encoding
/// entirely — so a grant without caveats is byte-identical to one minted
/// before they existed, and its content id and signature are unchanged.
/// Attenuation may only **add** caveats: a child carries every one of its
/// parent's, so authority narrows monotonically down a chain.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub caveats: Vec<Caveat>,
/// Unix seconds after which the grant is invalid; `0` means no expiry.
pub expiry: u64,
/// The parent grant this was attenuated from, if any.
@ -381,6 +455,9 @@ pub enum DenyReason {
/// `BudgetExceeded` (joules) on purpose: which ceiling bound is exactly
/// the fact the receipt exists to record.
FundsExceeded,
/// A caveat carried inside the grant did not hold (SPEC §4.9) — the open
/// attenuation axis. The label names which kind failed.
CaveatUnsatisfied,
/// A cost was presented in a currency the grant's money ceiling is not
/// denominated in. JCP invents no exchange rate, so this is a denial rather
/// than a conversion.
@ -413,6 +490,7 @@ impl DenyReason {
DenyReason::BudgetExceeded => "budget-exceeded",
DenyReason::FundsExceeded => "funds-exceeded",
DenyReason::CurrencyMismatch => "currency-mismatch",
DenyReason::CaveatUnsatisfied => "caveat-unsatisfied",
DenyReason::UntrustedControl => "untrusted-control",
DenyReason::AttenuationViolation => "attenuation-violation",
DenyReason::Revoked => "revoked",
@ -470,6 +548,7 @@ mod tests {
capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -510,6 +589,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 100,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -263,6 +263,7 @@ mod tests {
capability: Capability::new(r, ["delete"]),
budget_uj: 1000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -306,6 +306,7 @@ mod tests {
capability: Capability::new(r, ["read"]),
budget_uj: 100_000,
budget_funds: None,
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -264,6 +264,7 @@ impl X402Profile {
capability: Capability::new(x402_resource(&network, &pay_to), ["pay"]),
budget_uj,
budget_funds: Some(budget_funds),
caveats: Vec::new(),
expiry: 0,
parent: None,
delegate_depth: None,