diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 4225b6b34..62713ec16 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -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. diff --git a/jcp/conformance/v1/dual_ceilings.json b/jcp/conformance/v1/dual_ceilings.json new file mode 100644 index 000000000..10b2f1ecd --- /dev/null +++ b/jcp/conformance/v1/dual_ceilings.json @@ -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 + } +} diff --git a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs index 1b26bdb1f..c612a129a 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs @@ -18,16 +18,22 @@ //! resource is `a2a::`, 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 { + 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, + ) -> Result { + 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, + issuer_sk: &SigningKey, + subject: Did, + steward_sk: SigningKey, + steward: impl Into, ) -> Result { 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, + ) -> Result { + // 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); + } } diff --git a/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs b/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs index e629cdc3c..c64a2a5af 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/examples/mint_token.rs @@ -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), diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index 3854f0e1d..da8f6eea5 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 403b2f3b2..fbaf8bca1 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -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), diff --git a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs index 61e576f27..dabd92cbb 100644 --- a/jcp/jcp-rs/crates/jcp-channel/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-channel/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-cli/src/main.rs b/jcp/jcp-rs/crates/jcp-cli/src/main.rs index e16ded5da..a43ff097f 100644 --- a/jcp/jcp-rs/crates/jcp-cli/src/main.rs +++ b/jcp/jcp-rs/crates/jcp-cli/src/main.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs index 8c8bac786..a62d22f24 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 97aa4baf2..152014350 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -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 { 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), diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 027fe41e8..1920c23c4 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs index 9898df071..fc540cd81 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs @@ -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(); diff --git a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs index b07ab1e2d..0cd37cf1f 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs @@ -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, child: &Option) -> 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, + expiry: u64, + nonce: [u8; 16], +) -> Result { + 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, diff --git a/jcp/jcp-rs/crates/jcp-grant/src/standing.rs b/jcp/jcp-rs/crates/jcp-grant/src/standing.rs index 7440ec872..b5e28f65c 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/standing.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/standing.rs @@ -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. diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs index 696417201..ec6fb401b 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 64255d935..9f6128543 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index 79ced60b1..81629633c 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -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, minor: u64) -> Self { + Price { currency: currency.into(), minor } + } + pub fn free(currency: impl Into) -> 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, + /// 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, 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, diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index a1baf1ec0..11adc0bb1 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -192,6 +192,35 @@ pub fn child_depth_bound(parent: Option) -> Option { 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, 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, /// 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")); + } } diff --git a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs index 47a4d3f86..6ce6475b4 100644 --- a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs @@ -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, diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index 11c3bff0f..220d52c53 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -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,