Skip to main content

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}