jcp: WithinWindow, a sliding-window caveat over calls, payloads and measured joules #388
7 changed files with 658 additions and 20 deletions
55
jcp/SPEC.md
55
jcp/SPEC.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)] {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue