agent_block_core/knl/scope.rs
1//! The scope: whose session this is, and what it was allowed to spend.
2//!
3//! A scope and a session are different things that share one lifetime. The
4//! session is the *stream*: one append-only history and its projections.
5//! The scope is the *authority* held while that stream is written — a
6//! kernel-issued identity, the principal it belongs to, and the quota an
7//! owner granted it. Both begin when the session opens and end when it
8//! closes, which is why [`super::Session`] holds a `Scope` by value rather
9//! than pointing at one: there is no scope without the session, and no
10//! session without a scope.
11//!
12//! Keeping the two apart is what makes the boundary readable in the log.
13//! The session id names the stream a reader reopens; a [`ScopeId`] names
14//! the authority the events were written under, and it is recorded — on
15//! `session_opened`, and on every `budget_*` event the ledger is folded
16//! from — so the boundary is recoverable from the log alone. The kernel
17//! issues it (a fresh UUID v4, like the session id) and there is no API to
18//! set one: an id a caller could choose is an authority a caller could
19//! claim.
20
21use super::budget::{check_amount, BudgetGrant};
22use super::KnlResult;
23
24/// The identity of a scope: a kernel-issued UUID v4 string.
25///
26/// A `String` with a name, so the field it lands in says what it is. There
27/// is no constructor a caller can reach — [`Scope::new`] mints one, and a
28/// resume takes what the log recorded.
29pub type ScopeId = String;
30
31/// Mint a fresh scope id. The only place one is created.
32fn mint_id() -> ScopeId {
33 uuid::Uuid::new_v4().to_string()
34}
35
36/// One scope: an identity, a principal, and the grant it was opened under.
37///
38/// Read through [`super::Session::scope`]. It holds no balance: the balance
39/// is [`super::fold_balance`] over the ledger, decided inside the store for a
40/// [`reserve`] and read back from the log for everything else
41/// ([`super::Session::remaining`]). A number kept here would be a second
42/// answer to that question, and the wrong one on a stream more than one
43/// handle writes to. What the scope keeps of the grant is its *words* — the
44/// `tag` a refusal reports, the `desc` an audit reads.
45///
46/// [`reserve`]: super::Session::reserve
47#[derive(Debug)]
48pub struct Scope {
49 /// Kernel-issued, recorded on `session_opened` and every `budget_*`
50 /// event.
51 id: ScopeId,
52 /// Whose scope this is: a real principal id, or the reserved
53 /// [`super::ANON`] / [`super::SYSTEM`]. Total — never absent.
54 owner: String,
55 /// The grant this scope opened (or resumed) with, kept for its words: a
56 /// refused reservation hands the `tag` back so a caller can say which
57 /// allowance stopped it, and its presence is what says this session keeps
58 /// a ledger at all. `None` when the session has no budget.
59 grant: Option<BudgetGrant>,
60}
61
62impl Scope {
63 /// Open a scope for `owner` with an optional `grant`, under a fresh
64 /// kernel-issued id.
65 pub fn new(owner: String, grant: Option<BudgetGrant>) -> Self {
66 Self {
67 id: mint_id(),
68 owner,
69 grant,
70 }
71 }
72
73 /// Restore the scope a log records: `id` and `owner` as they were
74 /// written, `grant` as the last `budget_granted` said.
75 ///
76 /// No balance is handed over, because none is held: what is left is
77 /// [`super::fold_balance`] over the ledger, read when it is asked for.
78 ///
79 /// `id` is `None` for a log written before the scope id was recorded,
80 /// and a fresh one is issued rather than the resume failing — the same
81 /// shape of fallback as an ownerless `session_opened` resuming as
82 /// [`super::ANON`], and for the same reason: a log that predates a field
83 /// is still a session.
84 pub(super) fn restore(id: Option<ScopeId>, owner: String, grant: Option<BudgetGrant>) -> Self {
85 Self {
86 id: id.unwrap_or_else(mint_id),
87 owner,
88 grant,
89 }
90 }
91
92 /// The kernel-issued scope id.
93 pub fn id(&self) -> &str {
94 &self.id
95 }
96
97 /// Whose scope this is (a principal id, or [`super::ANON`] /
98 /// [`super::SYSTEM`]).
99 pub fn owner(&self) -> &str {
100 &self.owner
101 }
102
103 /// The grant this scope opened (or resumed) with, if any.
104 pub fn grant(&self) -> Option<&BudgetGrant> {
105 self.grant.as_ref()
106 }
107
108 /// The owner granting again: take the new grant's words as the scope's.
109 ///
110 /// The balance it raises is the ledger's business — the
111 /// `budget_granted` event is already in the log by the time this runs
112 /// ([`super::Session::grant_more`]) — so all that is left here is the
113 /// amount check and the words a later refusal will report.
114 pub(super) fn grant_more(&mut self, grant: BudgetGrant) -> KnlResult<()> {
115 check_amount(grant.amount)?;
116 self.grant = Some(grant);
117 Ok(())
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::knl::ANON;
125
126 /// The id is the kernel's: every scope gets its own, and it is a real
127 /// (non-empty) string before anything is recorded.
128 #[test]
129 fn every_scope_is_issued_its_own_id() {
130 let a = Scope::new(ANON.to_string(), None);
131 let b = Scope::new(ANON.to_string(), None);
132 assert!(!a.id().is_empty());
133 assert_ne!(a.id(), b.id(), "scope ids must be unique");
134 assert_eq!(a.owner(), ANON);
135 assert_eq!(a.grant(), None, "no grant, no ledger");
136 }
137
138 /// A restored scope keeps the id the log recorded; a log with none gets
139 /// a fresh kernel-issued one rather than an empty or absent id.
140 #[test]
141 fn restore_keeps_the_recorded_id_and_issues_one_when_there_is_none() {
142 let kept = Scope::restore(
143 Some("scope-from-the-log".to_string()),
144 "user-1".to_string(),
145 Some(BudgetGrant::new(100)),
146 );
147 assert_eq!(kept.id(), "scope-from-the-log");
148 assert_eq!(kept.owner(), "user-1");
149 assert_eq!(
150 kept.grant().map(|g| g.amount),
151 Some(100),
152 "the grant the log recorded comes back"
153 );
154
155 let minted = Scope::restore(None, ANON.to_string(), None);
156 assert!(
157 !minted.id().is_empty(),
158 "an older log still resumes under a scope id"
159 );
160 assert_ne!(minted.id(), kept.id());
161 }
162
163 /// A second grant replaces the words a refusal reports, and refuses a
164 /// negative amount. It moves no balance here: the balance is the
165 /// ledger's, and the `budget_granted` event is what raised it.
166 #[test]
167 fn a_second_grant_replaces_the_words_and_refuses_a_negative_amount() {
168 let mut scope = Scope::new(
169 "user-2".to_string(),
170 Some(BudgetGrant {
171 amount: 100,
172 tag: Some("tokens".to_string()),
173 desc: None,
174 }),
175 );
176 assert_eq!(scope.grant().and_then(|g| g.tag.as_deref()), Some("tokens"));
177
178 scope
179 .grant_more(BudgetGrant {
180 amount: 5,
181 tag: Some("calls".to_string()),
182 desc: None,
183 })
184 .expect("a second grant");
185 assert_eq!(scope.grant().and_then(|g| g.tag.as_deref()), Some("calls"));
186 assert_eq!(scope.grant().map(|g| g.amount), Some(5));
187
188 let err = scope
189 .grant_more(BudgetGrant::new(-1))
190 .expect_err("a negative grant");
191 assert!(err.reason().contains("non-negative"), "{err}");
192 assert_eq!(
193 scope.grant().and_then(|g| g.tag.as_deref()),
194 Some("calls"),
195 "a refused grant leaves the words as they were"
196 );
197 }
198}