jcp: a chain is bounded in money on every link, and a delegated grant is never decided alone #304

Merged
dcharlot merged 1 commit from fix/jcp-chained-money into main 2026-10-17 17:03:10 -04:00
3 changed files with 149 additions and 5 deletions

View file

@ -152,12 +152,20 @@ structurally invalid chain is denied `chain-broken`; an amplifying one
`attenuation-violation`. The rules `attenuate` holds when it mints are held
again here, because a chain arrives signed by whoever minted it.
A delegated grant (one with a `parent`) MUST NOT be decided on its own. Alone,
nothing shows that it narrows what its issuer held, so its budget, caveats and
expiry would be taken at their word, as a root's are. A single-grant decision
over a delegated grant is denied `chain-broken`.
**Caveats bind on every link.** A chained decision MUST evaluate the caveats of
every grant in the chain, exactly as a single-grant decision evaluates its own
(§4.9); a chain is never a way past a condition its root imposed. In the
two-phase metered path a chain is authorized without debit, cost-dependent
caveats waiting, and then charged with the measured cost, which is debited from
every link.
every link. A priced chained decision applies both ceilings of §4.8 to **every**
link: the joule ceilings first, then the money ceiling of each link that has
one. An allow debits both dimensions from every link, so siblings share their
parent's money as they share its joules; a deny debits neither.
**Energy conservation across siblings.** The cost of an allowed action MUST fit
the remaining budget of **every** grant in the chain and MUST be debited from
@ -1251,4 +1259,4 @@ gate.
- **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one.
- **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; `ModelIn` admits only a frame naming one of its models, and denies a frame naming another or none; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed.
- **C25 — flow:** a value's propagated label must sit at or below the grant's ceiling and clear the resource's taint floor; an argument whose provenance was **not computed** fails closed against a ceilinged grant rather than passing as clean; several ceilings in a chain compose to the strictest and attenuation never widens one; a flow denial debits nothing, so a tainted call costs zero joules; and a grant with no flow caveat encodes byte-identically to one minted before flow existed.
- **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it.
- **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. A priced chained decision applies the parent's money ceiling to its child's spend, and a delegated grant decided without its chain is denied `chain-broken`.

View file

@ -256,6 +256,32 @@ fn c22_dual_ceilings() -> Result<(), String> {
if child.grant.budget_funds != Some(Funds::new("USD", 500)) {
return Err("a plain attenuation must inherit the parent's money ceiling".into());
}
// A chain is bounded in money on every link (§4.2): the parent's ceiling
// binds its child's spend, and a deny debits neither dimension.
let heir = attenuate(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 50, 0, [4; 16])
.map_err(|e| e.to_string())?;
let chain = [sg.clone(), heir.clone()];
let mut crt = Runtime::new();
let fc = frame(&heir, r, "read", Trust::Control);
let sub = did(&sk(3));
if !crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("USD", 300), 1).is_allow() {
return Err("a chained priced call within both ceilings was denied".into());
}
let d = crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("USD", 300), 1);
if d.verdict != Verdict::Deny(DenyReason::FundsExceeded) || crt.spent(&sg.grant.id()) != 1 {
return Err("a chained call past the parent's money ceiling was not refused cleanly".into());
}
let d = crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("EUR", 1), 1);
if d.verdict != Verdict::Deny(DenyReason::CurrencyMismatch) {
return Err("a chained call in another currency was converted rather than refused".into());
}
// A delegated grant is decided only with its chain: alone, its money
// ceiling is not shown to narrow its parent's.
let d = crt.decide_priced(&fc, &heir, sub, Energy::measured(1), &Price::new("USD", 1), 1);
if d.verdict != Verdict::Deny(DenyReason::ChainBroken) {
return Err("a delegated grant was decided without its chain".into());
}
Ok(())
}

View file

@ -476,6 +476,12 @@ impl Runtime {
if verify(sg).is_err() {
return Some(DenyReason::BadSignature);
}
// 4b. A delegated grant is decided only with its chain (§4.2). Alone,
// nothing shows that it narrows what its issuer held: its budget,
// caveats and expiry would be taken at their word, as a root's are.
if g.parent.is_some() {
return Some(DenyReason::ChainBroken);
}
// 5. Not expired.
if g.is_expired(now) {
return Some(DenyReason::Expired);
@ -687,7 +693,25 @@ impl Runtime {
) -> Decision {
// No flow facts: a chain whose links carry a flow ceiling fails closed
// (the un-migrated path), exactly as `decide` does with `check(.., None)`.
self.decide_chained_inner(frame, chain, caller, cost, now, None)
self.decide_chained_inner(frame, chain, caller, cost, now, None, None)
}
/// [`decide_priced`](Self::decide_priced) over a delegation chain: both
/// ceilings on **every** link (§4.8, §4.2). The joule ceiling is checked
/// first on every link, then the money ceiling of every link that has
/// one, refusing a cost in another currency. On allow both dimensions are
/// debited from every link, so siblings share their parent's money as they
/// share its joules; on deny neither is debited.
pub fn decide_chained_priced(
&mut self,
frame: &Frame,
chain: &[SignedGrant],
caller: Did,
cost: Energy,
price: &Price,
now: u64,
) -> Decision {
self.decide_chained_inner(frame, chain, caller, cost, now, None, Some(price))
}
/// The flow-aware [`decide_chained`](Self::decide_chained): the chain path with
@ -702,9 +726,10 @@ impl Runtime {
now: u64,
facts: &FlowFacts,
) -> Decision {
self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts))
self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts), None)
}
#[allow(clippy::too_many_arguments)]
fn decide_chained_inner(
&mut self,
frame: &Frame,
@ -713,6 +738,7 @@ impl Runtime {
cost: Energy,
now: u64,
facts: Option<&FlowFacts>,
price: Option<&Price>,
) -> Decision {
let leaf_remaining = chain
.last()
@ -734,11 +760,31 @@ impl Runtime {
return deny(DenyReason::BudgetExceeded);
}
}
// Allow — debit every link, so sibling delegations share their root.
// The money ceiling of every link that has one (§4.8), checked after
// joules so that when both bind the receipt names energy. A link with
// no money ceiling is bounded by joules alone.
if let Some(price) = price {
for sg in chain {
if let Some(funds) = &sg.grant.budget_funds {
if !funds.same_currency(&price.currency) {
return deny(DenyReason::CurrencyMismatch);
}
if self.spent_minor(&sg.grant.id()).saturating_add(price.minor) > funds.minor {
return deny(DenyReason::FundsExceeded);
}
}
}
}
// Allow — debit every link, so sibling delegations share their root,
// in money as in joules.
for sg in chain {
let gid = sg.grant.id();
let s = self.spent(&gid) + c;
self.spent_uj.insert(gid, s);
if let (Some(price), Some(_)) = (price, &sg.grant.budget_funds) {
let m = self.spent_minor(&gid) + price.minor;
self.spent_minor.insert(gid, m);
}
}
// A tainted chained call also depletes each link's tainted sub-budget.
if facts.map(|f| !f.is_bottom()).unwrap_or(false) {
@ -920,6 +966,70 @@ mod tests {
issue(&sk(1), g).unwrap()
}
/// A delegated grant is decided only with its chain (§4.2). Alone, its
/// budget, caveats and expiry would be taken at their word, as a root's
/// are, and nothing would show that they narrow what its issuer held.
#[test]
fn a_delegated_grant_alone_is_refused() {
let r = ContentId::of_bytes(b"db");
let root = chain_root(1_000, Vec::new());
let child = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500, 0, [2; 16]).unwrap();
let mut rt = Runtime::new();
let f = frame(&child, r, "read", Trust::Control);
let broken = Verdict::Deny(DenyReason::ChainBroken);
assert_eq!(rt.decide(&f, &child, did(&sk(3)), Energy::measured(1), 10).verdict, broken);
assert_eq!(rt.authorize(&f, &child, did(&sk(3)), 10).verdict, broken);
assert_eq!(rt.decide_priced(&f, &child, did(&sk(3)), Energy::measured(1), &Price::new("USD", 1), 10).verdict, broken);
assert!(rt.decide_chained(&f, &[root, child], did(&sk(3)), Energy::measured(1), 10).is_allow());
}
/// Money binds on every link of a chain (§4.8): siblings share their
/// parent's money as they share its joules, a deny debits neither
/// dimension, a foreign currency is refused, and joules are checked first.
#[test]
fn a_chain_is_bounded_in_money_on_every_link() {
let r = ContentId::of_bytes(b"db");
let mut g = chain_root(1_000_000, Vec::new()).grant;
g.budget_funds = Some(Funds::new("USD", 1_000));
let root = issue(&sk(1), g).unwrap();
let usd = |m| Some(Funds::new("USD", m));
let a = jcp_grant::attenuate_priced(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500_000, usd(600), 0, [2; 16]).unwrap();
let b = jcp_grant::attenuate_priced(&root, &sk(2), did(&sk(4)), Capability::new(r, ["read"]), 500_000, usd(600), 0, [3; 16]).unwrap();
let (ca, cb) = ([root.clone(), a.clone()], [root.clone(), b.clone()]);
let mut rt = Runtime::new();
let (fa, fb) = (frame(&a, r, "read", Trust::Control), frame(&b, r, "read", Trust::Control));
let usd_cost = |m| Price::new("USD", m);
assert!(rt.decide_chained_priced(&fa, &ca, did(&sk(3)), Energy::measured(10), &usd_cost(500), 10).is_allow());
assert_eq!((rt.spent_minor(&root.grant.id()), rt.spent_minor(&a.grant.id())), (500, 500));
let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &usd_cost(600), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::FundsExceeded), "the parent has 500 left, though b has 600");
assert_eq!((rt.spent_minor(&b.grant.id()), rt.spent(&b.grant.id()), rt.spent(&root.grant.id())), (0, 0, 10));
let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &Price::new("EUR", 1), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CurrencyMismatch));
let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(600_000), &usd_cost(1), 10);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::BudgetExceeded), "joules bind first");
assert!(rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &usd_cost(500), 10).is_allow());
assert_eq!(rt.spent_minor(&root.grant.id()), 1_000);
}
/// A chain with no money ceiling is bounded by joules alone, whatever the
/// price or its currency, as a single grant is.
#[test]
fn a_chain_with_no_money_ceiling_ignores_the_price() {
let r = ContentId::of_bytes(b"db");
let root = chain_root(1_000, Vec::new());
let child = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500, 0, [2; 16]).unwrap();
let mut rt = Runtime::new();
let f = frame(&child, r, "read", Trust::Control);
let chain = [root, child];
assert!(rt.decide_chained_priced(&f, &chain, did(&sk(3)), Energy::measured(1), &Price::new("JPY", 1 << 40), 10).is_allow());
assert_eq!(rt.spent_minor(&chain[0].grant.id()), 0);
}
/// A chained decision enforces caveats. It used to evaluate none, so a
/// delegated one-shot grant could be exercised with any payload, past its
/// window, or above its per-call cap.