jcp: dual ceilings — a money budget beside the joule budget (SPEC §4.8 + C22) #19

Merged
dcharlot merged 2 commits from jcp/dual-ceilings into main 2026-07-17 02:56:32 -04:00
20 changed files with 929 additions and 22 deletions

View file

@ -107,6 +107,7 @@ Grant {
subject: did:key ; who holds it
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)
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)
@ -114,6 +115,11 @@ Grant {
}
```
`budget_funds` is absent in the baseline grant. Per §3, an absent OPTIONAL field
is omitted from the canonical encoding entirely, so a grant with no money ceiling
is byte-identical to one minted before §4.8 existed — its content id, its
signature, and every published conformance vector are unchanged.
A grant is identified by `blake3(canonical_cbor(grant))` and signed by the
`issuer`'s key (Ed25519 over the canonical bytes). The `resource` is addressed
**by content, not by URL** — audience binding an attacker cannot spoof by name
@ -260,6 +266,43 @@ delegation — an auditor verifies it alongside the signed checkpoint+amendment
pair, which together record who intervened, when, and how. Conformance class
**C21**.
### 4.8 Dual ceilings: a money budget beside the joule budget (OPTIONAL)
A grant MAY carry a second, independent ceiling denominated in **money**
(`budget_funds`), enforced alongside the joule ceiling of §4. This exists because
the payments ecosystem authorizes agent spend in a currency (an AP2 mandate names
dollars), while JCP meters work in joules — and the two MUST NOT be reconciled by
an invented exchange rate. There is no canonical joules-per-dollar; asserting one
would fabricate authority. So JCP enforces **both** ceilings and reconciles
neither.
The two dimensions are governed by two different sources of authority: the
payment mandate bounds the money, the operator bounds the energy. A conforming
runtime MUST, on each priced decision:
- deny with `funds-exceeded` if the action's money cost would carry the grant's
money spend past `budget_funds.minor`;
- deny with `currency-mismatch` if the cost is denominated in a currency other
than `budget_funds.currency` — a foreign-currency cost is refused, never
converted;
- on allow, debit **both** the joule and the money spend; on any deny, debit
**neither**;
- treat an absent `budget_funds` as "joules alone bound this grant" — the money
dimension is not present, not a zero ceiling.
Because the ceilings are independent, a decision MAY deny on one while the other
still has room, and the sealed receipt (§8) records *which* bound. A receipt that
reads "denied: joule ceiling, while the money ceiling had room" is a statement no
single-dimension authorization can make — and it is the point of the design.
**Attenuation (§4.1) applies to both dimensions independently.** A child's money
ceiling MUST NOT amplify the parent's: a child bounded in money where the parent
was unbounded is permitted (it only adds a constraint), but dropping a bounded
parent's ceiling, raising it, or denominating it in a different currency (which
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**.
## 5. Energy (REQUIRED)
Every metered cost is integer microjoules with a provenance tag: `HwShunt`
@ -492,3 +535,4 @@ Behavioral scoring MAY be layered above as a secondary signal; it is never the
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.
- **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

@ -0,0 +1,81 @@
{
"grant": {
"budget_funds": {
"currency": "USD",
"minor": 500
},
"budget_uj": 1000000,
"id_hex": "6029fa6238bf16b012276af6d63378dc23b7a582d2790e5750f9c335167637ae",
"issuer": "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"signature_hex": "b1a94961108cdcac11affb93f0407fe9a1c82c771cbd28f03ad98cd7f28de55963ea8f41415d25a08879efd9c5dab2e646a1a36dabcbb1ea18bea8815ed2ef07",
"subject": "did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH"
},
"receipts": [
{
"envelope_hex": "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d7ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a0a677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784036303239666136323338626631366230313232373661663664363333373864633233623761353832643237393065353735306639633333353136373633376165687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a1a000f42406a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a1a000f42365840c0418a5886b3220a3212d50e79cc31fc16ad837c90572f82b8e191172349db2360618cb5b587597a4387a007092af0b2f717443a653071c37c7fc6fbbce88701",
"verdict": "allow",
"wire": {
"action": "read",
"budget_remaining_uj": 999990,
"budget_uj": 1000000,
"cost_uj": 10,
"energy_provenance": "HwShunt",
"frame_kind": "tool-call",
"grant_id": "6029fa6238bf16b012276af6d63378dc23b7a582d2790e5750f9c335167637ae",
"issuer": "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"jcp_receipt": "jcp/1",
"resource": "0f7fb55cee93ab9777829608e851fa31e68e12db9dfc0a8026ad298314cf3f92",
"steward": "did:example:jcp-reference",
"subject": "did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH",
"trust": "control",
"verdict": "allow"
}
},
{
"envelope_hex": "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901f1af65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a0a677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f3871564269794867766572646963746464656e79686772616e745f6964784036303239666136323338626631366230313232373661663664363333373864633233623761353832643237393065353735306639633333353136373633376165687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a1a000f42406a6672616d655f6b696e6469746f6f6c2d63616c6c6b64656e795f726561736f6e6e66756e64732d65786365656465646b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a1a000f42365840843ee35658c68a12d7fc8275c5451544f596040dd7de5ae04b6893ca68de116955b3de20d5d6c0558e26e1d5a6b8c10d32bf75957e4133639aee4930a4261f06",
"verdict": "deny",
"wire": {
"action": "read",
"budget_remaining_uj": 999990,
"budget_uj": 1000000,
"cost_uj": 10,
"deny_reason": "funds-exceeded",
"energy_provenance": "HwShunt",
"frame_kind": "tool-call",
"grant_id": "6029fa6238bf16b012276af6d63378dc23b7a582d2790e5750f9c335167637ae",
"issuer": "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"jcp_receipt": "jcp/1",
"resource": "0f7fb55cee93ab9777829608e851fa31e68e12db9dfc0a8026ad298314cf3f92",
"steward": "did:example:jcp-reference",
"subject": "did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH",
"trust": "control",
"verdict": "deny"
}
},
{
"envelope_hex": "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901f4af65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a01677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f3871564269794867766572646963746464656e79686772616e745f6964784036303239666136323338626631366230313232373661663664363333373864633233623761353832643237393065353735306639633333353136373633376165687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a1a000f42406a6672616d655f6b696e6469746f6f6c2d63616c6c6b64656e795f726561736f6e7163757272656e63792d6d69736d617463686b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a1a000f42365840ab174bbca1b41c89beb590b3b68fa8eefdf8e0e6610061001cff93f718b5d27374a0837952d77310f20a2dbd4e85545839c6898c0884b080152b014217f84707",
"verdict": "deny",
"wire": {
"action": "read",
"budget_remaining_uj": 999990,
"budget_uj": 1000000,
"cost_uj": 1,
"deny_reason": "currency-mismatch",
"energy_provenance": "HwShunt",
"frame_kind": "tool-call",
"grant_id": "6029fa6238bf16b012276af6d63378dc23b7a582d2790e5750f9c335167637ae",
"issuer": "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
"jcp_receipt": "jcp/1",
"resource": "0f7fb55cee93ab9777829608e851fa31e68e12db9dfc0a8026ad298314cf3f92",
"steward": "did:example:jcp-reference",
"subject": "did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH",
"trust": "control",
"verdict": "deny"
}
}
],
"spent_after": {
"minor": 300,
"uj": 10
}
}

View file

@ -18,16 +18,22 @@
//! resource is `a2a:<agent>:<skill>`, an audience an attacker can't spoof by
//! name). The Agent Card *advertises* the skills; the profile *binds* a
//! capability to each.
//! - [`ingest_ap2_mandate`] parses an AP2 mandate; [`Ap2Mandate::joule_budget`]
//! reads its authorization as a **joule** ceiling. A mandate denominated in
//! joules maps directly; a currency mandate is carried but needs its joule
//! figure set explicitly — the profile never invents an exchange rate.
//! - [`A2aProfile`] mints one capability-scoped, joule-budgeted grant per skill
//! and gates every A2A task against it through a [`jcp_bridge::Bridge`]: a call
//! to a skill the Agent Card did not advertise — or one past the AP2 budget —
//! is denied **before** it reaches the peer, and the denial seals just like an
//! allow. The model's judgement never enters the decision; the held capability
//! and the joule budget do.
//! - [`ingest_ap2_mandate`] parses an AP2 mandate. A **joule**-denominated
//! mandate reads as an energy ceiling ([`Ap2Mandate::joule_budget`]); a
//! **currency**-denominated one — what the payments ecosystem actually emits —
//! reads as a *money* ceiling ([`Ap2Mandate::funds_ceiling`]). The profile
//! never invents a currency→joule exchange rate: the two are carried as two
//! independent ceilings (SPEC §4.8), enforced separately.
//! - [`A2aProfile`] mints one capability-scoped grant per skill and gates every
//! A2A task against it through a [`jcp_bridge::Bridge`]: a call to a skill the
//! Agent Card did not advertise — or one past *either* ceiling — is denied
//! **before** it reaches the peer, and the denial seals just like an allow,
//! recording which ceiling bound. For a currency mandate,
//! [`A2aProfile::from_mandate_priced`] takes the money ceiling from the mandate
//! and the joule ceiling from the operator (the mandate has no authority over
//! energy), then [`A2aProfile::serve_task_priced`] enforces both. The model's
//! judgement never enters the decision; the held capability and the two
//! budgets do.
//!
//! Ingest, not compete: the A2A/AP2 wire is the surface; JCP contributes the
//! capability, the energy budget, and the cryptographic audit it lacks.
@ -37,7 +43,8 @@ use std::collections::BTreeMap;
use ed25519_dalek::SigningKey;
use jcp_bridge::{a2a_resource, Bridge, Gated};
use jcp_grant::issue;
use jcp_schema::{Capability, Did, Energy, Grant, SignedGrant};
use jcp_runtime::Price;
use jcp_schema::{Capability, Did, Energy, Funds, Grant, SignedGrant};
use serde_json::Value;
/// Errors ingesting an Agent Card, an AP2 mandate, or an A2A request.
@ -176,6 +183,25 @@ impl Ap2Mandate {
_ => None,
}
}
/// The **money** ceiling this mandate authorizes (SPEC §4.8) — `Some` for a
/// currency-denominated mandate, which is what the payments ecosystem
/// actually emits.
///
/// This is the counterpart to [`joule_budget`](Self::joule_budget), and the
/// two are deliberately disjoint: a mandate denominated in joules is an
/// energy ceiling and yields no money ceiling; one denominated in a currency
/// is a money ceiling and yields no joule ceiling. **No exchange rate is
/// invented in either direction.** A currency mandate therefore does not
/// bound energy at all — the operator supplies that ceiling, because the
/// mandate has no authority over it. Two ceilings, two sources of authority:
/// exactly why they are enforced separately rather than converted.
pub fn funds_ceiling(&self) -> Option<Funds> {
if self.joule_budget().is_some() {
return None; // joule-denominated: an energy ceiling, not money
}
Some(Funds::new(self.currency.trim(), self.max_amount))
}
}
/// Parse an AP2 mandate from its JSON form. Reads the payee, the currency, and
@ -247,6 +273,21 @@ impl A2aProfile {
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
Self::new_priced(card, budget_uj, None, issuer_sk, subject, steward_sk, steward)
}
/// Mint one **dual-ceiling** grant per advertised skill (SPEC §4.8): a joule
/// ceiling and, optionally, a money ceiling — each enforced independently.
#[allow(clippy::too_many_arguments)]
pub fn new_priced(
card: &AgentCard,
budget_uj: u64,
budget_funds: Option<Funds>,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
let agent = card.agent_id().to_string();
@ -258,6 +299,7 @@ impl A2aProfile {
subject,
capability: cap,
budget_uj,
budget_funds: budget_funds.clone(),
expiry: 0,
parent: None,
delegate_depth: None,
@ -302,6 +344,69 @@ impl A2aProfile {
Self::new(card, budget, issuer_sk, subject, steward_sk, steward)
}
/// Ingest a **currency-denominated** AP2 mandate — the form the payments
/// ecosystem actually emits — as a dual-ceiling profile (SPEC §4.8).
///
/// The mandate supplies the **money** ceiling; the caller supplies the
/// **joule** ceiling, because the mandate has no authority over energy and
/// JCP will not invent a rate to derive one. Both are then enforced
/// independently on every task, and the receipt records which one bound.
///
/// This is what a real AP2 mandate (`currency: "USD"`) resolves to: before
/// dual ceilings it yielded no joule ceiling at all and could not be gated.
#[allow(clippy::too_many_arguments)]
pub fn from_mandate_priced(
card: &AgentCard,
mandate: &Ap2Mandate,
budget_uj: u64,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, A2aError> {
// A joule-denominated mandate has no money ceiling; a currency one has
// no joule ceiling. Either way the caller's `budget_uj` bounds energy.
let funds = mandate.funds_ceiling();
Self::new_priced(card, budget_uj, funds, issuer_sk, subject, steward_sk, steward)
}
/// Serve an A2A task against **both** ceilings — the priced dual of
/// [`serve_task`](Self::serve_task). `price` is what this task costs in
/// money; `cost_uj` is what it costs in energy.
pub fn serve_task_priced(
&mut self,
skill_id: &str,
payload: &[u8],
cost_uj: u64,
price: &Price,
) -> Gated {
let signed = self
.grants
.get(skill_id)
.or(self.anchor.as_ref())
.expect("profile always holds at least one grant (new rejects empty cards)")
.clone();
let gated = self.bridge.call_a2a_priced(
&signed,
self.caller,
&self.agent,
skill_id,
payload,
Energy::estimator(cost_uj),
price,
self.now,
);
self.receipts.push(gated.receipt.clone());
gated
}
/// Money debited against a skill's grant, in its ceiling's minor units.
pub fn spent_minor(&self, skill_id: &str) -> u64 {
self.grants
.get(skill_id)
.map_or(0, |sg| self.bridge.runtime().spent_minor(&sg.grant.id()))
}
/// Gate one A2A task: `skill_id` is the skill the task targets, `payload` its
/// message bytes, `cost_uj` the energy to attribute. On allow the cost debits
/// the skill's grant; either way the decision seals a JCR-1 receipt. Forward
@ -553,4 +658,54 @@ mod tests {
assert_eq!(core.verdict, "allow");
assert_eq!(core.cost_uj, 10_000);
}
/// A **USD** AP2 mandate — the form the payments ecosystem actually emits —
/// carries no joule ceiling (no rate is invented), but it DOES bound money.
fn usd_mandate() -> Ap2Mandate {
ingest_ap2_mandate(&json!({
"paymentMandate": { "payee": "planner.example", "currency": "USD", "maxAmount": 500 }
}))
.unwrap()
}
#[test]
fn usd_mandate_yields_a_money_ceiling_not_a_joule_one() {
let m = usd_mandate();
assert_eq!(m.joule_budget(), None); // no exchange rate invented
assert_eq!(m.funds_ceiling(), Some(Funds::new("USD", 500)));
}
/// The whole point of the dual-ceiling design: a real USD mandate is now
/// gateable. The operator supplies the joule ceiling (the mandate has no
/// authority over energy); the mandate supplies the money ceiling; each
/// binds on its own terms.
#[test]
fn usd_mandate_gates_on_money_while_joules_have_room() {
let card = ingest_agent_card(&sample_card()).unwrap();
let mut profile = A2aProfile::from_mandate_priced(
&card,
&usd_mandate(),
10_000_000, // operator's generous joule ceiling
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:a2a-steward",
)
.unwrap();
// First task: $3.00 of the $5.00 mandate, trivial joules → allowed.
let g1 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
assert!(g1.allowed());
assert_eq!(profile.spent_minor("plan"), 300);
// Second task: another $3.00 would exceed the $5.00 ceiling — denied on
// MONEY, though joules had ample room. The receipt records exactly that.
let g2 = profile.serve_task_priced("plan", b"goal", 1_000, &Price::new("USD", 300));
assert!(!g2.allowed());
let core = jcp_receipt::extract(&g2.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.verdict, "deny");
assert_eq!(core.deny_reason.as_deref(), Some("funds-exceeded"));
// The denied task debited neither dimension.
assert_eq!(profile.spent_minor("plan"), 300);
}
}

View file

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

View file

@ -20,7 +20,7 @@ use std::collections::BTreeMap;
use ed25519_dalek::SigningKey;
use jcp_grant::issue;
use jcp_receipt::seal;
use jcp_runtime::{Decision, Runtime};
use jcp_runtime::{Decision, Price, Runtime};
use jcp_schema::{
Capability, ContentId, Did, Energy, Frame, FrameKind, Grant, SignedGrant, Trust,
};
@ -105,6 +105,12 @@ impl Bridge {
self.runtime.spent(grant_id)
}
/// The enforcing runtime — read access for spend accounting across both
/// budget dimensions.
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
fn gate(&mut self, frame: Frame, sg: &SignedGrant, caller: Did, cost: Energy, now: u64) -> Gated {
let decision = self.runtime.decide(&frame, sg, caller, cost, now);
let receipt = seal(&frame, &sg.grant, &decision, &self.steward_sk, &self.steward)
@ -112,6 +118,42 @@ impl Bridge {
Gated { decision, frame, receipt }
}
/// Gate against **both** ceilings of a dual-ceiling grant (SPEC §4.8) —
/// joules and money, each enforced on its own terms, sealed either way.
fn gate_priced(
&mut self,
frame: Frame,
sg: &SignedGrant,
caller: Did,
cost: Energy,
price: &Price,
now: u64,
) -> Gated {
let decision = self.runtime.decide_priced(&frame, sg, caller, cost, price, now);
let receipt = seal(&frame, &sg.grant, &decision, &self.steward_sk, &self.steward)
.expect("seal decision receipt");
Gated { decision, frame, receipt }
}
/// Gate an outbound A2A task against a joule ceiling **and** a money
/// ceiling — the AP2 path, where a payment mandate bounds the spend and the
/// operator bounds the energy.
#[allow(clippy::too_many_arguments)]
pub fn call_a2a_priced(
&mut self,
sg: &SignedGrant,
caller: Did,
agent: &str,
method: &str,
payload: &[u8],
cost: Energy,
price: &Price,
now: u64,
) -> Gated {
let frame = a2a_task_frame(sg.grant.id(), agent, method, payload);
self.gate_priced(frame, sg, caller, cost, price, now)
}
/// Gate an outbound MCP tool call. Forward to the MCP server only if
/// `allowed()`.
#[allow(clippy::too_many_arguments)]
@ -182,6 +224,8 @@ impl LocalToolGate {
subject: did,
capability: Capability::new(mcp_resource(server, tool), [*tool]),
budget_uj,
// An MCP tool call carries no payment context — joules alone bound it.
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -279,6 +323,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(mcp_resource("fs", "read_file"), ["read_file"]),
budget_uj: 100_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -321,6 +366,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(a2a_resource("planner", "plan"), ["plan"]),
budget_uj: 10_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,

View file

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

View file

@ -184,6 +184,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(r, ["read"]),
budget_uj: 10_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,

View file

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

View file

@ -7,8 +7,8 @@ use ed25519_dalek::SigningKey;
use jcp_grant::issue;
use jcp_grant::standing::{issue_policy, mint_on_trigger, MintLedger, TriggerEvent};
use jcp_grant::checkpoint::{amend, apply_amendment, checkpoint};
use jcp_runtime::Runtime;
use jcp_schema::{Capability, ContentId, Did, Energy, Frame, FrameKind, Grant, Trust};
use jcp_runtime::{Price, Runtime};
use jcp_schema::{Capability, ContentId, Did, Energy, Frame, FrameKind, Funds, Grant, Trust};
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "conformance/v1".to_string());
@ -26,6 +26,7 @@ fn main() {
subject: holder_did,
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -81,6 +82,7 @@ fn main() {
subject: Did::from_verifying_key(&issuer.verifying_key()),
capability: standing_cap,
budget_uj: 1_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -98,7 +100,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_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None,
}).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");
@ -115,6 +117,62 @@ fn main() {
std::fs::write(format!("{out}/standing.json"), serde_json::to_string_pretty(&standing_vectors).unwrap() + "\n")
.expect("write standing");
// ---- dual ceilings (§4.8) vectors ---------------------------------------
// A grant bounded in BOTH joules and money. Fixed keys/nonces ⇒ a
// cross-language verifier reproduces every id, signature, and receipt. The
// three receipts are the three outcomes that define C22: an allow, a deny
// where MONEY bound while joules had room, and a foreign-currency refusal
// (no exchange rate is ever invented).
let dual = Grant {
issuer: Did::from_verifying_key(&issuer.verifying_key()),
subject: holder_did,
capability: Capability::new(resource, ["read"]),
budget_uj: 1_000_000,
budget_funds: Some(Funds::new("USD", 500)),
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [10; 16],
};
let dual_sg = issue(&issuer, dual.clone()).expect("issue dual");
let dual_f = Frame {
kind: FrameKind::ToolCall,
trust: Trust::Control,
grant_id: dual.id(),
action: "read".into(),
resource,
payload_hash: ContentId::of_bytes(b"q"),
};
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);
// $3.00 again would pass the $5.00 ceiling — money binds, joules had room.
let d_funds = drt.decide_priced(&dual_f, &dual_sg, holder_did, Energy::measured(10), &Price::new("USD", 300), 10);
// A cost in another currency is refused, not converted.
let d_cur = drt.decide_priced(&dual_f, &dual_sg, holder_did, Energy::measured(1), &Price::new("EUR", 1), 10);
let dual_receipt = |d: &jcp_runtime::Decision| {
let env = jcp_receipt::seal(&dual_f, &dual, d, &steward_sk, steward).expect("seal");
serde_json::json!({
"verdict": d.verdict.wire_tag(),
"wire": jcp_receipt::to_jcr1_wire(&dual_f, &dual, d, steward),
"envelope_hex": hex::encode(&env),
})
};
let dual_vectors = serde_json::json!({
"grant": {
"id_hex": dual.id().to_hex(),
"issuer": dual.issuer.to_did_key(),
"subject": dual.subject.to_did_key(),
"signature_hex": hex::encode(&dual_sg.signature),
"budget_uj": dual.budget_uj,
"budget_funds": { "currency": "USD", "minor": 500 },
},
"receipts": [dual_receipt(&d_allow), dual_receipt(&d_funds), dual_receipt(&d_cur)],
"spent_after": { "uj": drt.spent(&dual.id()), "minor": drt.spent_minor(&dual.id()) },
});
std::fs::write(format!("{out}/dual_ceilings.json"), serde_json::to_string_pretty(&dual_vectors).unwrap() + "\n")
.expect("write dual_ceilings");
let meta = serde_json::json!({
"profile": "jcp.receipt/1",
"steward": steward,

View file

@ -27,11 +27,11 @@
//! the precise reason; canonical CBOR rejects floats/bools.
use ed25519_dalek::SigningKey;
use jcp_grant::{attenuate, issue, revoke, verify as verify_grant, verify_chain, GrantError};
use jcp_runtime::Runtime;
use jcp_grant::{attenuate, attenuate_priced, 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, Grant, SignedGrant, Trust, Verdict,
FrameKind, Funds, Grant, SignedGrant, Trust, Verdict,
};
/// A single conformance check.
@ -63,6 +63,7 @@ pub fn checks() -> Vec<Check> {
Check { id: "C19 injection resistance (tool-poisoning attack corpus)", run: c19_injection },
Check { id: "C20 standing grant: bounded event-triggered mint", run: c20_standing },
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: "C-neg expired / forged / wrong-caller", run: cneg_denials },
Check { id: "C-neg canonical rejects floats", run: cneg_canonical },
]
@ -81,6 +82,7 @@ fn grant(issuer: &SigningKey, holder: Did, resource: ContentId, actions: &[&str]
subject: holder,
capability: Capability::new(resource, actions.iter().copied()),
budget_uj: budget,
budget_funds: None,
expiry,
parent: None,
delegate_depth: None,
@ -154,6 +156,105 @@ fn c2_attenuation() -> Result<(), String> {
Ok(())
}
/// C22 — dual ceilings (SPEC §4.8): a money ceiling beside the joule ceiling,
/// enforced independently, with no exchange rate ever invented.
fn c22_dual_ceilings() -> Result<(), String> {
let r = ContentId::of_bytes(b"db");
let holder = did(&sk(2));
// A joules-only grant must encode byte-identically to one minted before
// §4.8 existed — the backward-compatibility guarantee the whole design
// rests on. Its id is a pure function of those bytes.
let plain = grant(&sk(1), holder, r, &["read"], 100, 0);
if canonical_cbor(&plain.grant)
.map_err(|e| e.to_string())?
.windows(12)
.any(|w| w == b"budget_funds")
{
return Err("an absent money ceiling must not appear in the encoding".into());
}
// A dual-ceiling grant: 1 000 000 µJ of energy, $5.00 of money.
let mut g = plain.grant.clone();
g.budget_uj = 1_000_000;
g.budget_funds = Some(Funds::new("USD", 500));
let sg = issue(&sk(1), g).map_err(|e| e.to_string())?;
if sg.grant.id() == plain.grant.id() {
return Err("adding a money ceiling must change the grant's identity".into());
}
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
// Both ceilings have room → allow, and BOTH dimensions debit.
if !rt.decide_priced(&f, &sg, holder, Energy::measured(10), &Price::new("USD", 300), 1).is_allow() {
return Err("a call within both ceilings was denied".into());
}
if rt.spent(&sg.grant.id()) != 10 || rt.spent_minor(&sg.grant.id()) != 300 {
return Err("an allow must debit both dimensions".into());
}
// Money binds while joules have room → deny `funds-exceeded`, debit neither.
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(10), &Price::new("USD", 300), 1);
if d.verdict != Verdict::Deny(DenyReason::FundsExceeded) {
return Err(format!("over-money call not denied funds-exceeded: {:?}", d.verdict));
}
if rt.spent(&sg.grant.id()) != 10 || rt.spent_minor(&sg.grant.id()) != 300 {
return Err("a denied call must debit neither dimension".into());
}
// A foreign currency is refused, never converted — no rate is invented.
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(1), &Price::new("EUR", 1), 1);
if d.verdict != Verdict::Deny(DenyReason::CurrencyMismatch) {
return Err(format!("foreign currency not denied currency-mismatch: {:?}", d.verdict));
}
// The mirror: joules bind while money has room.
let tight = issue(
&sk(1),
Grant { budget_uj: 5, budget_funds: Some(Funds::new("USD", 10_000)), ..sg.grant.clone() },
)
.map_err(|e| e.to_string())?;
let tf = frame(&tight, r, "read", Trust::Control);
let d = Runtime::new().decide_priced(&tf, &tight, holder, Energy::measured(500), &Price::new("USD", 1), 1);
if d.verdict != Verdict::Deny(DenyReason::BudgetExceeded) {
return Err(format!("over-joule call not denied budget-exceeded: {:?}", d.verdict));
}
// An absent money ceiling means "joules alone bound" — not a zero ceiling.
if !Runtime::new()
.decide_priced(
&frame(&plain, r, "read", Trust::Control),
&plain,
holder,
Energy::measured(1),
&Price::new("USD", 999_999),
1,
)
.is_allow()
{
return Err("an absent money ceiling must not bind".into());
}
// Attenuation narrows money independently, and never amplifies.
if attenuate_priced(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1, Some(Funds::new("USD", 900)), 0, [1; 16]).is_ok() {
return Err("a raised money ceiling must be refused".into());
}
if attenuate_priced(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1, None, 0, [1; 16]).is_ok() {
return Err("dropping a bounded parent's money ceiling must be refused".into());
}
if attenuate_priced(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1, Some(Funds::new("EUR", 1)), 0, [1; 16]).is_ok() {
return Err("a re-denominated money ceiling must be refused (no rate)".into());
}
// A plain attenuation inherits the ceiling rather than dropping it.
let child = attenuate(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 1, 0, [2; 16])
.map_err(|e| e.to_string())?;
if child.grant.budget_funds != Some(Funds::new("USD", 500)) {
return Err("a plain attenuation must inherit the parent's money ceiling".into());
}
Ok(())
}
fn c3_budget() -> Result<(), String> {
let r = ContentId::of_bytes(b"db");
let holder = did(&sk(2));
@ -344,6 +445,7 @@ fn c10_delegation_control() -> Result<(), String> {
subject: did(&holder),
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: Some(1),

View file

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

View file

@ -230,6 +230,7 @@ pub fn apply_amendment(
subject: grant.grant.subject,
capability: grant.grant.capability.clone(),
budget_uj: sa.amendment.new_budget_uj,
budget_funds: None,
expiry: grant.grant.expiry,
parent: Some(grant.grant.id()),
delegate_depth: grant.grant.delegate_depth,
@ -257,6 +258,7 @@ mod tests {
subject: did(&agent),
capability: cap,
budget_uj: 1_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -302,7 +304,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_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
};
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, Grant, KeyEvent, Revocation, SchemaError,
canonical_cbor, Capability, ContentId, Did, Funds, Grant, KeyEvent, Revocation, SchemaError,
SignedGrant, SignedKeyEvent, SignedRevocation,
};
@ -109,6 +109,12 @@ pub fn attenuate(
subject: to_subject,
capability,
budget_uj,
// 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
// second budget dimension invites. To narrow it, use
// [`attenuate_priced`].
budget_funds: p.budget_funds.clone(),
expiry,
parent: Some(p.id()),
// The child's allowance is the parent's minus one — depth only narrows.
@ -118,6 +124,47 @@ pub fn attenuate(
issue(delegator_sk, child)
}
/// Whether a child's money ceiling is no broader than its parent's.
///
/// The no-exchange-rate rule reaches attenuation too: a child ceiling in a
/// *different* currency cannot be shown to narrow the parent's without a rate,
/// so it is refused rather than converted.
pub fn funds_narrows(parent: &Option<Funds>, child: &Option<Funds>) -> bool {
match (parent, child) {
// Parent unbounded in money → any child ceiling only adds a constraint.
(None, _) => true,
// Parent bounded, child unbounded → amplification.
(Some(_), None) => false,
(Some(p), Some(c)) => p.same_currency(&c.currency) && c.minor <= p.minor,
}
}
/// Attenuate with an explicit **money** ceiling as well as a joule ceiling —
/// the dual-ceiling delegation path (SPEC §4.8).
///
/// Both dimensions must narrow independently. No rate is ever invented: a child
/// ceiling denominated in a different currency than the parent's is refused.
#[allow(clippy::too_many_arguments)]
pub fn attenuate_priced(
parent: &SignedGrant,
delegator_sk: &SigningKey,
to_subject: Did,
capability: Capability,
budget_uj: u64,
budget_funds: Option<Funds>,
expiry: u64,
nonce: [u8; 16],
) -> Result<SignedGrant, GrantError> {
if !funds_narrows(&parent.grant.budget_funds, &budget_funds) {
return Err(GrantError::Amplifies("money ceiling exceeds parent"));
}
let mut sg = attenuate(parent, delegator_sk, to_subject, capability, budget_uj, expiry, nonce)?;
// `attenuate` inherited the parent's ceiling; replace it with the narrowed
// one and re-sign, so the signature covers the ceiling actually granted.
sg.grant.budget_funds = budget_funds;
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
@ -406,6 +453,7 @@ mod tests {
subject,
capability: Capability::new(ContentId::of_bytes(b"db"), ["read", "write"]),
budget_uj: 10_000,
budget_funds: None,
expiry: 1000,
parent: None,
delegate_depth: None,
@ -414,6 +462,69 @@ mod tests {
issue(issuer, g).unwrap()
}
fn priced_root(issuer: &SigningKey, subject: Did, minor: u64) -> SignedGrant {
let mut g = root(issuer, subject).grant;
g.budget_funds = Some(Funds::new("USD", minor));
issue(issuer, g).unwrap()
}
/// A money ceiling MUST be inherited by a plain `attenuate`, never silently
/// dropped — dropping it would hand the child unbounded spend where the
/// parent was bounded (an amplification).
#[test]
fn attenuate_inherits_the_money_ceiling() {
let issuer = sk(1);
let holder = sk(2);
let parent = priced_root(&issuer, did(&holder), 500);
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.budget_funds, Some(Funds::new("USD", 500)));
}
#[test]
fn attenuate_priced_narrows_and_refuses_amplification() {
let issuer = sk(1);
let holder = sk(2);
let r = ContentId::of_bytes(b"db");
let parent = priced_root(&issuer, did(&holder), 500);
// Narrowing the money ceiling ($5.00 → $2.00) is fine.
let child = attenuate_priced(
&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, Some(Funds::new("USD", 200)), 500, [2; 16],
)
.unwrap();
assert_eq!(child.grant.budget_funds, Some(Funds::new("USD", 200)));
assert!(verify(&child).is_ok());
// A larger money ceiling amplifies.
assert!(matches!(
attenuate_priced(&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, Some(Funds::new("USD", 900)), 500, [2; 16]),
Err(GrantError::Amplifies(_))
));
// Dropping the ceiling (→ unbounded money) amplifies.
assert!(matches!(
attenuate_priced(&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, None, 500, [2; 16]),
Err(GrantError::Amplifies(_))
));
// A different currency cannot be shown to narrow without a rate → refused.
assert!(matches!(
attenuate_priced(&parent, &holder, did(&sk(3)), Capability::new(r, ["read"]),
5_000, Some(Funds::new("EUR", 100)), 500, [2; 16]),
Err(GrantError::Amplifies(_))
));
}
#[test]
fn issue_then_verify() {
let issuer = sk(1);
@ -428,6 +539,7 @@ mod tests {
subject: did(&sk(2)),
capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]),
budget_uj: 1,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -574,6 +686,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: Some(1),
@ -596,6 +709,7 @@ mod tests {
subject: did(&sk(4)),
capability: Capability::new(r, ["read"]),
budget_uj: 100,
budget_funds: None,
expiry: 0,
parent: Some(child.grant.id()),
delegate_depth: None,

View file

@ -212,6 +212,7 @@ mod tests {
subject: did(&issuer), // issuer holds it and fires it
capability: cap.clone(),
budget_uj: 1_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -296,7 +297,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_uj: 250, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
};
let sg = crate::issue(&issuer, grant).unwrap();
let policy = issue_policy(
@ -319,7 +320,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_uj: 100, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None,
};
let sg = crate::issue(&issuer, grant).unwrap();
// per-fire budget over the grant's ceiling is refused at author time.

View file

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

View file

@ -410,6 +410,7 @@ mod tests {
subject: Did::from_verifying_key(&holder.verifying_key()),
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -450,6 +451,7 @@ mod tests {
subject: Did::from_verifying_key(&holder.verifying_key()),
capability: Capability::new(resource, ["read"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -30,10 +30,35 @@ impl Decision {
}
}
/// A price for one action, in a currency's minor units (SPEC §4.8).
///
/// The money dual of [`Energy`] — with one deliberate difference: energy carries
/// a [`Provenance`] because a joule figure can be *measured*, whereas a price is
/// always *asserted* by whoever quotes it. So there is no provenance ladder here
/// and none is implied.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Price {
pub currency: String,
pub minor: u64,
}
impl Price {
pub fn new(currency: impl Into<String>, minor: u64) -> Self {
Price { currency: currency.into(), minor }
}
pub fn free(currency: impl Into<String>) -> Self {
Price { currency: currency.into(), minor: 0 }
}
}
/// A stateful enforcer tracking per-grant joule spend across a session.
#[derive(Clone, Debug, Default)]
pub struct Runtime {
spent_uj: HashMap<ContentId, u64>,
/// Money spent per grant, in the grant ceiling's minor units. Tracked
/// separately from joules — the two dimensions never convert into one
/// another.
spent_minor: HashMap<ContentId, u64>,
revocations: RevocationSet,
/// Configured trust roots (SPEC §7): resource → the root-issuer keys this
/// enforcement point honors for it. Empty for a resource = not configured
@ -52,6 +77,80 @@ impl Runtime {
*self.spent_uj.get(grant_id).unwrap_or(&0)
}
/// Money already spent against a grant, in its ceiling's minor units.
pub fn spent_minor(&self, grant_id: &ContentId) -> u64 {
*self.spent_minor.get(grant_id).unwrap_or(&0)
}
/// Decide a frame against **both** ceilings of a dual-ceiling grant
/// (SPEC §4.8): the joule budget and the money budget, enforced
/// independently, denying on **whichever binds first**.
///
/// This is the AP2/OpenPay path: a real-world payment mandate authorizes
/// spend in a currency, while JCP meters the work in joules. The two are
/// never reconciled by an invented exchange rate — they are simply both
/// enforced, and the receipt records which one bound. A denial for
/// `BudgetExceeded` while the money ceiling still had room is a statement
/// no single-dimension authorization can make.
///
/// Debits both dimensions on allow, neither on deny. A grant with no money
/// ceiling (`budget_funds: None`) ignores `price` entirely and behaves
/// exactly like [`decide`](Self::decide).
pub fn decide_priced(
&mut self,
frame: &Frame,
sg: &SignedGrant,
caller: Did,
cost: Energy,
price: &Price,
now: u64,
) -> Decision {
let g = &sg.grant;
let gid = g.id();
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
let deny = |r: DenyReason| Decision {
verdict: Verdict::Deny(r),
cost,
budget_remaining_uj: remaining,
};
// Validity checks 1–6, in the same fixed order as `decide`.
if let Some(r) = self.check(frame, sg, caller, now) {
return deny(r);
}
// 7. The JOULE ceiling. Checked first so that when both would bind, the
// receipt names energy — JCP's primary dimension.
let spent = self.spent(&gid);
let c = cost.uj();
if spent.saturating_add(c) > g.budget_uj {
return deny(DenyReason::BudgetExceeded);
}
// 8. The MONEY ceiling, independently. Absent = joules alone bound.
if let Some(funds) = &g.budget_funds {
if !funds.same_currency(&price.currency) {
// No rate is invented, so a foreign-currency cost is refused.
return deny(DenyReason::CurrencyMismatch);
}
let spent_m = self.spent_minor(&gid);
if spent_m.saturating_add(price.minor) > funds.minor {
return deny(DenyReason::FundsExceeded);
}
// Allow — debit money too. Only reached when BOTH ceilings pass.
self.spent_minor.insert(gid, spent_m + price.minor);
}
// Allow — debit the energy.
let new_spent = spent + c;
self.spent_uj.insert(gid, new_spent);
Decision {
verdict: Verdict::Allow,
cost,
budget_remaining_uj: g.budget_uj - new_spent,
}
}
/// Rebuild enforcement state from a replayed spend ledger (SPEC §8.1: the
/// receipt stream is sufficient to reconstruct enforcement state). Each
/// entry adds to the grant's recorded spend — feed it the allow-receipt
@ -329,6 +428,7 @@ impl Runtime {
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use jcp_schema::Funds;
use jcp_grant::issue;
use jcp_schema::{Capability, FrameKind, Grant};
@ -348,6 +448,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(resource, ["read"]),
budget_uj: 100,
budget_funds: None,
expiry: 1000,
parent: None,
delegate_depth: None,
@ -424,6 +525,98 @@ mod tests {
assert_eq!(rt.spent(&sg.grant.id()), 90); // the denied call was not debited
}
fn priced_setup(budget_uj: u64, minor: u64) -> (SignedGrant, Did, ContentId) {
let issuer = sk(1);
let holder = sk(2);
let resource = ContentId::of_bytes(b"db");
let g = Grant {
issuer: did(&issuer),
subject: did(&holder),
capability: Capability::new(resource, ["read"]),
budget_uj,
budget_funds: Some(Funds::new("USD", minor)),
expiry: 1000,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
(issue(&issuer, g).unwrap(), did(&holder), resource)
}
/// THE receipt only a dual-ceiling grant can produce: denied on ENERGY while
/// the money ceiling still had room. A single-dimension authorization cannot
/// make this statement.
#[test]
fn joule_ceiling_binds_while_money_has_room() {
let (sg, holder, r) = priced_setup(100, 10_000); // tiny joules, huge money
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(500), &Price::new("USD", 1), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
// Neither dimension was debited on deny.
assert_eq!(rt.spent(&sg.grant.id()), 0);
assert_eq!(rt.spent_minor(&sg.grant.id()), 0);
}
/// The mirror: money binds while joules had room.
#[test]
fn money_ceiling_binds_while_joules_have_room() {
let (sg, holder, r) = priced_setup(1_000_000, 50); // huge joules, tiny money
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(10), &Price::new("USD", 500), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::FundsExceeded));
// A money denial must not silently debit energy.
assert_eq!(rt.spent(&sg.grant.id()), 0);
assert_eq!(rt.spent_minor(&sg.grant.id()), 0);
}
#[test]
fn both_ceilings_debit_independently_on_allow() {
let (sg, holder, r) = priced_setup(1_000, 500);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
assert!(rt
.decide_priced(&f, &sg, holder, Energy::measured(400), &Price::new("USD", 200), 10)
.is_allow());
assert_eq!(rt.spent(&sg.grant.id()), 400);
assert_eq!(rt.spent_minor(&sg.grant.id()), 200);
// Second call: joules fit (800≤1000) and money fits (400≤500).
assert!(rt
.decide_priced(&f, &sg, holder, Energy::measured(400), &Price::new("USD", 200), 10)
.is_allow());
assert_eq!(rt.spent(&sg.grant.id()), 800);
assert_eq!(rt.spent_minor(&sg.grant.id()), 400);
// Third: joules would fit (1200>1000 → no). Energy binds first.
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(400), &Price::new("USD", 50), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
}
/// No exchange rate is ever invented — a foreign-currency cost is refused,
/// not converted.
#[test]
fn foreign_currency_is_denied_not_converted() {
let (sg, holder, r) = priced_setup(1_000_000, 10_000);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(1), &Price::new("EUR", 1), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CurrencyMismatch));
assert_eq!(rt.spent(&sg.grant.id()), 0);
}
/// A grant with no money ceiling must behave exactly like `decide` — the
/// price is ignored entirely, not treated as zero-budget.
#[test]
fn no_money_ceiling_ignores_price() {
let (sg, holder, r) = setup(); // budget_funds: None, budget_uj 100
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let d = rt.decide_priced(&f, &sg, holder, Energy::measured(30), &Price::new("USD", 999_999), 10);
assert!(d.is_allow(), "an absent money ceiling must not bind");
assert_eq!(rt.spent(&sg.grant.id()), 30);
assert_eq!(rt.spent_minor(&sg.grant.id()), 0); // nothing tracked
}
#[test]
fn expired_grant_denied() {
let (sg, holder, r) = setup(); // expiry 1000
@ -470,6 +663,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(r, ["read"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -192,6 +192,35 @@ pub fn child_depth_bound(parent: Option<u64>) -> Option<u64> {
parent.map(|d| d.saturating_sub(1))
}
/// A **money ceiling** — an amount in a currency's minor units (integer cents,
/// never a float: the canonical encoding forbids floats, and money is typed).
///
/// This is the second, independent dimension of a dual-ceiling grant. It is
/// deliberately *not* convertible to joules: JCP invents no exchange rate. A
/// grant carrying both ceilings enforces each on its own terms and denies on
/// whichever binds first — so a receipt can say "denied: joule ceiling, while
/// the money ceiling had room", which is a statement no single-dimension
/// authorization can make.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Funds {
/// The currency code the ceiling is denominated in (e.g. `USD`). Compared
/// case-insensitively; a cost in a different currency is never debited
/// against this ceiling.
pub currency: String,
/// The ceiling, in the currency's minor units (e.g. cents).
pub minor: u64,
}
impl Funds {
pub fn new(currency: impl Into<String>, minor: u64) -> Self {
Funds { currency: currency.into(), minor }
}
/// Whether two ceilings/costs are denominated in the same currency.
pub fn same_currency(&self, other: &str) -> bool {
self.currency.eq_ignore_ascii_case(other)
}
}
/// 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
@ -203,6 +232,15 @@ pub struct Grant {
pub capability: Capability,
/// The joule ceiling this grant may ever spend.
pub budget_uj: u64,
/// An **optional second ceiling, denominated in money** (SPEC §4.8).
///
/// Absent = this grant is bounded by joules alone, and it serializes
/// byte-identically to a grant minted before dual ceilings existed — so its
/// content id, its signature, and every published conformance vector are
/// unchanged. Present = both ceilings are enforced independently and the
/// first to bind denies.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub budget_funds: Option<Funds>,
/// Unix seconds after which the grant is invalid; `0` means no expiry.
pub expiry: u64,
/// The parent grant this was attenuated from, if any.
@ -338,6 +376,15 @@ pub enum DenyReason {
ActionNotGranted,
Expired,
BudgetExceeded,
/// The action's money cost exceeds the grant's **money** ceiling — the
/// second dimension of a dual-ceiling grant (SPEC §4.8). Distinct from
/// `BudgetExceeded` (joules) on purpose: which ceiling bound is exactly
/// the fact the receipt exists to record.
FundsExceeded,
/// 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.
CurrencyMismatch,
/// A `Data`-tagged frame tried to exercise a capability — control from
/// untrusted output is refused (anti-tool-poisoning).
UntrustedControl,
@ -364,6 +411,8 @@ impl DenyReason {
DenyReason::ActionNotGranted => "action-not-granted",
DenyReason::Expired => "expired",
DenyReason::BudgetExceeded => "budget-exceeded",
DenyReason::FundsExceeded => "funds-exceeded",
DenyReason::CurrencyMismatch => "currency-mismatch",
DenyReason::UntrustedControl => "untrusted-control",
DenyReason::AttenuationViolation => "attenuation-violation",
DenyReason::Revoked => "revoked",
@ -420,6 +469,7 @@ mod tests {
subject: did(2),
capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
@ -451,4 +501,54 @@ mod tests {
let bad = Energy { measured_uj: None, estimated_uj: 1, provenance: Provenance::HwShunt };
assert!(bad.check_provenance().is_err());
}
fn grant_without_funds() -> Grant {
let r = ContentId::of_bytes(b"db");
Grant {
issuer: Did::from_verifying_key(&ed25519_dalek::SigningKey::from_bytes(&[1; 32]).verifying_key()),
subject: Did::from_verifying_key(&ed25519_dalek::SigningKey::from_bytes(&[2; 32]).verifying_key()),
capability: Capability::new(r, ["read"]),
budget_uj: 100,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [0; 16],
}
}
/// THE backward-compatibility guarantee the dual-ceiling design rests on:
/// a grant with no money ceiling must encode to the exact bytes it did
/// before the field existed, so its content id — and therefore every
/// signature and every published conformance vector — is unchanged.
#[test]
fn absent_money_ceiling_does_not_change_the_encoding() {
let g = grant_without_funds();
let bytes = crate::cbor::canonical_cbor(&g).expect("canonical");
// The field must be entirely absent from the encoding, not present-as-empty.
let hay = String::from_utf8_lossy(&bytes);
assert!(!hay.contains("budget_funds"), "absent ceiling must not appear in the encoding");
// And the id is a pure function of those bytes.
assert_eq!(g.id(), ContentId::of(&g).unwrap());
}
/// Adding the ceiling MUST change identity — otherwise two grants with
/// different authority would share a content id.
#[test]
fn present_money_ceiling_changes_identity() {
let plain = grant_without_funds();
let mut priced = grant_without_funds();
priced.budget_funds = Some(Funds::new("USD", 500));
assert_ne!(plain.id(), priced.id());
let bytes = crate::cbor::canonical_cbor(&priced).expect("canonical");
assert!(String::from_utf8_lossy(&bytes).contains("budget_funds"));
}
#[test]
fn funds_currency_compare_is_case_insensitive() {
let f = Funds::new("USD", 500);
assert!(f.same_currency("usd"));
assert!(f.same_currency("USD"));
assert!(!f.same_currency("EUR"));
}
}

View file

@ -262,6 +262,7 @@ mod tests {
subject: team.group_did(),
capability: Capability::new(r, ["delete"]),
budget_uj: 1000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,

View file

@ -305,6 +305,7 @@ mod tests {
subject: did(&holder),
capability: Capability::new(r, ["read"]),
budget_uj: 100_000,
budget_funds: None,
expiry: 0,
parent: None,
delegate_depth: None,