jcp: dual ceilings — a money budget beside the joule budget (SPEC §4.8 + C22) #19
20 changed files with 929 additions and 22 deletions
44
jcp/SPEC.md
44
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.
|
||||
|
|
|
|||
81
jcp/conformance/v1/dual_ceilings.json
Normal file
81
jcp/conformance/v1/dual_ceilings.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue