jcp: WithinWindow, a sliding-window caveat over calls, payloads and measured joules #388

Merged
dcharlot merged 1 commit from feat/jcp-window-caveat into main 2026-10-21 10:20:15 -04:00
7 changed files with 658 additions and 20 deletions

View file

@ -387,6 +387,8 @@ NotBefore(uint) ; unix seconds
NotAfter(uint) ; unix seconds — a window narrower than `expiry`
MaxCostUj(uint) ; a ceiling on ONE invocation, distinct from the cumulative budget
ModelIn([content-id]) ; the frame must name its model (§6), and it must be one of these
WithinWindow({secs: uint, limit: MaxUj(uint) | MaxCalls(uint) | MaxDistinctPayloads(uint)})
; a sliding window over the grant's recent use (§4.12)
```
Every predicate ranges only over what an enforcement point holds when it decides:
@ -574,6 +576,58 @@ naming the party that held it, and let an operator or that party settle it:
retrying risks performing paid work twice, discarding risks losing it, and
choosing between those without asking is a decision the record does not support.
### 4.12 Windows: a bound on rate, not only on total (OPTIONAL)
A grant's budget bounds what it may spend over its whole life. It does not bound
how fast: a grant with a day's budget can spend it in a minute. `WithinWindow`
bounds a grant's use over any `secs` seconds. A call admitted at `t` is inside
the window decided at `now` while `now − t < secs`. The window bounds one of:
- `MaxUj(n)`: the joules metered for the calls inside the window;
- `MaxCalls(n)`: the calls admitted inside it;
- `MaxDistinctPayloads(n)`: the distinct `payload_hash` values among those calls,
that is, how many different things the grant is used on, not how often.
A window predicates over the clock, the frame's `payload_hash` and the metered
cost, as §4.9 requires, plus the grant's own recent history at this enforcement
point. That history is state the enforcement point keeps. A window therefore
cannot be decided from the frame alone. Like a flow ceiling, it is decided by a
gate in the runtime, after the other caveats and before the budget.
- **What counts.** A call counts once it is admitted: allowed in the one-shot path,
or authorised in the two-phase path. Its joules count from the debit, at the time
the call was admitted. A denied call counts for nothing, and is debited nothing.
- **Over a chain**, every link's windows count the call. A window on a parent
therefore bounds what all its delegates do together, as its budget does. A child
inherits its parent's windows and MAY add tighter ones (§4.9).
- **When it binds.** A call that would exceed a window is denied `window-exceeded`.
- In the one-shot path, a joule window admits a call only if its cost fits.
- In the two-phase path the cost is not yet known. A joule window admits a call
while any of the window is left, and binds at the debit, as `MaxCostUj` does.
An overrun is denied, its joules stay debited, and the window refuses the
next call until enough has slid out.
- **A debit with no time.** In the two-phase path, a debit that arrives without
the time of its call MUST be placed no earlier than the grant's most recent
admission. A debit with no admission at all MUST stay inside every window.
Placing a debit too early would let it leave a window before it should.
- **A window of zero seconds** bounds nothing, and MUST be refused
(`caveat-unsatisfied`) rather than read as no limit.
**Durability.** A window's history is spend over time, so §4.11 applies to it.
An enforcement point that rebuilds its state by replaying its decisions through
the gate, each at the time it was decided, rebuilds its windows with it. One that
rebuilds only cumulative spend, from receipts or a ledger that carries no times,
**cannot know what its windows held**. It MUST treat every window as full until
its history is complete again: until it can name a time from which it has decided
every call, and a whole window has passed since. An unknown history is not an
empty one.
**Sharding** (§4.5). A window is enforced where its grant's history is kept. A
grant whose history is split across enforcement points that do not share it is
bounded at each point separately, so `n` points admit up to `n` times the limit.
A deployment that needs one bound across points MUST route the grant's calls
through one of them, or share its history between them.
## 5. Energy (REQUIRED)
Every metered cost is integer microjoules with a provenance tag: `HwShunt`
@ -1401,3 +1455,4 @@ gate.
- **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. 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`.
- **C26 — one writer per record:** a durable decision record admits one writer. A second holder of the same record is refused rather than opening it, because two would each replay it, each re-establish the same remaining budget and each debit against it — spending it twice while every entry still verifies (§5). The exclusive claim is released when the holder goes away, so a restart can reopen the record it previously held, and a platform that cannot provide exclusivity fails the open rather than proceeding.
- **C27 — windows:** a `WithinWindow` caveat bounds a grant's use over any window of its length (§4.12). A call window admits its limit and refuses the next, `window-exceeded`, without debiting it, and admits again once the earliest call has slid out. A distinct-payload window admits a payload already inside the window and refuses a new one beyond the limit. A joule window refuses a one-shot call whose cost would overfill it, and in the two-phase path binds at the debit, where an overrun is denied and stays debited. A parent's window counts its delegates' calls. A window survives a restart replayed through the gate. A runtime restored from a timeless ledger counts every window as full until its history is complete. A zero-second window is refused.

View file

@ -205,6 +205,7 @@ fn project(chain: &[SignedGrant], issuer_sk: &SigningKey, issued_at: u64, ttl_se
jcp_schema::Caveat::MaxCostUj(_) => return Err(OAuthError::Unprojectable("MaxCostUj caveat")),
jcp_schema::Caveat::ModelIn(_) => return Err(OAuthError::Unprojectable("ModelIn caveat")),
jcp_schema::Caveat::FlowBelow(_) => return Err(OAuthError::Unprojectable("flow ceiling")),
jcp_schema::Caveat::WithinWindow(_) => return Err(OAuthError::Unprojectable("window caveat")),
}
}
}
@ -547,7 +548,8 @@ mod tests {
use jcp_schema::{Caveat, Funds};
let sk = SigningKey::from_bytes(&[9u8; 32]);
let other = Did::from_verifying_key(&SigningKey::from_bytes(&[5u8; 32]).verifying_key());
for c in [Caveat::PayloadIs(ContentId::of_bytes(b"args")), Caveat::MaxCostUj(10), Caveat::ModelIn(vec![ContentId::of_bytes(b"m")])] {
let window = Caveat::WithinWindow(jcp_schema::Window { secs: 60, limit: jcp_schema::WindowLimit::MaxCalls(3) });
for c in [Caveat::PayloadIs(ContentId::of_bytes(b"args")), Caveat::MaxCostUj(10), Caveat::ModelIn(vec![ContentId::of_bytes(b"m")]), window] {
assert!(matches!(grant_to_access_token(&caveated(&sk, vec![c]), &sk, 1_000, 3_600), Err(OAuthError::Unprojectable(_))));
}
for c in [Caveat::CallerIs(other), Caveat::ActionIn(vec!["delete".into()]), Caveat::NotBefore(9_000)] {

View file

@ -81,6 +81,7 @@ pub fn checks() -> Vec<Check> {
Check { id: "C24 caveats: one-shot, per-call cost, model, monotone attenuation", run: c24_caveats },
Check { id: "C26 one writer per record: a second holder is refused", run: c26_single_writer },
Check { id: "C25 flow: label under ceiling, floor, fail-closed, no debit", run: c25_flow },
Check { id: "C27 windows: calls, payloads and joules over a sliding window", run: c27_windows },
Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials },
Check { id: "C-neg canonical rejects floats", run: cneg_canonical },
]
@ -335,6 +336,110 @@ fn c23_payload_binding() -> Result<(), String> {
Ok(())
}
/// C27 — windows (SPEC §4.12): a sliding window over a grant's recent use,
/// counted where calls are admitted, binding a joule window at the debit, over
/// every link of a chain, and full while its history is unknown.
fn c27_windows() -> Result<(), String> {
use jcp_schema::{Window, WindowLimit};
let r = ContentId::of_bytes(b"db");
let holder = did(&sk(2));
let full = Verdict::Deny(DenyReason::WindowExceeded);
let windowed = |limit: WindowLimit| -> Result<SignedGrant, String> {
let mut g = grant(&sk(1), holder, r, &["read"], 1_000_000, 0).grant;
g.caveats = vec![Caveat::WithinWindow(Window { secs: 60, limit })];
issue(&sk(1), g).map_err(|e| e.to_string())
};
// Calls: the limit, then a refusal that debits nothing, then the slide.
let sg = windowed(WindowLimit::MaxCalls(2))?;
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
let call = |rt: &mut Runtime, now| rt.decide(&f, &sg, holder, Energy::measured(10), now).verdict;
if call(&mut rt, 100) != Verdict::Allow || call(&mut rt, 110) != Verdict::Allow {
return Err("a call window refused a call within its limit".into());
}
if call(&mut rt, 159) != full || rt.spent(&sg.grant.id()) != 20 {
return Err("a call window admitted a call beyond its limit, or debited the refusal".into());
}
if call(&mut rt, 160) != Verdict::Allow {
return Err("a call window did not slide".into());
}
// Distinct payloads: a repeat is admitted, a new one beyond the limit is not.
let sg = windowed(WindowLimit::MaxDistinctPayloads(1))?;
let mut rt = Runtime::new();
let mut on = |p: &[u8], now| {
let mut f = frame(&sg, r, "read", Trust::Control);
f.payload_hash = ContentId::of_bytes(p);
rt.decide(&f, &sg, holder, Energy::measured(1), now).verdict
};
if on(b"a", 100) != Verdict::Allow || on(b"a", 101) != Verdict::Allow || on(b"b", 102) != full {
return Err("a distinct-payload window counted calls, not payloads".into());
}
// Joules: a one-shot call that would overfill is refused; the two-phase
// path binds at the debit, keeps the overrun, and refuses the next call.
let sg = windowed(WindowLimit::MaxUj(1_000))?;
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
if rt.decide(&f, &sg, holder, Energy::measured(1_001), 100).verdict != full {
return Err("a joule window admitted a one-shot call that overfills it".into());
}
let mut rt = Runtime::new();
if !rt.authorize(&f, &sg, holder, 100).is_allow() {
return Err("a joule window refused a call with the window empty".into());
}
if rt.charge(&sg.grant, Energy::measured(1_200)).verdict != full || rt.spent(&sg.grant.id()) != 1_200 {
return Err("a joule window did not bind at the debit, or dropped the overrun".into());
}
if rt.authorize(&f, &sg, holder, 110).verdict != full {
return Err("a full joule window admitted the next call".into());
}
// A parent's window counts what its delegates do.
let root = windowed(WindowLimit::MaxCalls(1))?;
let child = attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500_000, 0, [2; 16])
.map_err(|e| e.to_string())?;
let sibling = attenuate(&root, &sk(2), did(&sk(4)), Capability::new(r, ["read"]), 500_000, 0, [3; 16])
.map_err(|e| e.to_string())?;
let mut rt = Runtime::new();
let fc = frame(&child, r, "read", Trust::Control);
let fs = frame(&sibling, r, "read", Trust::Control);
if !rt.decide_chained(&fc, &[root.clone(), child], did(&sk(3)), Energy::measured(1), 100).is_allow() {
return Err("a delegate's first call was refused".into());
}
if rt.decide_chained(&fs, &[root, sibling], did(&sk(4)), Energy::measured(1), 101).verdict != full {
return Err("a sibling escaped its parent's window".into());
}
// A timeless restore leaves every window full until its history is known.
let sg = windowed(WindowLimit::MaxCalls(5))?;
let f = frame(&sg, r, "read", Trust::Control);
let mut rt = Runtime::new();
rt.restore([(sg.grant.id(), 1)]);
if rt.decide(&f, &sg, holder, Energy::measured(1), 5_000).verdict != full {
return Err("a restored runtime read an unknown window as empty".into());
}
rt.resume_windows_at(1_000);
if rt.decide(&f, &sg, holder, Energy::measured(1), 1_059).verdict != full
|| !rt.decide(&f, &sg, holder, Energy::measured(1), 1_060).is_allow()
{
return Err("a window was decided before a whole window had passed since its history resumed".into());
}
// A zero-second window bounds nothing and is refused.
let mut g = grant(&sk(1), holder, r, &["read"], 1_000_000, 0).grant;
g.caveats = vec![Caveat::WithinWindow(Window { secs: 0, limit: WindowLimit::MaxCalls(9) })];
let zero = issue(&sk(1), g).map_err(|e| e.to_string())?;
let fz = frame(&zero, r, "read", Trust::Control);
if Runtime::new().decide(&fz, &zero, holder, Energy::measured(1), 100).verdict
!= Verdict::Deny(DenyReason::CaveatUnsatisfied)
{
return Err("a zero-second window was read as no limit".into());
}
Ok(())
}
/// C24 — caveats (SPEC §4.9): conditions carried inside the grant, checked
/// offline by the decision point, accumulating monotonically down a chain.
fn c24_caveats() -> Result<(), String> {

View file

@ -9,11 +9,13 @@
//! loop — which is the entire point: the inverse-scaling-law result means a more
//! capable model is a more dangerous one, so the gate cannot be the model.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use jcp_grant::{verify, verify_chain, GrantError, RevocationSet};
use jcp_schema::flow::{FlowCeiling, FlowFacts, Origins};
use jcp_schema::{ContentId, DenyReason, Did, Energy, Frame, Grant, SignedGrant, SignedRevocation, Trust, Verdict};
use jcp_schema::{
Caveat, ContentId, DenyReason, Did, Energy, Frame, Grant, SignedGrant, SignedRevocation, Trust, Verdict, WindowLimit,
};
use serde::{Deserialize, Serialize};
/// The result of deciding one frame.
@ -90,6 +92,32 @@ pub struct Runtime {
/// for calls carrying untrusted provenance. Tracked separately from
/// `spent_uj`, which it never converts into.
spent_tainted_uj: HashMap<ContentId, u64>,
/// The recent history of each grant that carries a window caveat (SPEC
/// §4.12), pruned to its longest window.
windows: HashMap<ContentId, WindowHistory>,
/// Whether that history is complete. `None`: it is, since this runtime
/// began. `Some(t)`: nothing before `t` is known, so a window is complete
/// only once it lies wholly after `t`. `Some(u64::MAX)`: nothing is known.
window_gap: Option<u64>,
}
/// What a grant's window caveats are decided over (SPEC §4.12): the calls
/// admitted under it, with their payloads, and the joules debited for them,
/// each at the time its call was admitted.
#[derive(Clone, Debug, Default)]
struct WindowHistory {
admissions: Vec<(u64, ContentId)>,
debits: Vec<(u64, u64)>,
}
impl WindowHistory {
/// The joules debited for calls inside a `secs` window decided at `now`.
fn uj_within(&self, secs: u64, now: u64) -> u64 {
self.debits
.iter()
.filter(|(at, _)| now.saturating_sub(*at) < secs)
.fold(0u64, |sum, (_, uj)| sum.saturating_add(*uj))
}
}
@ -259,7 +287,9 @@ impl Runtime {
return deny(r);
}
// 6b. Caveats (§4.9).
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now)
.or_else(|| self.window_unsatisfied(g, &frame.payload_hash, Some(cost.uj()), now))
{
return deny(r);
}
@ -288,6 +318,8 @@ impl Runtime {
// Allow — debit the energy.
let new_spent = spent + c;
self.spent_uj.insert(gid, new_spent);
self.admit_windows(g, frame.payload_hash, now);
self.debit_windows(g, c, Some(now));
Decision {
verdict: Verdict::Allow,
cost,
@ -299,13 +331,107 @@ impl Runtime {
/// receipt stream is sufficient to reconstruct enforcement state). Each
/// entry adds to the grant's recorded spend — feed it the allow-receipt
/// debits, e.g. via `jcp_receipt::replay_spend`.
///
/// A spend ledger carries no times, so after restoring one this runtime
/// cannot know what any window held (SPEC §4.12). It treats every window
/// as full until [`resume_windows_at`](Self::resume_windows_at) says from
/// when its history is complete.
pub fn restore(&mut self, entries: impl IntoIterator<Item = (ContentId, u64)>) {
for (gid, uj) in entries {
let e = self.spent_uj.entry(gid).or_insert(0);
*e = e.saturating_add(uj);
self.window_gap = Some(u64::MAX);
}
}
/// Declare this runtime's window history (SPEC §4.12) complete from `from`
/// on: every call admitted since `from` has been decided here. A window is
/// then decided once it lies wholly after `from`, and until then it counts
/// as full, since calls before `from` may have filled it.
pub fn resume_windows_at(&mut self, from: u64) {
self.window_gap = Some(from);
}
/// Whether a `secs` window decided at `now` lies wholly inside the part of
/// history this runtime knows.
fn window_known(&self, secs: u64, now: u64) -> bool {
match self.window_gap {
None => true,
Some(from) => from != u64::MAX && now.saturating_sub(from) >= secs,
}
}
/// The window gate (SPEC §4.12): whether a call under `g` carrying
/// `payload`, at `now`, costing `cost_uj`, would exceed any of its window
/// caveats. The cost is `None` in the two-phase path, where a joule window
/// admits a call while any of it is left and binds at the debit.
fn window_unsatisfied(&self, g: &Grant, payload: &ContentId, cost_uj: Option<u64>, now: u64) -> Option<DenyReason> {
let windows: Vec<_> = g.caveats.iter().filter_map(Caveat::window).collect();
if windows.is_empty() {
return None;
}
let history = self.windows.get(&g.id());
for w in windows {
if !self.window_known(w.secs, now) {
return Some(DenyReason::WindowExceeded);
}
let admitted = history
.into_iter()
.flat_map(|h| &h.admissions)
.filter(|(at, _)| now.saturating_sub(*at) < w.secs);
let full = match w.limit {
WindowLimit::MaxCalls(n) => admitted.count() as u64 >= n,
WindowLimit::MaxDistinctPayloads(n) => {
let distinct: HashSet<&ContentId> = admitted.map(|(_, p)| p).collect();
!distinct.contains(payload) && distinct.len() as u64 >= n
}
WindowLimit::MaxUj(max) => {
let used = history.map_or(0, |h| h.uj_within(w.secs, now));
match cost_uj {
Some(c) => used.saturating_add(c) > max,
None => used >= max,
}
}
};
if full {
return Some(DenyReason::WindowExceeded);
}
}
None
}
/// Record an admitted call under `g` for its window caveats, pruning what
/// no window of `g` can reach any more.
fn admit_windows(&mut self, g: &Grant, payload: ContentId, now: u64) {
let Some(longest) = g.caveats.iter().filter_map(Caveat::window).map(|w| w.secs).max() else {
return;
};
let h = self.windows.entry(g.id()).or_default();
h.admissions.retain(|(at, _)| now.saturating_sub(*at) < longest);
h.debits.retain(|(at, _)| now.saturating_sub(*at) < longest);
h.admissions.push((now, payload));
}
/// Record joules debited under `g` for its window caveats, at `at`, the
/// time the call was admitted. Returns whether a joule window is now
/// exceeded.
///
/// In the two-phase path the debit arrives without its time, so `at` is
/// `None` and the debit is placed at the grant's latest admission. That is
/// never earlier than the call's own, so the joules stay inside a window
/// at least as long as they should. A debit with no admission recorded at
/// all stays inside every window for good.
fn debit_windows(&mut self, g: &Grant, uj: u64, at: Option<u64>) -> bool {
let windows: Vec<_> = g.caveats.iter().filter_map(Caveat::window).copied().collect();
if windows.is_empty() {
return false;
}
let h = self.windows.entry(g.id()).or_default();
let at = at.or_else(|| h.admissions.last().map(|(t, _)| *t)).unwrap_or(u64::MAX);
h.debits.push((at, uj));
windows.iter().any(|w| matches!(w.limit, WindowLimit::MaxUj(max) if h.uj_within(w.secs, at) > max))
}
/// Verify and honor a revocation for `grant`. Only the grant's issuer may
/// revoke; an unauthorized or unsigned revocation is rejected and never
/// enters the set. Once admitted, every decision against the revoked grant
@ -419,7 +545,9 @@ impl Runtime {
return deny(r);
}
// 6b. Caveats carried inside the grant (§4.9) — the open attenuation axis.
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now)
.or_else(|| self.window_unsatisfied(g, &frame.payload_hash, Some(cost.uj()), now))
{
return deny(r);
}
// 7. Budget: prior spend + this cost must not exceed the ceiling.
@ -432,6 +560,8 @@ impl Runtime {
// Allow — debit the cost.
let new_spent = spent + c;
self.spent_uj.insert(gid, new_spent);
self.admit_windows(g, frame.payload_hash, now);
self.debit_windows(g, c, Some(now));
Decision {
verdict: Verdict::Allow,
cost,
@ -521,6 +651,7 @@ impl Runtime {
let remaining = g.budget_uj.saturating_sub(self.spent(&gid));
if let Some(r) = self.check(frame, sg, caller, now, None)
.or_else(|| caveats_unsatisfied(g, frame, caller, None, now))
.or_else(|| self.window_unsatisfied(g, &frame.payload_hash, None, now))
{
return Decision {
verdict: Verdict::Deny(r),
@ -535,6 +666,7 @@ impl Runtime {
budget_remaining_uj: 0,
};
}
self.admit_windows(g, frame.payload_hash, now);
Decision {
verdict: Verdict::Allow,
cost: nothing_ran(),
@ -563,8 +695,16 @@ impl Runtime {
// silently absorbing it. Hiding the breach would be the dishonest move,
// exactly as capping-without-recording would be for a budget overrun.
let breached = grant.caveats.iter().any(|c| c.cost_breached(measured.uj()));
// A joule window (§4.12) binds here for the same reason, and the same way.
let window_breached = self.debit_windows(grant, measured.uj(), None);
Decision {
verdict: if breached { Verdict::Deny(DenyReason::CaveatUnsatisfied) } else { Verdict::Allow },
verdict: if breached {
Verdict::Deny(DenyReason::CaveatUnsatisfied)
} else if window_breached {
Verdict::Deny(DenyReason::WindowExceeded)
} else {
Verdict::Allow
},
cost: measured,
budget_remaining_uj: grant.budget_uj - new_spent,
}
@ -595,6 +735,7 @@ impl Runtime {
if let Some(r) = self
.check(frame, sg, caller, now, Some(facts))
.or_else(|| caveats_unsatisfied(g, frame, caller, None, now))
.or_else(|| self.window_unsatisfied(g, &frame.payload_hash, None, now))
{
return deny(r);
}
@ -615,6 +756,7 @@ impl Runtime {
}
}
}
self.admit_windows(g, frame.payload_hash, now);
Decision { verdict: Verdict::Allow, cost: nothing_ran(), budget_remaining_uj: remaining }
}
@ -656,7 +798,9 @@ impl Runtime {
if let Some(r) = self.check(frame, sg, caller, now, Some(facts)) {
return deny(r);
}
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) {
if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now)
.or_else(|| self.window_unsatisfied(g, &frame.payload_hash, Some(cost.uj()), now))
{
return deny(r);
}
let spent = self.spent(&gid);
@ -666,6 +810,8 @@ impl Runtime {
}
let new_spent = spent + c;
self.spent_uj.insert(gid, new_spent);
self.admit_windows(g, frame.payload_hash, now);
self.debit_windows(g, c, Some(now));
if !facts.is_bottom() {
let accrued = self.spent_tainted(&gid).saturating_add(c);
self.spent_tainted_uj.insert(gid, accrued);
@ -786,6 +932,12 @@ impl Runtime {
self.spent_minor.insert(gid, m);
}
}
// Every link's windows (§4.12) count the call, so a parent's window
// covers what its delegates spend.
for sg in chain {
self.admit_windows(&sg.grant, frame.payload_hash, now);
self.debit_windows(&sg.grant, c, Some(now));
}
// A tainted chained call also depletes each link's tainted sub-budget.
if facts.map(|f| !f.is_bottom()).unwrap_or(false) {
for sg in chain {
@ -879,7 +1031,9 @@ impl Runtime {
// dropped one of its parent's, so the leaf carries them all; evaluating
// each link as well keeps this decision sound on its own.
for sg in chain {
if let Some(r) = caveats_unsatisfied(&sg.grant, frame, caller, cost_uj, now) {
if let Some(r) = caveats_unsatisfied(&sg.grant, frame, caller, cost_uj, now)
.or_else(|| self.window_unsatisfied(&sg.grant, &frame.payload_hash, cost_uj, now))
{
return Err(r);
}
}
@ -903,12 +1057,33 @@ impl Runtime {
/// [`charge_chained`](Self::charge_chained). Debits nothing. The reported
/// `budget_remaining_uj` is the least any link has left.
pub fn authorize_chained(&mut self, frame: &Frame, chain: &[SignedGrant], caller: Did, now: u64) -> Decision {
self.authorize_chained_for(frame, chain, caller, now, 1)
}
/// [`authorize_chained`](Self::authorize_chained) for a call that needs
/// `need_uj` of every link's budget, such as one its caller holds to a
/// joule ceiling before running it. A link with less left denies the call
/// `budget-exceeded` here, before it is admitted, so a call refused for
/// its ceiling is not counted in any window (§4.12).
pub fn authorize_chained_for(
&mut self,
frame: &Frame,
chain: &[SignedGrant],
caller: Did,
now: u64,
need_uj: u64,
) -> Decision {
let remaining = self.chain_remaining(chain);
let verdict = match self.check_chained(frame, chain, caller, now, None, None) {
Err(r) => Verdict::Deny(r),
Ok(_) if remaining == 0 => Verdict::Deny(DenyReason::BudgetExceeded),
Ok(_) if remaining < need_uj.max(1) => Verdict::Deny(DenyReason::BudgetExceeded),
Ok(_) => Verdict::Allow,
};
if verdict == Verdict::Allow {
for sg in chain {
self.admit_windows(&sg.grant, frame.payload_hash, now);
}
}
Decision { verdict, cost: nothing_ran(), budget_remaining_uj: remaining }
}
@ -926,8 +1101,18 @@ impl Runtime {
self.spent_uj.insert(gid, spent + debited);
}
let breached = chain.iter().any(|sg| sg.grant.caveats.iter().any(|c| c.cost_breached(uj)));
let mut window_breached = false;
for sg in chain {
window_breached |= self.debit_windows(&sg.grant, uj, None);
}
Decision {
verdict: if breached { Verdict::Deny(DenyReason::CaveatUnsatisfied) } else { Verdict::Allow },
verdict: if breached {
Verdict::Deny(DenyReason::CaveatUnsatisfied)
} else if window_breached {
Verdict::Deny(DenyReason::WindowExceeded)
} else {
Verdict::Allow
},
cost: measured,
budget_remaining_uj: self.chain_remaining(chain),
}
@ -1821,12 +2006,12 @@ mod tests {
let mut rt = Runtime::new();
// Plain chain path: no facts under a ceiling ⇒ fail closed.
assert_eq!(
rt.decide_chained(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10).verdict,
rt.decide_chained(&f, std::slice::from_ref(&root_sg), did(&holder), Energy::measured(10), 10).verdict,
Verdict::Deny(DenyReason::TaintedFlow),
);
// Flow chain path: a tainted argument is denied.
assert_eq!(
rt.decide_chained_flow(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10, &tainted(7)).verdict,
rt.decide_chained_flow(&f, std::slice::from_ref(&root_sg), did(&holder), Energy::measured(10), 10, &tainted(7)).verdict,
Verdict::Deny(DenyReason::TaintedFlow),
);
// A clean argument passes.
@ -1859,4 +2044,193 @@ mod tests {
assert_eq!(d.cost.measured_uj, None, "{what}: a measured zero nothing read");
}
}
// ── sliding windows (SPEC §4.12) ─────────────────────────────────────────
fn within(secs: u64, limit: WindowLimit) -> Caveat {
Caveat::WithinWindow(jcp_schema::Window { secs, limit })
}
const FULL: Verdict = Verdict::Deny(DenyReason::WindowExceeded);
/// A call window admits its limit inside any window, refuses the next
/// without debiting it, and admits again once the earliest call has slid
/// out. The budget alone would have allowed every call.
#[test]
fn a_call_window_admits_its_limit_and_then_slides() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxCalls(2))]);
let mut rt = Runtime::new();
let f = frame(&sg, r, "read", Trust::Control);
let call = |rt: &mut Runtime, now| rt.decide(&f, &sg, holder, Energy::measured(10), now).verdict;
assert_eq!(call(&mut rt, 100), Verdict::Allow);
assert_eq!(call(&mut rt, 110), Verdict::Allow);
assert_eq!(call(&mut rt, 159), FULL);
assert_eq!(rt.spent(&sg.grant.id()), 20, "the refused call was not debited");
assert_eq!(call(&mut rt, 160), Verdict::Allow, "the call at 100 has left the window");
assert_eq!(call(&mut rt, 169), FULL, "the calls at 110 and 160 are inside it");
}
/// A distinct-payload window counts what a grant is used on, not how
/// often: a payload already inside the window is admitted again, a new one
/// beyond the limit is not.
#[test]
fn a_distinct_payload_window_counts_targets_not_calls() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxDistinctPayloads(2))]);
let mut rt = Runtime::new();
let on = |p: &[u8]| {
let mut f = frame(&sg, r, "read", Trust::Control);
f.payload_hash = ContentId::of_bytes(p);
f
};
let mut call = |p: &[u8], now| rt.decide(&on(p), &sg, holder, Energy::measured(1), now).verdict;
assert_eq!(call(b"a", 100), Verdict::Allow);
assert_eq!(call(b"b", 101), Verdict::Allow);
assert_eq!(call(b"a", 102), Verdict::Allow, "a is already inside the window");
assert_eq!(call(b"c", 103), FULL);
assert_eq!(call(b"c", 161), Verdict::Allow, "the first a and b have left");
}
/// A joule window in the one-shot path refuses a call whose cost would
/// overfill it. In the two-phase path it admits a call while any of the
/// window is left, and binds at the debit: an overrun is denied, the
/// joules stay debited, and the full window refuses the next call.
#[test]
fn a_joule_window_binds_at_the_debit() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxUj(1_000))]);
let f = frame(&sg, r, "read", Trust::Control);
let mut one_shot = Runtime::new();
assert!(one_shot.decide(&f, &sg, holder, Energy::measured(700), 100).is_allow());
assert_eq!(one_shot.decide(&f, &sg, holder, Energy::measured(301), 110).verdict, FULL);
assert!(one_shot.decide(&f, &sg, holder, Energy::measured(300), 110).is_allow());
assert_eq!(one_shot.spent(&sg.grant.id()), 1_000);
let mut rt = Runtime::new();
assert!(rt.authorize(&f, &sg, holder, 100).is_allow());
assert!(rt.charge(&sg.grant, Energy::measured(700)).is_allow());
assert!(rt.authorize(&f, &sg, holder, 110).is_allow(), "300 µJ are left");
assert_eq!(rt.charge(&sg.grant, Energy::measured(500)).verdict, FULL);
assert_eq!(rt.spent(&sg.grant.id()), 1_200, "the overrun stays debited");
assert_eq!(rt.authorize(&f, &sg, holder, 120).verdict, FULL);
assert!(rt.authorize(&f, &sg, holder, 160).is_allow(), "the 700 at 100 has left; 500 remain");
// A debit counts from its own call's admission, the latest, not the
// grant's first: the 450 charged after the call at 150 is still inside
// the window at 205, when the call at 100 is long gone.
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxUj(500))]);
let f = frame(&sg, r, "write", Trust::Control);
let mut rt = Runtime::new();
assert!(rt.authorize(&f, &sg, holder, 100).is_allow());
assert!(rt.charge(&sg.grant, Energy::measured(100)).is_allow());
assert!(rt.authorize(&f, &sg, holder, 150).is_allow());
assert_eq!(rt.charge(&sg.grant, Energy::measured(450)).verdict, FULL);
assert_eq!(rt.decide(&f, &sg, holder, Energy::measured(100), 205).verdict, FULL);
assert!(rt.decide(&f, &sg, holder, Energy::measured(50), 205).is_allow());
}
/// The priced and flow paths count against a window as the plain ones do,
/// and gate on it: every way to decide a call is a way to fill the window.
#[test]
fn every_path_counts_against_the_window() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxCalls(1))]);
let f = frame(&sg, r, "read", Trust::Control);
let clean = FlowFacts::bottom();
let usd = Price::new("USD", 1);
let mut priced = Runtime::new();
assert!(priced.decide_priced(&f, &sg, holder, Energy::measured(1), &usd, 100).is_allow());
assert_eq!(priced.decide_priced(&f, &sg, holder, Energy::measured(1), &usd, 101).verdict, FULL);
assert_eq!(priced.decide(&f, &sg, holder, Energy::measured(1), 102).verdict, FULL);
let mut flow = Runtime::new();
assert!(flow.decide_flow(&f, &sg, holder, Energy::measured(1), 100, &clean).is_allow());
assert_eq!(flow.decide_flow(&f, &sg, holder, Energy::measured(1), 101, &clean).verdict, FULL);
assert_eq!(flow.authorize(&f, &sg, holder, 102).verdict, FULL);
let mut two_phase = Runtime::new();
assert!(two_phase.authorize_flow(&f, &sg, holder, 100, &clean).is_allow());
assert!(two_phase.charge_flow(&sg.grant, Energy::measured(1), &clean).is_allow());
assert_eq!(two_phase.authorize_flow(&f, &sg, holder, 101, &clean).verdict, FULL);
assert_eq!(two_phase.decide(&f, &sg, holder, Energy::measured(1), 102).verdict, FULL);
}
/// A debit that arrives with no admission recorded for it stays inside
/// every window, so a window is never emptied by charging around it.
#[test]
fn a_debit_without_an_admission_stays_in_the_window() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxUj(100))]);
let mut rt = Runtime::new();
rt.charge(&sg.grant, Energy::measured(100));
let f = frame(&sg, r, "read", Trust::Control);
assert_eq!(rt.authorize(&f, &sg, holder, 1_000_000).verdict, FULL);
}
/// A parent's window counts what every delegate spends under it: two
/// siblings cannot each take the parent's limit.
#[test]
fn a_parents_window_covers_its_delegates() {
let r = ContentId::of_bytes(b"db");
let root = chain_root(1_000_000, vec![within(60, WindowLimit::MaxCalls(2))]);
let a = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500_000, 0, [2; 16]).unwrap();
let b = jcp_grant::attenuate(&root, &sk(2), did(&sk(4)), Capability::new(r, ["read"]), 500_000, 0, [3; 16]).unwrap();
let mut rt = Runtime::new();
let (fa, fb) = (frame(&a, r, "read", Trust::Control), frame(&b, r, "read", Trust::Control));
let ca = [root.clone(), a.clone()];
let cb = [root.clone(), b.clone()];
assert!(rt.decide_chained(&fa, &ca, did(&sk(3)), Energy::measured(1), 100).is_allow());
assert!(rt.decide_chained(&fb, &cb, did(&sk(4)), Energy::measured(1), 101).is_allow());
assert_eq!(rt.decide_chained(&fa, &ca, did(&sk(3)), Energy::measured(1), 102).verdict, FULL);
assert_eq!(rt.authorize_chained(&fb, &cb, did(&sk(4)), 103).verdict, FULL);
assert!(rt.authorize_chained(&fb, &cb, did(&sk(4)), 160).is_allow());
}
/// Over a chain in the two-phase path, a joule window on any link binds at
/// the debit, and every link keeps the joules.
#[test]
fn a_chained_joule_window_binds_at_the_debit() {
let r = ContentId::of_bytes(b"db");
let root = chain_root(1_000_000, vec![within(60, WindowLimit::MaxUj(500))]);
let a = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500_000, 0, [2; 16]).unwrap();
let chain = [root.clone(), a.clone()];
let f = frame(&a, r, "read", Trust::Control);
let mut rt = Runtime::new();
assert!(rt.authorize_chained(&f, &chain, did(&sk(3)), 100).is_allow());
assert_eq!(rt.charge_chained(&chain, Energy::measured(600)).verdict, FULL);
assert_eq!((rt.spent(&root.grant.id()), rt.spent(&a.grant.id())), (600, 600));
assert_eq!(rt.authorize_chained(&f, &chain, did(&sk(3)), 110).verdict, FULL);
}
/// A runtime restored from a spend ledger cannot know what its windows
/// held, so it counts each as full until a whole window has passed since
/// the time its history is declared complete. Grants with no window are
/// unaffected.
#[test]
fn a_restored_runtime_counts_its_windows_as_full_until_their_history_is_known() {
let (sg, holder, r) = caveated(vec![within(60, WindowLimit::MaxCalls(5))]);
let f = frame(&sg, r, "read", Trust::Control);
let (plain, plain_holder, _) = caveated(vec![]);
let pf = frame(&plain, r, "read", Trust::Control);
let mut rt = Runtime::new();
rt.restore([(sg.grant.id(), 10)]);
assert_eq!(rt.decide(&f, &sg, holder, Energy::measured(1), 5_000).verdict, FULL);
assert!(rt.decide(&pf, &plain, plain_holder, Energy::measured(1), 5_000).is_allow());
rt.resume_windows_at(1_000);
assert_eq!(rt.decide(&f, &sg, holder, Energy::measured(1), 1_059).verdict, FULL);
assert!(rt.decide(&f, &sg, holder, Energy::measured(1), 1_060).is_allow());
let mut empty = Runtime::new();
empty.restore([]);
assert!(empty.decide(&f, &sg, holder, Energy::measured(1), 0).is_allow(), "nothing restored, nothing unknown");
}
/// A window of zero seconds would bound nothing, so it is refused rather
/// than read as no limit.
#[test]
fn a_zero_second_window_is_refused() {
let (sg, holder, r) = caveated(vec![within(0, WindowLimit::MaxCalls(1_000))]);
let f = frame(&sg, r, "read", Trust::Control);
let d = Runtime::new().decide(&f, &sg, holder, Energy::measured(1), 100);
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
}
}

View file

@ -349,6 +349,38 @@ pub enum Caveat {
/// always returns `true` for it (it never blocks the caveat path); the real
/// test is the runtime's flow gate, which reads this ceiling.
FlowBelow(flow::FlowCeiling),
/// A **sliding-window limit** (SPEC §4.12): over any `secs` seconds, the
/// calls admitted under this grant, the distinct payloads they carry, or
/// the joules metered for them stay within the limit. The budget bounds a
/// grant's whole life; this bounds its rate. Like
/// [`FlowBelow`](Self::FlowBelow), it cannot be decided from one frame. It
/// needs the grant's recent history, which the runtime keeps, so
/// [`holds`](Self::holds) refuses only a window of zero seconds, which
/// would bound nothing.
WithinWindow(Window),
}
/// A sliding window over a grant's recent use (SPEC §4.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Window {
/// The window's length in seconds. A call admitted at `t` is inside the
/// window decided at `now` while `now - t < secs`.
pub secs: u64,
/// What the window bounds.
pub limit: WindowLimit,
}
/// What a [`Window`] bounds.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum WindowLimit {
/// The joules metered for the calls inside the window, in µJ.
MaxUj(u64),
/// The calls admitted inside the window.
MaxCalls(u64),
/// The distinct payloads, by `payload_hash`, among the calls inside the
/// window: how many different things the grant may be used on, not how
/// often.
MaxDistinctPayloads(u64),
}
impl Caveat {
@ -382,6 +414,17 @@ impl Caveat {
// Flow is decided by the runtime gate against the argument's label,
// not from the frame — so as a caveat it never blocks.
Caveat::FlowBelow(_) => true,
// A window is decided by the runtime gate against the grant's
// history (§4.12). A zero-length window would bound nothing.
Caveat::WithinWindow(w) => w.secs > 0,
}
}
/// The window this caveat carries, if it is a [`Caveat::WithinWindow`].
pub fn window(&self) -> Option<&Window> {
match self {
Caveat::WithinWindow(w) => Some(w),
_ => None,
}
}
@ -617,6 +660,10 @@ pub enum DenyReason {
/// (SPEC §4.11), so it does not decide it: a decision it could not record
/// would be lost, with its debit, on a restart.
Unrecorded,
/// A sliding-window caveat (SPEC §4.12) would be exceeded: too many calls,
/// distinct payloads or joules inside the window. A window whose history
/// the enforcement point cannot vouch for counts as full.
WindowExceeded,
}
impl DenyReason {
@ -641,6 +688,7 @@ impl DenyReason {
DenyReason::StaleRequest => "stale-request",
DenyReason::Replayed => "replayed",
DenyReason::Unrecorded => "unrecorded",
DenyReason::WindowExceeded => "window-exceeded",
}
}
}

View file

@ -703,6 +703,30 @@ mod tests {
assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
}
/// A window survives a restart (SPEC §4.12): the endpoint replays each
/// decision at the time it made it, so the window a call filled is still
/// full afterwards.
#[test]
fn a_restart_keeps_the_window() {
let (chain, _, r) = fixture();
let mut g = chain[0].grant.clone();
g.caveats = vec![jcp_schema::Caveat::WithinWindow(jcp_schema::Window {
secs: 3_600,
limit: jcp_schema::WindowLimit::MaxCalls(1),
})];
let root = issue(&sk(1), g).unwrap();
let leaf = attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 50_000, 0, [1; 16]).unwrap();
let chain = vec![root, leaf];
let journal = Shared::default();
let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
assert!(before.handle(&stamped(&chain, r, 1, 10)).decision.is_allow());
drop(before);
let recorded = journal.0.lock().unwrap().clone();
let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap();
let d = after.handle(&stamped(&chain, r, 2, 10));
assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::WindowExceeded));
}
/// A revocation honoured through the endpoint is recorded, and still
/// stands after a restart.
#[test]

View file

@ -513,7 +513,10 @@ impl<'m> Mesh<'m> {
now: u64,
) -> Result<ContentId, MeshError> {
let leaf = p.leaf().ok_or_else(|| MeshError::GrantInvalid("empty chain".into()))?;
let d = self.grants.authorize_chained(&infer_frame(leaf, req, model), &p.chain, self.steward_did, now);
// The request's ceiling is part of the budget test, so a serve refused
// for it is refused before the runtime admits it (JCP §4.12).
let frame = infer_frame(leaf, req, model);
let d = self.grants.authorize_chained_for(&frame, &p.chain, self.steward_did, now, req.joule_ceiling_uj);
match d.verdict {
Verdict::Allow => {}
Verdict::Deny(DenyReason::BadSignature) => {
@ -525,16 +528,10 @@ impl<'m> Mesh<'m> {
}
Verdict::Deny(DenyReason::WrongSubject) => return Err(MeshError::NotThisSteward),
Verdict::Deny(DenyReason::BudgetExceeded) => {
return Err(MeshError::BudgetTooLow { budget_uj: 0, ceiling_uj: req.joule_ceiling_uj });
return Err(MeshError::BudgetTooLow { budget_uj: d.budget_remaining_uj, ceiling_uj: req.joule_ceiling_uj });
}
Verdict::Deny(r) => return Err(MeshError::GrantDenied(r)),
}
if d.budget_remaining_uj < req.joule_ceiling_uj {
return Err(MeshError::BudgetTooLow {
budget_uj: d.budget_remaining_uj,
ceiling_uj: req.joule_ceiling_uj,
});
}
Ok(leaf.grant.id())
}
@ -2160,4 +2157,37 @@ mod tests {
Err(MeshError::ChangeRecord(_))
));
}
// ── grant windows across a restart (JCP §4.12) ───────────────────────────
/// A grant's window survives a restart: the journal replays each
/// authorisation at the time it was made, so a window the serves filled
/// is still full afterwards. A serve refused for its ceiling is refused
/// before it is admitted, so it fills no window.
#[test]
fn a_restart_keeps_the_grants_window() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let req = request(8, Fidelity::F1, 4_000_000);
let window = Caveat::WithinWindow(jcp_schema::Window { secs: 3_600, limit: jcp_schema::WindowLimit::MaxCalls(1) });
let sg = grant_over(&req, 10_000_000, 0, vec![window.clone()]);
let full = |r: Result<ServeResponse, MeshError>| matches!(r, Err(MeshError::GrantDenied(DenyReason::WindowExceeded)));
let journal = SharedJournal::default();
{
let mut mesh = mesh_with(&meter).with_clock(at_1000).with_journal(journal.clone(), &[]).unwrap();
mesh.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
assert!(full(mesh.serve(&req, &[], Some(&present(&sg, &req)))));
}
let recorded = journal.0.borrow().clone();
let mut mesh = mesh_with(&meter).with_clock(at_1000).with_journal(journal.clone(), &recorded).unwrap();
assert!(full(mesh.serve(&req, &[], Some(&present(&sg, &req)))), "still full after the restart");
let poor = grant_over(&req, 1_000, 0, vec![window]);
let mut mesh = mesh_with(&meter).with_clock(at_1000);
let refused = mesh.serve(&req, &[], Some(&present(&poor, &req)));
assert!(matches!(refused, Err(MeshError::BudgetTooLow { budget_uj: 1_000, .. })));
assert_eq!(mesh.grants.spent(&poor.grant.id()), 0);
assert!(mesh.grants.authorize_chained(
&infer_frame(&poor, &req, None), std::slice::from_ref(&poor), mesh.steward_did, 1_000
).is_allow(), "the refused serve took no place in the window");
}
}