diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 9a32af7e7..0ccaf08af 100644 --- a/jcp/SPEC.md +++ b/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. diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 5d04fc399..d0317b123 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -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)] { diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 56f35ea98..d20111a9c 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -81,6 +81,7 @@ pub fn checks() -> Vec { 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 { + 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> { diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index a3e76ef79..1d4317782 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -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, + /// The recent history of each grant that carries a window caveat (SPEC + /// §4.12), pruned to its longest window. + windows: HashMap, + /// 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, +} + +/// 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) { 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, now: u64) -> Option { + 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) -> 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)); + } } diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index d10c5a263..3a5a42b84 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -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", } } } diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index d7bd59c69..db9823c54 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -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] diff --git a/mesh/mesh-rs/crates/mesh-core/src/lib.rs b/mesh/mesh-rs/crates/mesh-core/src/lib.rs index eeec59713..8246bd3dd 100644 --- a/mesh/mesh-rs/crates/mesh-core/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-core/src/lib.rs @@ -513,7 +513,10 @@ impl<'m> Mesh<'m> { now: u64, ) -> Result { 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| 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"); + } }