agent_block_core/knl/session.rs
1//! K5 — the session.
2//!
3//! A session binds one history, one budget and the projection caches
4//! together and is the only handle on kernel state. All of it lives in
5//! the value: two sessions share nothing.
6//!
7//! # The lifecycle is the session's
8//!
9//! There is no "run" inside a session. A session opens once, records, and
10//! closes once; the two events that bracket it — `session_opened` and
11//! `session_closed` — are written by the kernel on those two occasions and
12//! by nothing else. A caller cannot hand-append either
13//! ([`super::event::is_kernel_only`]), because a stream that claims an
14//! opening it never had, or an ending it never reached, is exactly what a
15//! resume and an audit read.
16//!
17//! Beats are the layer above's, not the kernel's: the shell mints a beat id
18//! and stamps it on the facts that belong together. The kernel neither
19//! numbers nor requires one — see [`super::event`].
20//!
21//! # The session *has a* scope
22//!
23//! A scope and a session are two things, sharing one lifetime: both begin
24//! when the session opens and end when it closes. The session is the
25//! stream — this history and its projections. The [`Scope`] is the
26//! authority it is written under: a kernel-issued [`ScopeId`], the `owner`,
27//! and the quota that owner granted. The session holds it *by value*,
28//! because neither outlives the other and there is nothing to share.
29//!
30//! A session holds only its own events, so ownership is not a per-event
31//! question: the scope carries one `owner` — a real principal id, or the
32//! reserved [`ANON`] / [`SYSTEM`] id — and it is *total* (never `Option`,
33//! never a "kernel vs caller" flag). System-originated work is a session
34//! whose owner is [`SYSTEM`]; unspecified is [`ANON`]. The policy layer
35//! above the kernel reads [`Session::owner`] to authorize; the kernel itself
36//! does not branch on it.
37//!
38//! The scope is in the log, not only in the value. Its id is recorded on
39//! `session_opened` beside the owner, and on every `budget_*` event, so the
40//! boundary is recoverable — and unforgeable, since the kinds that carry it
41//! are the kernel's alone to write ([`super::event::is_kernel_only`]).
42//! [`Session::resume`] restores the scope from those records rather than
43//! being told what it was.
44//!
45//! # One append, and it lands
46//!
47//! There is a single write path. [`Session::append`] validates, stamps
48//! the kernel-owned `seq` / `epoch_ms`, and pushes. It adds nothing else:
49//! what an event says beyond the envelope is the caller's.
50//!
51//! An append *records a fact*, so it is never refused for what the handle
52//! last saw. The store assigns the `seq` and the ordering, and serializes
53//! the write per stream; two handles on one stream both write and the log
54//! interleaves in arrival order. No handle keeps a head of its own to be
55//! measured against — the store's head is read when something needs it.
56//!
57//! A *command with an invariant* is the other shape, and it is decided
58//! inside the store ([`EventStore::append_if`]), which folds the events it
59//! is handed and writes only if the invariant holds, all under the same
60//! serialization. Checking a cached value out here and appending
61//! afterwards is exactly the race that would let two handles reserve the
62//! same allowance twice. Every move of the balance is one:
63//! [`Session::reserve`] writes a `budget_reserved` if the ledger covers what
64//! was asked and a `budget_refused` if it does not, [`Session::spend`] writes
65//! a `budget_spent` if there is a ledger at all, and
66//! [`Session::grant_on_resume`] writes a `budget_granted` only where one
67//! already is. What all three decide first is the same question — *does this
68//! stream have a ledger* — and it is the log's answer, not the handle's: a
69//! grant this handle never saw still binds it. None of them asks whether the
70//! session ended — see below.
71//!
72//! The one thing written as a *batch* is the session's own opening:
73//! [`Session::open_on`] records `session_opened` and the `budget_granted`
74//! that says what it opened under through [`EventStore::append_many`], so a
75//! reader never meets a session that opened without its quota.
76//!
77//! # The log never refuses a write
78//!
79//! `closed` is the *handle's* state, not the stream's. A handle that has
80//! closed will not operate again — [`Session::append`], [`Session::reserve`],
81//! [`Session::spend`] and [`Session::close`] all read that one local flag —
82//! but the log itself turns nothing away. A write that arrives after a
83//! `session_closed`, from another handle that never saw the ending, is
84//! recorded like any other, because it *is* a fact: something wrote to a
85//! stream that had ended, which is exactly what an audit is there to find.
86//! Refusing it would delete the evidence of the bug that produced it.
87//!
88//! So two handles closing leave two `session_closed` events, not one, and
89//! that is the truthful record. The store's job is to serialize appends and
90//! land them; deciding what a stream *ought* to have looked like is a
91//! reader's.
92//!
93//! # A session is disposable
94//!
95//! Nothing else looks at `session_closed`: [`Session::resume`] is its one
96//! reader. A stream whose log already carries an ending is not continued —
97//! what comes after an ending is a new session, not a second life for the
98//! old one, or "closed" would say nothing about what a reader of the log can
99//! expect after it. That is where a closed stream refuses; the writes do
100//! not.
101//!
102//! # A child is a fact, not a handle
103//!
104//! [`Session::open_child`] opens a session from this one and pays for it out
105//! of this one's balance. It is the second *command with an invariant* the
106//! kernel has, and the only one whose write spans two streams
107//! ([`EventStore::append_if_many`]): the child's `session_opened` and
108//! `budget_granted` land on the child's stream in the same transaction as the
109//! `budget_reserved` on this one, or a `budget_refused` lands here and no
110//! child is opened at all. Both streams are in one database, which is
111//! checked before anything is written — an allocation that could half-land
112//! would leave units in neither ledger.
113//!
114//! The session holds nothing afterwards. There is no list of children here,
115//! no pointer to a parent, and no cascade: what the kernel knows about the
116//! structure is in the log — `session_opened.data.parent` on the child, and
117//! the child's stream named on the parent's ledger entry — and a supervisor
118//! reads it back with a query. A close *records* the children that had not
119//! ended ([`Session::close`], `session_closed.data.open_children`) inside the
120//! same write as the boundary, and lands anyway: the log never refuses a
121//! write, and what to do about a subtree that outlived its root is a
122//! decision, which is not the kernel's to take.
123//!
124//! # Stored shape change ⇒ upcaster
125//!
126//! Every read a session makes — the restore fold, the view folds, `events`,
127//! the balance fold — goes through the read-time upcaster chain
128//! ([`super::event_store::kernel_upcasters`]), so a log written under an
129//! older shape reads as the current one and the stored bytes are never
130//! rewritten. The chain is empty until the first release, because there is
131//! no released shape to read yet; from then on, a round that changes what is
132//! stored ships the matching `n → n+1` step in the same breath. See the
133//! [`super::event_store`] module docs.
134//!
135//! That is a property of the types here, not a rule to remember: a session
136//! holds a [`CurrentStore`] and never a bare backend, so every event it hands
137//! to a fold — or out through [`Session::events`] — is a
138//! [`Current`](super::event_store::Current), and a read that skipped the
139//! chain has no way to reach one.
140//!
141//! An append does not charge. It is a record of something that happened,
142//! and the budget is a quota: [`Session::reserve`] is a deduction that
143//! refuses when the balance is short, [`Session::spend`] is a deduction that
144//! does not ask. They are independent — nothing is held and nothing is
145//! released, so a beat that calls both deducts twice — and the layer that
146//! knows what a call costs picks. Folding the budget into the append is what
147//! turned it into a flag that only stands up once the allowance is already
148//! gone; the balance and what a run actually consumed (a query view over the
149//! recorded `llm_response` payloads, on the Lua side) are independent
150//! readings and neither is the other's ledger.
151//!
152//! # The budget is in the log, and nowhere else
153//!
154//! Every move of the balance is an event — `budget_granted` when an owner
155//! allows, `budget_reserved` / `budget_refused` at the decision point,
156//! `budget_spent` for a deduction that did not ask — written through the same
157//! store. The
158//! balance is not session-local state that dies with the process, and it is
159//! not a number kept beside the log either: it *is*
160//! [`super::budget::fold_balance`] over the stream, which is why
161//! [`Session::remaining`] is right on a stream more than one handle writes
162//! to. Reading it is cheap because the fold is cached against the store's
163//! head and retaken only when the head has moved; nothing but that fold ever
164//! sets it. Those kinds are the kernel's alone to write
165//! ([`super::event::is_kernel_only`]) — [`Session::append`] refuses them
166//! from a caller, because writing one is moving the account.
167//!
168//! The kernel writes the session's own boundaries through the same append:
169//! [`Session::new`] appends `session_opened` and [`Session::close`] appends
170//! `session_closed`, so a session is bracketed in the history whether or not
171//! the shell remembers to say so. After a close, *this handle's* `append` /
172//! `reserve` / `spend` are errors while reads keep working — the record
173//! outlives the session, and another handle's writes go on landing in it.
174//!
175//! What ends a session is [`close`], and only [`close`] writes the
176//! `session_closed` that says so: the flag and the event are set on one
177//! path, so a handle's state and what it wrote cannot disagree. Taking that
178//! path twice on one handle writes once, because the flag is already set the
179//! second time.
180//!
181//! [`close`]: Session::close
182
183use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
184use std::sync::{Arc, Mutex, PoisonError};
185
186use serde_json::{Map, Value};
187
188use super::budget::{self, fold_balance, last_grant, Allocation, BudgetGrant};
189use super::event::{
190 data_field, is_kernel_only, kernel_event, BUDGET_KINDS, FIELD_AMOUNT, FIELD_CHILD, FIELD_DESC,
191 FIELD_DETAIL, FIELD_KIND, FIELD_OPEN_CHILDREN, FIELD_OWNER, FIELD_PARENT, FIELD_REASON,
192 FIELD_REMAINING, FIELD_SCOPE_ID, FIELD_TAG, KIND_BUDGET_GRANTED, KIND_BUDGET_REFUSED,
193 KIND_BUDGET_RESERVED, KIND_BUDGET_SPENT, KIND_SESSION_CLOSED, KIND_SESSION_OPENED,
194};
195use super::event_store::{kernel_upcasters, ChildScan, Current, CurrentStore, EventStore, Split};
196use super::projection::{tail_count, VIEW_TAIL};
197use super::query::{self, QueryOpts, QueryParams, QueryRows};
198use super::scope::{Scope, ScopeId};
199use super::sqlite_store::{IsleDrivers, SqliteEventStore};
200use super::{projection, KnlError, KnlResult};
201
202/// Reason recorded by `close()` when the caller does not give one.
203pub const DEFAULT_CLOSE_REASON: &str = "closed";
204/// Reason recorded when a session ended on its own: the Lua `<close>`
205/// variable holding the session went out of scope with no error.
206pub const CLOSE_REASON_SCOPE_EXIT: &str = "scope_exit";
207/// Reason recorded when a run scope ended because the block raised: the
208/// message goes to [`FIELD_DETAIL`], never into the reason.
209pub const CLOSE_REASON_ERROR: &str = "error";
210/// Reason recorded by the backstop: the handle died without anyone closing
211/// it, so the boundary is written where the value is dropped.
212pub const CLOSE_REASON_DROPPED: &str = "dropped";
213
214/// What a closed handle says when it is asked to write.
215///
216/// One sentence for both places it comes from — a handle that has closed,
217/// and a [`Session::resume`] of a stream whose log already ended — because
218/// from the caller's side they are the same answer: this session is over,
219/// go and open another.
220const CLOSED: &str = "session is closed";
221
222/// Whether the stream already carries its ending.
223///
224/// Asked in exactly one place, [`Session::resume`], because a session is
225/// disposable: there is no reopening kind, so a single `session_closed`
226/// anywhere in the log means the stream is not a state to continue from.
227/// No *write* asks this — a write records what happened, and something
228/// writing after an ending is the fact an audit most wants recorded.
229///
230/// It reads the one kind it is asking about, and at most one of those: the
231/// question is whether an ending exists, not where it is or how many there
232/// are.
233async fn has_ended(store: &CurrentStore) -> KnlResult<bool> {
234 let ending = store.read_kinds(Some(&[KIND_SESSION_CLOSED]), 0, 1).await?;
235 Ok(!ending.is_empty())
236}
237
238/// Reserved owner: no principal was named when the session opened.
239pub const ANON: &str = "anon";
240/// Reserved owner: the session belongs to the system itself.
241pub const SYSTEM: &str = "system";
242
243/// A `budget_granted` event for `grant`, written under `scope_id`.
244///
245/// Only what the owner said is written — an absent `tag` is an absent
246/// field, not a null — so the record carries the grant and nothing more.
247/// The fields are the kind's own, so they go under `data`
248/// ([`super::event`]); the envelope carries the log's vocabulary and not the
249/// ledger's.
250fn granted_event(grant: &BudgetGrant, scope_id: &str) -> Map<String, Value> {
251 let mut data = Map::new();
252 data.insert(
253 FIELD_SCOPE_ID.to_string(),
254 Value::from(scope_id.to_string()),
255 );
256 data.insert(FIELD_AMOUNT.to_string(), Value::from(grant.amount));
257 if let Some(tag) = grant.tag.as_ref() {
258 data.insert(FIELD_TAG.to_string(), Value::from(tag.clone()));
259 }
260 if let Some(desc) = grant.desc.as_ref() {
261 data.insert(FIELD_DESC.to_string(), Value::from(desc.clone()));
262 }
263 kernel_event(KIND_BUDGET_GRANTED, data)
264}
265
266/// A `budget_reserved` / `budget_spent` / `budget_refused` event for
267/// `amount`, written under `scope_id` and tagged with the grant's unit so
268/// the ledger reads without a join.
269///
270/// The scope id is on every move of the balance, not only on the session's
271/// opening: the ledger is the one part of the log that says what was
272/// *allowed*, so each entry names the authority it was allowed under.
273fn budget_move_event(
274 kind: &str,
275 amount: i64,
276 tag: Option<&str>,
277 scope_id: &str,
278) -> Map<String, Value> {
279 kernel_event(kind, budget_move_data(amount, tag, scope_id))
280}
281
282/// The `data` every `budget_*` entry carries: the scope it was allowed
283/// under, how much, and the grant's unit if it named one.
284fn budget_move_data(amount: i64, tag: Option<&str>, scope_id: &str) -> Map<String, Value> {
285 let mut data = Map::new();
286 data.insert(
287 FIELD_SCOPE_ID.to_string(),
288 Value::from(scope_id.to_string()),
289 );
290 data.insert(FIELD_AMOUNT.to_string(), Value::from(amount));
291 if let Some(tag) = tag {
292 data.insert(FIELD_TAG.to_string(), Value::from(tag.to_string()));
293 }
294 data
295}
296
297/// A `budget_refused` event: a move that did not happen, carrying what was
298/// asked for *and* the balance it was measured against.
299///
300/// The pair is what makes a refusal readable without folding the ledger, so
301/// `remaining` is built into the entry rather than added to it afterwards.
302fn refused_event(
303 amount: i64,
304 remaining: i64,
305 tag: Option<&str>,
306 scope_id: &str,
307) -> Map<String, Value> {
308 let mut data = budget_move_data(amount, tag, scope_id);
309 data.insert(FIELD_REMAINING.to_string(), Value::from(remaining));
310 kernel_event(KIND_BUDGET_REFUSED, data)
311}
312
313/// The parent's side of an allocation: the same `budget_reserved` a
314/// [`Session::reserve`] writes, naming the child the units went to.
315///
316/// An allocation *is* a reservation from the parent's side — units left the
317/// balance and are not coming back — so it is the same kind and folds the
318/// same way. What [`FIELD_CHILD`] adds is where they went, which is the one
319/// thing a reservation for a call of its own has no answer to.
320fn allocated_event(
321 amount: i64,
322 tag: Option<&str>,
323 scope_id: &str,
324 child: &str,
325) -> Map<String, Value> {
326 let mut data = budget_move_data(amount, tag, scope_id);
327 data.insert(FIELD_CHILD.to_string(), Value::from(child.to_string()));
328 kernel_event(KIND_BUDGET_RESERVED, data)
329}
330
331/// The parent's side of an allocation that did not happen: what was asked
332/// for, the balance it was measured against, and the child that was not
333/// opened.
334fn allocation_refused_event(
335 amount: i64,
336 remaining: i64,
337 tag: Option<&str>,
338 scope_id: &str,
339 child: &str,
340) -> Map<String, Value> {
341 let mut event = refused_event(amount, remaining, tag, scope_id);
342 if let Some(Value::Object(data)) = event.get_mut(super::event::FIELD_DATA) {
343 data.insert(FIELD_CHILD.to_string(), Value::from(child.to_string()));
344 }
345 event
346}
347
348/// A child's `session_opened`: the scope it opens under, and the stream it
349/// was opened from.
350///
351/// The same event [`Session::open_on`] writes plus [`FIELD_PARENT`], and
352/// written by the parent's store rather than the child's — the opening and
353/// the reservation that paid for it are one transaction, so the child's first
354/// event arrives before the child has a handle at all.
355fn child_opened_event(owner: &str, scope_id: &str, parent: &str) -> Map<String, Value> {
356 let mut data = Map::new();
357 data.insert(FIELD_OWNER.to_string(), Value::from(owner.to_string()));
358 data.insert(
359 FIELD_SCOPE_ID.to_string(),
360 Value::from(scope_id.to_string()),
361 );
362 data.insert(FIELD_PARENT.to_string(), Value::from(parent.to_string()));
363 kernel_event(KIND_SESSION_OPENED, data)
364}
365
366/// A child's `budget_granted`: the units the parent moved, naming where they
367/// came from.
368///
369/// A grant on the child's ledger like any other — its balance is the fold of
370/// its own stream, and this is the entry that starts it — with
371/// [`FIELD_PARENT`] recording that an owner did not conjure it: it was paid
372/// for by a `budget_reserved` on the stream named here, in the same write.
373fn child_granted_event(
374 amount: i64,
375 tag: Option<&str>,
376 scope_id: &str,
377 parent: &str,
378) -> Map<String, Value> {
379 let mut data = budget_move_data(amount, tag, scope_id);
380 data.insert(FIELD_PARENT.to_string(), Value::from(parent.to_string()));
381 kernel_event(KIND_BUDGET_GRANTED, data)
382}
383
384/// The `session_closed` event a close records.
385///
386/// One builder for both close paths — the awaited [`Session::close_with`] and
387/// the detached [`Session::close_detached`] — so a boundary written by the
388/// backstop is the same event, with the same fields, as one a caller asked
389/// for. The reason defaults to [`DEFAULT_CLOSE_REASON`]; an absent `detail`
390/// is an absent field rather than a null.
391///
392/// `children` are the sessions this one opened that had not ended when it
393/// did, found by the store inside the same transaction that writes this
394/// event. An empty list is an absent field, not an empty array: "there were
395/// none" and "nobody looked" then read the same way, which is the truth for
396/// the detached path — it cannot scan, so it writes none.
397fn closing_event(
398 reason: Option<&str>,
399 detail: Option<&str>,
400 children: Vec<String>,
401) -> Map<String, Value> {
402 let mut data = Map::new();
403 data.insert(
404 FIELD_REASON.to_string(),
405 Value::from(reason.unwrap_or(DEFAULT_CLOSE_REASON)),
406 );
407 if let Some(detail) = detail {
408 data.insert(FIELD_DETAIL.to_string(), Value::from(detail.to_string()));
409 }
410 if !children.is_empty() {
411 data.insert(
412 FIELD_OPEN_CHILDREN.to_string(),
413 Value::from(children.into_iter().map(Value::from).collect::<Vec<_>>()),
414 );
415 }
416 kernel_event(KIND_SESSION_CLOSED, data)
417}
418
419/// How the store recognises the children of this session: the two kernel
420/// kinds that bracket a session, and the `data` field a child's opening names
421/// its parent in.
422///
423/// Built here rather than in the store because the vocabulary is the
424/// kernel's — [`EventStore::append_with_open_children`] walks a database and
425/// knows nothing about what `session_opened` means.
426fn child_scan() -> ChildScan {
427 ChildScan {
428 opened: KIND_SESSION_OPENED.to_string(),
429 closed: KIND_SESSION_CLOSED.to_string(),
430 parent_field: FIELD_PARENT.to_string(),
431 }
432}
433
434/// What an allocation's decision is shown: the ledger it measures, and the
435/// ending it must not open a child under.
436///
437/// [`BUDGET_KINDS`] plus `session_closed`, written out rather than
438/// concatenated because a `const` cannot join two slices — and held against
439/// the ledger's own list by a test below, so a kind added to the ledger and
440/// missed here goes red instead of quietly falling out of the balance an
441/// allocation is decided against.
442const ALLOCATION_KINDS: &[&str] = &[
443 KIND_BUDGET_GRANTED,
444 KIND_BUDGET_RESERVED,
445 KIND_BUDGET_REFUSED,
446 KIND_BUDGET_SPENT,
447 KIND_SESSION_CLOSED,
448];
449
450/// What a caller should have called instead of hand-appending `kind`.
451///
452/// The refusal names the method that legitimately writes the kind, so the
453/// error is a redirection rather than a wall.
454fn kernel_only_hint(kind: &str) -> &'static str {
455 match kind {
456 KIND_SESSION_OPENED => "a session records its own opening",
457 KIND_SESSION_CLOSED => "use close",
458 _ => "use reserve / spend",
459 }
460}
461
462/// One session: a stream, and the scope it is written under.
463pub struct Session {
464 /// Correlation id, unique per session — the stream this session writes.
465 /// Distinct from [`Scope::id`], which names the authority the stream is
466 /// written under.
467 id: String,
468 /// The session's scope: its kernel-issued id, its owner, and the budget
469 /// an owner granted it. Held by value — a scope and its session begin
470 /// and end together, so there is nothing to point at.
471 scope: Scope,
472 /// K1 append-only history, held behind the upcasting seam.
473 ///
474 /// A [`CurrentStore`] and never a bare `Box<dyn EventStore>`: the backend
475 /// inside it can be the in-memory store or the durable SQLite one, and
476 /// the seam is what makes every read of it — here and in the folds — an
477 /// event at the current shape.
478 store: CurrentStore,
479 /// The last balance fold, and the store head it was taken at.
480 ///
481 /// Not a counter: nothing adds to it or subtracts from it. A read of
482 /// the balance compares the store's head against the `seq` recorded here
483 /// and, if the log has moved on, refolds [`fold_balance`] over the
484 /// stream and replaces both halves. So the answer is the ledger's on a
485 /// stream two handles write to, and costs one head read on a stream that
486 /// has not moved.
487 ///
488 /// Behind a lock because reading a balance is a read —
489 /// [`Session::remaining`] takes `&self`, and the cache it refreshes is
490 /// derived state rather than a change to the session — and because that
491 /// read is an `async fn` now, whose future has to be `Send`; a `Cell`
492 /// would not be. The guard is never held across an `.await`: the cached
493 /// pair is copied out, the store is asked, and the answer written back.
494 balance: Mutex<(u64, Option<i64>)>,
495 /// Set by `close()`; blocks this handle's further `append` / `reserve` /
496 /// `spend` / `close`.
497 ///
498 /// The handle's state, not the stream's: another handle on the same
499 /// stream keeps its own flag and goes on writing, and the log records
500 /// what it writes. Nothing consults the log to set this.
501 closed: bool,
502}
503
504impl std::fmt::Debug for Session {
505 /// The store is a trait object (`dyn EventStore` is not `Debug`), so it
506 /// is summarised rather than printed; the fields that identify the
507 /// session are shown.
508 ///
509 /// The length is not among them: reading it is a call to the store now,
510 /// and `Debug` cannot wait for one. A caller that wants it asks
511 /// [`Session::len`].
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 f.debug_struct("Session")
514 .field("id", &self.id)
515 .field("scope", &self.scope)
516 .field("closed", &self.closed)
517 .finish_non_exhaustive()
518 }
519}
520
521impl Session {
522 /// Open a session for `owner` with an optional budget grant, on an
523 /// in-memory store.
524 ///
525 /// `owner` is total: pass a real principal id, or [`ANON`] / [`SYSTEM`]
526 /// for the reserved ones. The `session_opened` event is appended here,
527 /// so a fresh session already has one event.
528 ///
529 /// "In-memory" is an in-memory *database*, not a different kind of store:
530 /// the same SQLite backend a durable session uses, on a database that is
531 /// reclaimed when this session lets go of it
532 /// ([`SqliteEventStore::open_memory`]). There is one backend, because
533 /// the log is read with SQL and a log that cannot be queried would be a
534 /// second, lesser kind of session.
535 ///
536 /// The stream is minted here and adopted as the session's id, so
537 /// [`Session::id`] names the stream this session writes — the same
538 /// identity a durable session has, and what `$stream` binds to in a
539 /// [`Session::query`].
540 pub async fn new(
541 owner: String,
542 grant: Option<BudgetGrant>,
543 drivers: &IsleDrivers,
544 ) -> KnlResult<Self> {
545 let stream = uuid::Uuid::new_v4().to_string();
546 let store = SqliteEventStore::open_memory(stream.clone(), drivers).await?;
547 let mut session = Self::open_on(owner, grant, Box::new(store)).await?;
548 session.adopt_id(stream);
549 Ok(session)
550 }
551
552 /// Open a session for `owner` on a caller-chosen backend `store` (the
553 /// in-memory store, or the durable SQLite one).
554 ///
555 /// Like [`Session::new`] but takes the backend, so the shell decides
556 /// whether the log is ephemeral or persisted. It appends the same
557 /// `session_opened` boundary, recording the session's scope on it — the
558 /// kernel-issued [`ScopeId`] and the `owner` — so a later
559 /// [`Session::resume`] can recover the scope from the log alone, and
560 /// the `grant`, so the log says what the owner allowed.
561 /// `session_opened` is an open-shape reserved kind, so both extra fields
562 /// are accepted without any change to the validator.
563 ///
564 /// The two are written as one batch ([`EventStore::append_many`]), so a
565 /// durable stream either carries the opening *and* the quota it opened
566 /// under, or carries nothing at all: an open that fails leaves no session
567 /// behind to close.
568 pub async fn open_on(
569 owner: String,
570 grant: Option<BudgetGrant>,
571 store: Box<dyn EventStore>,
572 ) -> KnlResult<Self> {
573 // Wrap the chosen backend in the read-time upcasting seam, so every one
574 // of this session's reads (view folds, `events`, the balance fold, the
575 // decision a `reserve` takes inside the store) passes through it by
576 // construction. The chain is empty until the first release; a shape
577 // change after it registers its step at that one site.
578 let store = CurrentStore::new(store, kernel_upcasters());
579 let mut session = Self {
580 id: uuid::Uuid::new_v4().to_string(),
581 // The scope is issued here, before the first event: the
582 // `session_opened` below is already written under it.
583 scope: Scope::new(owner, grant),
584 store,
585 // Nothing folded yet, over a stream with nothing in it: the first
586 // read of the balance sees the head move and folds the ledger the
587 // two appends below are about to write.
588 balance: Mutex::new((0, None)),
589 closed: false,
590 };
591 // The scope rides on the opening: the id the kernel just issued, next
592 // to the owner. Together they are the whole of what a resume needs to
593 // restore the scope, so the boundary is in the log and not only in
594 // this value — and they are the kind's own fields, so they go under
595 // `data` where the validator requires them.
596 let mut opened = Map::new();
597 opened.insert(
598 FIELD_OWNER.to_string(),
599 Value::from(session.scope.owner().to_string()),
600 );
601 opened.insert(
602 FIELD_SCOPE_ID.to_string(),
603 Value::from(session.scope.id().to_string()),
604 );
605 let started = kernel_event(KIND_SESSION_OPENED, opened);
606
607 // The grant is its own fact, right after the boundary: what the
608 // owner allowed is the first entry of the ledger the balance folds
609 // from, not a decoration on the run's opening.
610 let mut opening = vec![started];
611 if let Some(grant) = session.scope.grant().cloned() {
612 opening.push(granted_event(&grant, session.scope.id()));
613 }
614
615 // One write for both. It CAN fail on a durable backend (a busy
616 // database exhausts its retries) — a session that could not record
617 // its own opening must not exist, so the error surfaces — and because
618 // the two events are one write, a failure leaves the stream *empty*
619 // rather than opened-without-a-grant. There is no half-opened stream
620 // to close on the way out, which is what the earlier best-effort
621 // `session_closed` here was patching over.
622 //
623 // The session is being built, so it cannot have been closed: the
624 // guarded path `append_kernel` takes has nothing to check yet.
625 session.store.append_many(opening).await?;
626 Ok(session)
627 }
628
629 /// Continue an existing session by re-folding its persisted log.
630 ///
631 /// The `store` already holds a session's events (a reopened SQLite
632 /// stream), so resume does *not* append a new `session_opened` — the
633 /// session already opened. It reads the whole log once and restores the
634 /// state from it:
635 ///
636 /// - the scope, from the first `session_opened` event: its [`ScopeId`]
637 /// ([`FIELD_SCOPE_ID`]) and its `owner` ([`FIELD_OWNER`]). An older
638 /// log written before either was recorded falls back — to a fresh
639 /// kernel-issued scope id, and to [`ANON`] — rather than failing the
640 /// resume;
641 /// - the grant, from the last `budget_granted` the log carries, so a
642 /// reopened stream goes on keeping a ledger and a refusal still has a
643 /// `tag` to report. The balance itself needs no restoring: it is
644 /// [`fold_balance`] over the stream, and the stream is right there.
645 ///
646 /// A `grant` passed here is the owner granting *again*: it is appended
647 /// as a new `budget_granted` and raises the restored balance, rather
648 /// than replacing it. Omit it to continue on what is left. Nothing is
649 /// deducted for the earlier `llm_response` usage — an append never
650 /// charged, and what was consumed is a query view's answer over the
651 /// recorded payloads, not the quota's.
652 ///
653 /// A closed stream is not resumed. A session is disposable: it opens
654 /// once and ends once, so a log that already carries its `session_closed`
655 /// is an ending, not a state to continue from — the caller opens a new
656 /// session instead.
657 ///
658 /// Nothing is restored for the read side: a resumed session's reads —
659 /// `events`, `tail`, a query — go to the reopened store, so they see the
660 /// whole stream on the first call.
661 pub async fn resume(grant: Option<BudgetGrant>, store: Box<dyn EventStore>) -> KnlResult<Self> {
662 // The upcasting seam goes on first, so the restore below reads the
663 // same projected shape every other read of this session gets: a log
664 // written under an older shape resumes as what it means today, and the
665 // stored bytes stay as they were written.
666 Self::resume_on(grant, CurrentStore::new(store, kernel_upcasters())).await
667 }
668
669 /// [`Session::resume`] on a store that is already behind the seam.
670 ///
671 /// The body of the resume, split off so a test can hand it a chain of its
672 /// own; the public entry wraps the backend in [`kernel_upcasters`] and
673 /// calls this.
674 async fn resume_on(grant: Option<BudgetGrant>, store: CurrentStore) -> KnlResult<Self> {
675 // Fallible read: a transient busy read or an undecodable row surfaces
676 // here rather than being silently folded into a wrong resumed state.
677 //
678 // Unfiltered, unlike the reads a running session takes: a resume is
679 // restoring the whole of the state, and it is looking for the opening
680 // *whatever it was written as* — the chain may have renamed the kind
681 // on the way through, and a filter selects on the stored name
682 // ([`EventStore::read_kinds`]).
683 let log = store.read(0, usize::MAX).await?;
684
685 // Resuming an empty or mistyped stream is a caller error, not an
686 // anonymous zero session: a real session always opens with a
687 // `session_opened`.
688 let opened = log
689 .iter()
690 .find(|event| event.kind() == KIND_SESSION_OPENED)
691 .ok_or_else(|| {
692 // The caller pointed a resume at a stream that is not a
693 // session — a bad argument, not a damaged log.
694 KnlError::Validation(
695 "stream has no session to resume (no session_opened event)".to_string(),
696 )
697 })?;
698
699 // …and an ended one is not resumed at all. There is no reopening
700 // kind, so any `session_closed` in the stream is the session's
701 // ending: a handle that carried on past it would be appending to a
702 // log whose readers were told nothing more was coming.
703 if has_ended(&store).await? {
704 return Err(KnlError::Closed(format!(
705 "{CLOSED} (disposable; open a new session)"
706 )));
707 }
708
709 // The scope rides on the `data` of `session_opened`, where the
710 // validator requires both halves of it. The fallbacks — the owner to
711 // ANON, the scope id to a fresh kernel-issued one (`Scope::restore`
712 // mints it) — are for a stream an upcaster could not bring all the
713 // way: a session that arrives here missing a field is still a
714 // session, and refusing to resume it would lose the log rather than
715 // protect it.
716 let owner = data_field(opened, FIELD_OWNER)
717 .and_then(Value::as_str)
718 .unwrap_or(ANON)
719 .to_string();
720 let scope_id: Option<ScopeId> = data_field(opened, FIELD_SCOPE_ID)
721 .and_then(Value::as_str)
722 .map(str::to_string);
723
724 // The log was read once already, so seed the balance cache from it
725 // rather than folding the same events again on the first read: the
726 // head it was taken at is the last event's seq (`read` returns events
727 // in seq order). A resume errors above on an empty /
728 // session_opened-less log, so this is a real event's seq; the `0`
729 // fallback is unreachable but keeps it total.
730 let head = log.last().map(Current::seq).unwrap_or(0);
731
732 // The grant comes back off the log: a resumed session keeps having a
733 // budget (and a tag to report) even when the caller grants nothing
734 // new. What is left of it is the fold, seeded just below — over the
735 // whole log, which folds to the same balance as the ledger alone
736 // because nothing else moves it.
737 let mut session = Self {
738 id: uuid::Uuid::new_v4().to_string(),
739 scope: Scope::restore(scope_id, owner, last_grant(&log)),
740 store,
741 balance: Mutex::new((head, fold_balance(&log))),
742 closed: false,
743 };
744
745 // A fresh grant is the owner allowing more, so it is recorded like
746 // any other and *adds* to what was left — and only on a stream that
747 // already keeps a ledger ([`Session::grant_on_resume`]).
748 //
749 // A caller that must vet the restored session *before* anything is
750 // written for it — the Lua bridge, which refuses a reserved owner —
751 // resumes with no grant and calls [`Session::grant_on_resume`] once
752 // the stream has passed.
753 if let Some(grant) = grant {
754 session.grant_on_resume(grant).await?;
755 }
756 Ok(session)
757 }
758
759 /// The owner granting again *on a resume*: record `budget_granted`, but
760 /// only on a stream whose ledger already carries one.
761 ///
762 /// **One session, one budget.** Whether a session has a quota is settled
763 /// when it opens: a stream that opened with a grant keeps a ledger for the
764 /// whole of its life, and a stream that opened without one has no ledger
765 /// and refuses nothing. A resume may raise the first — that is the owner
766 /// allowing more — and may not create the second, because a session that
767 /// opened unbudgeted has handles that were told there is no quota, and a
768 /// ledger appearing underneath them turns "refuses nothing" into "refuses"
769 /// with nobody having asked for it. So a `budget` on a resume of a stream
770 /// with no `budget_granted` is a [`KnlError::Validation`]: the caller
771 /// wanted [`Session::open_on`], and nothing is written.
772 ///
773 /// The question is decided **inside the transaction that would write the
774 /// grant** ([`EventStore::append_if`]), not read beforehand: two resumes
775 /// racing on one stream would otherwise both see an empty ledger and both
776 /// create one.
777 ///
778 /// [`Session::grant_more`] is the other door and keeps no such rule: it is
779 /// the owner acting through a handle it holds, on a session it opened,
780 /// rather than a second handle changing what a first one was told.
781 async fn grant_on_resume(&mut self, grant: BudgetGrant) -> KnlResult<()> {
782 // Before anything is decided: an amount the ledger cannot take is the
783 // caller's own bug, and the log should not carry the evidence twice.
784 budget::check_amount(grant.amount)?;
785 let scope_id = self.scope.id().to_string();
786 let recorded = grant.clone();
787
788 let committed = self
789 .store
790 .append_if(
791 Some(BUDGET_KINDS),
792 Box::new(move |events: Vec<Current>| {
793 last_grant(&events)?;
794 Some(granted_event(&recorded, &scope_id))
795 }),
796 )
797 .await?;
798
799 if committed.is_none() {
800 return Err(KnlError::Validation(
801 "this stream opened with no budget, and a resume does not give one: a session's \
802 quota is settled when it opens (open a new session with the grant, or resume \
803 without one)"
804 .to_string(),
805 ));
806 }
807 self.scope.grant_more(grant)
808 }
809
810 /// The owner granting again: record `budget_granted` and raise the
811 /// balance by it.
812 ///
813 /// The one way the balance rises, and it is a fact in the log before it
814 /// is a number in the counter — a failed append leaves the balance
815 /// exactly as the ledger describes it. Refused on a closed session, like
816 /// every other write: a run that has ended cannot be granted more.
817 ///
818 /// This is the owner acting through a handle it holds, so it takes the
819 /// ledger as it finds it — including a stream that has none, which this
820 /// grant then starts. [`Session::grant_on_resume`] is the other door and
821 /// refuses that case: a *resume* is a second handle arriving at a session
822 /// that already exists, and it does not get to give one a quota it opened
823 /// without.
824 pub async fn grant_more(&mut self, grant: BudgetGrant) -> KnlResult<()> {
825 let event = granted_event(&grant, self.scope.id());
826 self.append_kernel(event).await?;
827 self.scope.grant_more(grant)
828 }
829
830 /// Open a session *from* this one, paying for it out of this session's
831 /// balance — one transaction, both ledgers.
832 ///
833 /// The kernel's whole part in a session tree. It records two facts and
834 /// performs one move:
835 ///
836 /// - the child's `session_opened` carries [`FIELD_PARENT`] — this
837 /// session's stream — and the `budget_granted` it opens with carries it
838 /// too, so where the units came from is in the log beside them;
839 /// - this session's ledger gains a `budget_reserved` naming the child
840 /// ([`FIELD_CHILD`]). An allocation is a *spend* from here: the
841 /// balance falls by exactly what the child's rises by, and nothing is
842 /// returned when the child closes. A refund would be a balance rising
843 /// without an owner granting, which is the one thing the ledger does
844 /// not allow.
845 ///
846 /// All of it is decided and written inside one transaction on this
847 /// session's store ([`EventStore::append_if_many`]), so two children
848 /// asking at once cannot both be given what only one balance covers, and
849 /// no reader ever meets a child that opened without the reservation that
850 /// paid for it.
851 ///
852 /// **The child is on the parent's database.** `child_store` must be a
853 /// store on the same database ([`EventStore::database`]) opened on
854 /// `child_stream`; anything else is a [`KnlError::Validation`] before a
855 /// word is written. A tree spread over two logs could be neither written
856 /// atomically nor read back by one statement, so it is not a tree.
857 ///
858 /// **The child's stream must be empty.** The two events written over
859 /// there are a session's first, so `child_stream` names a stream nothing
860 /// has been written to; one that already carries an event is a
861 /// [`KnlError::Validation`] with nothing written on either side. The
862 /// emptiness is decided inside the same transaction as the rest — the
863 /// decision is shown the other stream's first event along with this
864 /// ledger ([`EventStore::append_if_many`]) — because two allocations
865 /// naming one stream would otherwise both be told it was free and both
866 /// open a session on it.
867 ///
868 /// **A refusal is an error here, not a `false`.** When the balance does
869 /// not cover the allocation, a `budget_refused` naming the child is
870 /// recorded on this session, nothing is opened, and
871 /// [`KnlError::Refused`] is raised: unlike [`Session::reserve`], which is
872 /// asked in a loop that expects to be told no, an allocation either
873 /// produced a session or it did not, and there is no half-opened one to
874 /// hand back.
875 ///
876 /// **The parent must be open.** This handle having closed is refused
877 /// straight away, and a stream whose log already carries an ending is
878 /// refused *inside the transaction* — the decision is shown
879 /// `session_closed` along with the ledger — both as
880 /// [`KnlError::Closed`], the same answer a resume of a closed stream
881 /// gives.
882 ///
883 /// The child comes back as an ordinary session with its scope restored
884 /// from the events just written ([`Session::resume`] over the two of
885 /// them): its balance is the fold of its own ledger, its owner and scope
886 /// are what the opening recorded, and nothing about it is special
887 /// afterwards. What it is *not* is a handle this session holds — the
888 /// parent keeps no pointer, and a supervisor reads the structure back out
889 /// of the log.
890 pub async fn open_child(
891 &mut self,
892 child_stream: String,
893 owner: String,
894 allocation: Allocation,
895 child_store: Box<dyn EventStore>,
896 ) -> KnlResult<Self> {
897 if self.closed {
898 return Err(KnlError::Closed(format!(
899 "{CLOSED} (a child is opened from an open parent)"
900 )));
901 }
902 budget::check_amount(allocation.amount)?;
903
904 // One log, checked before anything is written: the two halves of an
905 // allocation land in one transaction, and a transaction covers one
906 // database.
907 match (self.store.database(), child_store.database()) {
908 (Some(parent), Some(child)) if parent == child => {}
909 (Some(parent), Some(child)) => {
910 return Err(KnlError::Validation(format!(
911 "a child opens on its parent's database, and a tree is one log: the parent is \
912 on {parent:?} and the child was given {child:?}"
913 )));
914 }
915 _ => {
916 return Err(KnlError::Validation(
917 "a child opens on its parent's database, and one of the two stores keeps a \
918 single stream with no database to share"
919 .to_string(),
920 ));
921 }
922 }
923
924 let amount = allocation.amount;
925 // The units come out of this ledger, so they are counted in its unit
926 // unless the caller renamed them for the child.
927 let parent_tag = self.scope.grant().and_then(|grant| grant.tag.clone());
928 let child_tag = allocation.tag.clone().or_else(|| parent_tag.clone());
929 let parent_scope = self.scope.id().to_string();
930 let parent_id = self.id.clone();
931 let child_id = child_stream.clone();
932
933 // The child's scope is issued before its first event, exactly as
934 // `open_on` issues one. The value is not kept: the handle below is
935 // built by a resume, which restores the scope from the very
936 // `session_opened` this id is about to be written onto.
937 let child_scope_id = Scope::new(owner.clone(), None).id().to_string();
938
939 // What the decision saw, carried out of it: the decision runs on the
940 // store's own thread, so the balance a refusal reports, the ending it
941 // found and the stream it found already in use come back through cells
942 // rather than through the events it returns.
943 let ended = Arc::new(AtomicBool::new(false));
944 let occupied = Arc::new(AtomicBool::new(false));
945 let refused = Arc::new(AtomicBool::new(false));
946 let measured = Arc::new(AtomicI64::new(0));
947 let (found_ending, found_events, said_no, balance_seen) = (
948 Arc::clone(&ended),
949 Arc::clone(&occupied),
950 Arc::clone(&refused),
951 Arc::clone(&measured),
952 );
953
954 let committed = self
955 .store
956 .append_if_many(
957 &child_stream,
958 Some(ALLOCATION_KINDS),
959 Box::new(move |seen: Split<Current>| {
960 // A child opens once, on a stream of its own. The two
961 // events written over there are a session's *first*, so a
962 // stream that already carries any is not a child being
963 // opened but an existing log being written into — a second
964 // `session_opened` under a scope its earlier handles never
965 // heard of, and a `budget_granted` nobody's owner allowed.
966 //
967 // Inside the invariant for the same reason the ending is:
968 // asked before the transaction, two allocations naming one
969 // stream would both be told it was empty. Nothing is
970 // written on either side — this is the caller having given
971 // a bad argument, like a child store on another database,
972 // and there is no fact about the parent's ledger in it.
973 if !seen.other.is_empty() {
974 found_events.store(true, Ordering::Relaxed);
975 return None;
976 }
977 let events = seen.own;
978 // The ending is part of the invariant, not a check taken
979 // beforehand: a parent that closed between the read and
980 // the write would otherwise get a child anyway.
981 if events.iter().any(|e| e.kind() == KIND_SESSION_CLOSED) {
982 found_ending.store(true, Ordering::Relaxed);
983 return None;
984 }
985 // No grant on the parent is no ledger to measure against
986 // — the same rule `reserve` follows — so the allocation
987 // is allowed and recorded, and the fold ignores a
988 // reservation with nothing granted before it.
989 if let Some(balance) = fold_balance(&events) {
990 if balance < amount {
991 said_no.store(true, Ordering::Relaxed);
992 balance_seen.store(balance, Ordering::Relaxed);
993 return Some(Split::own(vec![allocation_refused_event(
994 amount,
995 balance,
996 parent_tag.as_deref(),
997 &parent_scope,
998 &child_id,
999 )]));
1000 }
1001 }
1002 Some(Split {
1003 own: vec![allocated_event(
1004 amount,
1005 parent_tag.as_deref(),
1006 &parent_scope,
1007 &child_id,
1008 )],
1009 other: vec![
1010 child_opened_event(&owner, &child_scope_id, &parent_id),
1011 child_granted_event(
1012 amount,
1013 child_tag.as_deref(),
1014 &child_scope_id,
1015 &parent_id,
1016 ),
1017 ],
1018 })
1019 }),
1020 )
1021 .await?;
1022
1023 // Read before the ending is, because a decision that found the child's
1024 // stream occupied returned before it looked at the parent at all: the
1025 // stream was the wrong argument, and reporting it as a closed parent
1026 // would send the caller after the wrong thing.
1027 if occupied.load(Ordering::Relaxed) {
1028 return Err(KnlError::Validation(format!(
1029 "a child opens on a stream of its own, and {child_stream:?} already has events on \
1030 it: pass a stream nothing has been written to (nothing was written here, on \
1031 either side)"
1032 )));
1033 }
1034 if committed.is_none() || ended.load(Ordering::Relaxed) {
1035 return Err(KnlError::Closed(format!(
1036 "{CLOSED} (the parent's log already carries its ending)"
1037 )));
1038 }
1039 if refused.load(Ordering::Relaxed) {
1040 let balance = measured.load(Ordering::Relaxed);
1041 return Err(KnlError::Refused(format!(
1042 "the parent's balance is {balance}, which does not cover an allocation of \
1043 {amount}; the refusal is in the log and no child was opened"
1044 )));
1045 }
1046
1047 // The opening and the grant are committed, so the child's stream is a
1048 // session: resuming it restores the scope and folds the balance out
1049 // of the two events that were just written for it.
1050 let mut child = Self::resume(None, child_store).await?;
1051 child.adopt_id(child_stream);
1052 Ok(child)
1053 }
1054
1055 /// The session-correlation id.
1056 pub fn id(&self) -> &str {
1057 &self.id
1058 }
1059
1060 /// Adopt `id` as the session-correlation id.
1061 ///
1062 /// Used so a session and the SQLite stream it writes to share one id:
1063 /// `open_on` / `resume` mint a fresh id, and the caller that opened the
1064 /// stream overrides it to that stream, so the id a caller resumes by *is*
1065 /// the stream — and so `$stream` in a [`Session::query`] means this
1066 /// session's own rows. `session_opened` records the scope (its id and
1067 /// the `owner`), never the session id, so overriding the id does not
1068 /// desync the log.
1069 pub(crate) fn adopt_id(&mut self, id: impl Into<String>) {
1070 self.id = id.into();
1071 }
1072
1073 /// The scope this session is written under.
1074 ///
1075 /// The scope and the session are two things with one lifetime: this is
1076 /// the authority half — the id, the owner, the granted quota — while the
1077 /// session is the stream. [`Session::owner`] / [`Session::scope_id`]
1078 /// read through to it for the two call sites that want one field.
1079 pub fn scope(&self) -> &Scope {
1080 &self.scope
1081 }
1082
1083 /// The scope's kernel-issued id, as recorded on `session_opened` and on
1084 /// every `budget_*` event.
1085 ///
1086 /// Not the session id: [`Session::id`] names the stream, this names the
1087 /// authority the stream is written under.
1088 pub fn scope_id(&self) -> &str {
1089 self.scope.id()
1090 }
1091
1092 /// Whose scope this is (a principal id, or [`ANON`] / [`SYSTEM`]).
1093 pub fn owner(&self) -> &str {
1094 self.scope.owner()
1095 }
1096
1097 /// The database this session's log lives in, or `None` for a backend that
1098 /// is not one ([`EventStore::database`]).
1099 ///
1100 /// Published for one caller: whoever opens a child has to open its store
1101 /// on the parent's database, and asking the parent is how it knows which
1102 /// that is ([`Session::open_child`] refuses any other). It is an
1103 /// identity to pass along, not a location to take apart.
1104 pub fn database(&self) -> Option<&str> {
1105 self.store.database()
1106 }
1107
1108 /// Record an event, returning its `seq`. The one write path.
1109 ///
1110 /// Any kind is welcome, the reserved ones included, as long as it meets
1111 /// the shape its kind requires and is not one of the kernel's own
1112 /// ([`is_kernel_only`]). The kernel-owned `seq` / `epoch_ms` are
1113 /// stamped here and overwrite any caller-supplied value; nothing else
1114 /// is added, and a `beat` the caller declared is recorded as given.
1115 ///
1116 /// No append moves the budget, this one included. A deduction is asked
1117 /// for before a call ([`Session::reserve`]) or taken after it
1118 /// ([`Session::spend`]) by the layer that knows what a call costs; the
1119 /// history records what happened and says nothing about what was
1120 /// allowed.
1121 ///
1122 /// The session's own boundaries are not appendable: `session_opened` and
1123 /// `session_closed` are written by [`Session::open_on`] and
1124 /// [`Session::close`], and a caller asking for either is refused.
1125 ///
1126 /// The append lands. The store assigns the `seq` and serializes the
1127 /// write per stream, so two handles on one stream both record and the log
1128 /// interleaves in the order the writes arrived. A stale view of the head
1129 /// is not a reason to refuse a fact: [`Session::head`] is what this handle
1130 /// last saw, and nothing is compared against it.
1131 ///
1132 /// The one refusal is this handle having closed. Another handle's close
1133 /// is not: it set *that* handle's flag, and a write landing after the
1134 /// `session_closed` it wrote is recorded, because that is what happened.
1135 pub async fn append(&mut self, event: Map<String, Value>) -> KnlResult<u64> {
1136 // The kernel's own kinds are refused before the closed check has
1137 // anything to say about it — the kind is wrong whatever state the
1138 // session is in. The balance is a fold of the `budget_*` events, so
1139 // accepting one from a caller would be letting it grant itself the
1140 // quota its owner set; the two `session_*` events are the lifecycle
1141 // a resume and an audit read off the log.
1142 let kind = event.get(FIELD_KIND).and_then(Value::as_str).unwrap_or("");
1143 if is_kernel_only(kind) {
1144 return Err(KnlError::Validation(format!(
1145 "{kind:?} is written by the kernel only ({})",
1146 kernel_only_hint(kind)
1147 )));
1148 }
1149 self.append_kernel(event).await
1150 }
1151
1152 /// [`Session::append`] without the kernel-only guard: the path the
1153 /// kernel's own writes take.
1154 ///
1155 /// Same validation, same stamping, same serialized write — the only
1156 /// difference is that this one may write the kernel-only kinds, which is
1157 /// what makes "the kernel wrote it" a property of the code path rather
1158 /// than of a field a caller could set.
1159 ///
1160 /// A plain append, with no invariant to decide: the only refusal is the
1161 /// handle's own `closed` flag, checked here before the store is touched.
1162 /// The store is not asked whether the stream has ended, because a write
1163 /// arriving after an ending is a fact — evidence of a bug or a misuse —
1164 /// and dropping it would hide the one thing an audit is reading for.
1165 async fn append_kernel(&mut self, event: Map<String, Value>) -> KnlResult<u64> {
1166 if self.closed {
1167 return Err(KnlError::Closed(CLOSED.to_string()));
1168 }
1169
1170 // The store orders the write and hands back where it landed.
1171 let committed = self.store.append(event).await?;
1172 Ok(committed.seq)
1173 }
1174
1175 /// Events with `seq >= from`, at most `limit`, cloned, at the current
1176 /// shape.
1177 ///
1178 /// They come back as [`Current`]s: the read went through the upcaster
1179 /// seam, and the type says so, so a caller folding them cannot be folding
1180 /// a shape that has been superseded. A caller that needs to own the
1181 /// underlying object — the Lua bridge, building tables — takes it with
1182 /// [`Current::into_inner`].
1183 ///
1184 /// **The caller says how much it will take.** A stream grows without
1185 /// bound, and every event of it read here is decoded, upcasted, cloned and
1186 /// (across the bridge) turned into a Lua table — so an unbounded read is
1187 /// an unbounded allocation on the VM's own thread. `limit` is the
1188 /// caller's answer to that; the shell reads a page at a time and pages
1189 /// with `from` ([`super::query::DEFAULT_LIMIT`] is what the bridge asks
1190 /// for). `usize::MAX` is still spelled out where a caller really does
1191 /// want the whole stream — a restore fold, a test — and says so at the
1192 /// call.
1193 ///
1194 /// Fallible: a durable backend can hit a transient busy read or a row it
1195 /// cannot decode, which surfaces here rather than being dropped silently.
1196 /// The in-memory backend is always `Ok`.
1197 pub async fn events(&self, from: u64, limit: usize) -> KnlResult<Vec<Current>> {
1198 self.store.read(from, limit).await
1199 }
1200
1201 /// Number of recorded events. Fallible like [`Session::events`].
1202 pub async fn len(&self) -> KnlResult<usize> {
1203 self.store.len().await
1204 }
1205
1206 /// Whether the history is empty (only before `session_opened`, i.e.
1207 /// never for a session built by [`Session::new`]).
1208 pub async fn is_empty(&self) -> KnlResult<bool> {
1209 self.store.is_empty().await
1210 }
1211
1212 /// Ask the budget to allow `amount`: `true` when it was deducted, `false`
1213 /// when the balance would not cover it (and nothing was deducted).
1214 ///
1215 /// The stop the budget exists for. A caller asks before it spends, and
1216 /// a `false` is a planned halt with the balance untouched — not a
1217 /// failure, and not a state the run has to be rolled back out of.
1218 ///
1219 /// **Whether there is a budget at all is the log's answer, not this
1220 /// handle's.** The decision is shown the stream's `budget_*` events, and
1221 /// a ledger with no `budget_granted` in it is a run with no quota: nothing
1222 /// is decided, nothing is recorded, and the answer is `true`. A grant the
1223 /// log *does* carry is honoured even by a handle that was opened without
1224 /// one — two handles on one stream cannot disagree about whether it has a
1225 /// budget, because neither of them is asked. The scope's cached grant is
1226 /// a hint about the words (§ [`Session::grant`]) and never the authority.
1227 ///
1228 /// **Both answers are recorded, by the same decision.** A
1229 /// `budget_reserved` when the balance covered it, a `budget_refused`
1230 /// (carrying what was asked for and what there was) when it did not —
1231 /// whichever the decision built lands in the transaction that took it, so
1232 /// exactly one of the two is in the log and this call's answer is which
1233 /// one that was. Recording the refusal afterwards, as a second append,
1234 /// made a refusal that could not be written indistinguishable from a
1235 /// storage failure with nothing decided.
1236 ///
1237 /// This is a *command with an invariant*, so the decision is taken inside
1238 /// the store ([`EventStore::append_if`]): the backend hands the ledger to
1239 /// [`fold_balance`] and writes the decision's event in the same serialized
1240 /// write, so two handles on one stream cannot both reserve the same
1241 /// allowance. Nothing is set afterwards: the write moved the store's
1242 /// head, so the next read of [`Session::remaining`] refolds the ledger the
1243 /// entry is now part of.
1244 ///
1245 /// A handle that has closed refuses, like [`Session::spend`]; another
1246 /// handle's close is nothing to this one — the balance is the whole of the
1247 /// invariant.
1248 pub async fn reserve(&mut self, amount: i64) -> KnlResult<bool> {
1249 if self.closed {
1250 return Err(KnlError::Closed(CLOSED.to_string()));
1251 }
1252 budget::check_amount(amount)?;
1253 let scope_id = self.scope.id().to_string();
1254
1255 // Which of the two entries the decision built, carried back out of
1256 // it: the decision runs wherever the store serializes its writes —
1257 // the connection's own thread — so what it decided comes back through
1258 // a cell rather than through the `Committed` it returns, exactly as
1259 // [`Session::open_child`] carries its own refusal back. It is set at
1260 // the moment the refusal event is built, and that event is the one
1261 // the transaction writes, so the flag *is* which kind landed.
1262 let refused = Arc::new(AtomicBool::new(false));
1263 let said_no = Arc::clone(&refused);
1264 let decided_scope = scope_id.clone();
1265 // The whole of the decision: is there a ledger, and does it cover
1266 // what was asked. Whether the stream carries an ending is not part
1267 // of it — a reservation past the boundary is a fact about a run that
1268 // overran its own close, and the log is where facts go.
1269 //
1270 // The decision names the kinds it folds, so the store hands it the
1271 // ledger and not the whole stream: the invariant is exact either way,
1272 // and this way it costs the size of the ledger.
1273 let committed = self
1274 .store
1275 .append_if(
1276 Some(BUDGET_KINDS),
1277 Box::new(move |events: Vec<Current>| {
1278 // No `budget_granted` in the log is no ledger to move, so
1279 // there is nothing to decide and nothing to record. The
1280 // grant is also where the unit comes from: the tag is read
1281 // off the log's own last grant rather than off whatever
1282 // this handle happens to remember.
1283 let grant = last_grant(&events)?;
1284 let balance = fold_balance(&events).unwrap_or(0);
1285 if balance >= amount {
1286 return Some(budget_move_event(
1287 KIND_BUDGET_RESERVED,
1288 amount,
1289 grant.tag.as_deref(),
1290 &decided_scope,
1291 ));
1292 }
1293 said_no.store(true, Ordering::Relaxed);
1294 Some(refused_event(
1295 amount,
1296 balance,
1297 grant.tag.as_deref(),
1298 &decided_scope,
1299 ))
1300 }),
1301 )
1302 .await?;
1303
1304 // A refusal that landed is the only `false`. Nothing committed means
1305 // the log carries no ledger, which is the run with no quota: it is
1306 // allowed, and there is nothing to write about it.
1307 match (committed, refused.load(Ordering::Relaxed)) {
1308 (Some(_), true) => Ok(false),
1309 _ => Ok(true),
1310 }
1311 }
1312
1313 /// Deduct `amount` from the budget without asking.
1314 ///
1315 /// The other half of [`Session::reserve`], and an independent one: a
1316 /// reserve is a deduction that *refuses* when the balance is short, a
1317 /// spend is a deduction that does not ask — it floors at `0` rather than
1318 /// refusing. Neither holds anything for the other to release, so calling
1319 /// both for one beat deducts twice; the layer above decides which of them
1320 /// a beat uses. It is recorded as a `budget_spent`, which is the whole of
1321 /// the move.
1322 ///
1323 /// **Whether there is a budget at all is the log's answer**, exactly as it
1324 /// is for [`Session::reserve`]: the decision is shown the ledger, and a
1325 /// stream with no `budget_granted` in it has no account to move, so
1326 /// nothing is written. That question is inside the same transaction as
1327 /// the write for the same reason the balance is — a handle's memory of
1328 /// what it opened with is not what the ledger says.
1329 ///
1330 /// There is no *balance* invariant to hold, so a spend is never refused
1331 /// for what the account holds. What the decision decides is only whether
1332 /// there is an account.
1333 ///
1334 /// **The write is the result.** It used to hand back the balance it read
1335 /// afterwards, which made a `spend` that landed and then failed its
1336 /// read-back indistinguishable from one that never landed: the caller got
1337 /// an error either way and could not tell whether the deduction was in
1338 /// the log. Two questions, two calls — this one says the move was
1339 /// recorded, and [`Session::remaining`] says what is left, failing on its
1340 /// own terms.
1341 ///
1342 /// A handle that has closed refuses before the store is reached; another
1343 /// handle's close does not, and a deduction landing after one is recorded
1344 /// as what it is.
1345 pub async fn spend(&mut self, amount: i64) -> KnlResult<()> {
1346 if self.closed {
1347 return Err(KnlError::Closed(CLOSED.to_string()));
1348 }
1349 budget::check_amount(amount)?;
1350 let scope_id = self.scope.id().to_string();
1351
1352 self.store
1353 .append_if(
1354 Some(BUDGET_KINDS),
1355 Box::new(move |events: Vec<Current>| {
1356 // No `budget_granted` in the log is no ledger to move, and
1357 // the log's own last grant is where the unit comes from.
1358 let grant = last_grant(&events)?;
1359 Some(budget_move_event(
1360 KIND_BUDGET_SPENT,
1361 amount,
1362 grant.tag.as_deref(),
1363 &scope_id,
1364 ))
1365 }),
1366 )
1367 .await?;
1368 Ok(())
1369 }
1370
1371 /// The grant this run opened (or resumed) with, if any.
1372 ///
1373 /// Read for its words — the `tag` a caller reports when a reservation
1374 /// is refused — and for its presence, which is what says this session
1375 /// keeps a ledger at all. The amount on it is the *last* grant, not
1376 /// what is left: that is [`Session::remaining`].
1377 pub fn grant(&self) -> Option<&BudgetGrant> {
1378 self.scope.grant()
1379 }
1380
1381 /// The remaining balance: `Ok(None)` without a budget.
1382 ///
1383 /// The ledger's answer, not a counter's: [`fold_balance`] over the
1384 /// stream, so a handle that has written nothing still sees what another
1385 /// handle spent. The fold is cached against the store's head and retaken
1386 /// only when the head has moved, so a read on a quiet stream costs one
1387 /// head query.
1388 ///
1389 /// Fallible, and deliberately so. A store that cannot be read has *no*
1390 /// answer to give, and the two answers this call can otherwise hand back
1391 /// — the last fold, or `None` — both read as facts about the budget:
1392 /// "you have this much" and "there is no budget here". Serving either
1393 /// off a failed read would fold a failure into a value, and the caller
1394 /// most likely to act on it is a loop deciding whether it may go on
1395 /// spending. So the failure surfaces, and what to do about a transient
1396 /// busy read ([`KnlError::is_retryable`]) is the caller's to decide.
1397 pub async fn remaining(&self) -> KnlResult<Option<i64>> {
1398 // Copied out and the guard released: nothing below waits while it is
1399 // held.
1400 let (folded_head_seq, cached) =
1401 *self.balance.lock().unwrap_or_else(PoisonError::into_inner);
1402
1403 let head = self.store.head().await?.unwrap_or(0);
1404 // The log has not moved since the fold, so neither has the balance.
1405 if head <= folded_head_seq {
1406 return Ok(cached);
1407 }
1408
1409 // Only the ledger is folded — the balance is a fold of the `budget_*`
1410 // kinds and nothing else — while the *head* it is recorded against is
1411 // the whole stream's, so any write at all makes the next read refold.
1412 // Conservative in the safe direction: an event that moves no balance
1413 // costs one extra fold, never a stale answer.
1414 let ledger = self
1415 .store
1416 .read_kinds(Some(BUDGET_KINDS), 0, usize::MAX)
1417 .await?;
1418 let balance = fold_balance(&ledger);
1419 *self.balance.lock().unwrap_or_else(PoisonError::into_inner) = (head, balance);
1420 Ok(balance)
1421 }
1422
1423 /// Whether the budget is used up (never true without a budget).
1424 ///
1425 /// The same fold [`Session::remaining`] reads, asked as a question — and
1426 /// fallible for the same reason: a `false` that meant "the store could
1427 /// not be read" is the one answer a run must never be given, because it
1428 /// reads as "carry on".
1429 pub async fn exhausted(&self) -> KnlResult<bool> {
1430 Ok(matches!(self.remaining().await?, Some(remaining) if remaining <= 0))
1431 }
1432
1433 /// Whether the session has ended.
1434 pub fn is_closed(&self) -> bool {
1435 self.closed
1436 }
1437
1438 /// End the session, recording `session_closed` with `reason`
1439 /// (defaulting to [`DEFAULT_CLOSE_REASON`]).
1440 ///
1441 /// Idempotent *per handle*: closing a session this handle already closed
1442 /// records nothing. Another handle closing the same stream is a second
1443 /// ending in the log — the truthful record of two handles both believing
1444 /// they owned the session, and the shape an audit needs to see.
1445 ///
1446 /// **Open children are recorded, never a refusal.** In the same write,
1447 /// the store looks for the streams that name this session as their parent
1448 /// and carry no ending of their own ([`Session::open_child`]); if it
1449 /// finds any, their ids go on the boundary as
1450 /// `data.open_children`. The close still succeeds — the log turns no
1451 /// write away, and a run that ended while what it started was still going
1452 /// is exactly the fact worth having in it.
1453 ///
1454 /// Fallible on a durable backend: the `session_closed` append can fail on
1455 /// a database that stays contended past its retries, or a store that is
1456 /// gone. On failure the session stays open (closed is not set), so the
1457 /// caller knows the boundary was NOT recorded and can retry — a close
1458 /// that reports success with no `session_closed` in the log would
1459 /// silently break resume/audit reads.
1460 pub async fn close(&mut self, reason: Option<&str>) -> KnlResult<()> {
1461 self.close_with(reason, None).await
1462 }
1463
1464 /// [`Session::close`] with a free-text `detail` recorded beside the
1465 /// reason.
1466 ///
1467 /// The reason names *which kind of ending* this was, and stays a short
1468 /// vocabulary a reader can fold on; `detail` is the sentence that only
1469 /// this close can tell — the message of the error that ended the scope.
1470 /// Keeping them apart is what stops every distinct error message from
1471 /// becoming its own reason.
1472 ///
1473 /// Idempotent and fallible exactly like [`Session::close`].
1474 pub async fn close_with(
1475 &mut self,
1476 reason: Option<&str>,
1477 detail: Option<&str>,
1478 ) -> KnlResult<()> {
1479 if self.closed {
1480 return Ok(());
1481 }
1482 // Owned, because the event is built on the store's own thread: the
1483 // decision below travels there with the scan it is answered from.
1484 let reason = reason.map(str::to_string);
1485 let detail = detail.map(str::to_string);
1486
1487 // The boundary and the scan that finds this session's open children
1488 // are one write. A close is never refused for them — the log turns
1489 // nothing away, and a run that ended while what it started was still
1490 // going is the fact an audit is reading for — so what the scan
1491 // produces is recorded on the event rather than raised at the caller.
1492 //
1493 // Kernel-only kinds do not go through the guarded `append`, which
1494 // would refuse the very event that ends the session; this path is the
1495 // kernel's own, like `append_kernel`, and carries the same `closed`
1496 // check above.
1497 //
1498 // The flag moves only after the boundary landed, so a failed write
1499 // leaves this handle open and the caller free to retry — a close that
1500 // reported success with nothing in the log would break every later
1501 // read of it.
1502 self.store
1503 .append_with_open_children(
1504 &child_scan(),
1505 Box::new(move |children| {
1506 closing_event(reason.as_deref(), detail.as_deref(), children)
1507 }),
1508 )
1509 .await?;
1510 self.closed = true;
1511 Ok(())
1512 }
1513
1514 /// End the session by handing `session_closed` to the store and *not*
1515 /// waiting for it — the drop backstop.
1516 ///
1517 /// The one close path with nobody left to tell. A handle that was
1518 /// collected without ever being closed is being dropped right now, on the
1519 /// VM's own thread, inside a Lua collection cycle: there is no task to
1520 /// suspend in and nothing that may block, so the event goes to the store's
1521 /// own writer ([`super::EventStore::detach_append`]) and whether it landed
1522 /// is reported to the log rather than to a caller.
1523 ///
1524 /// The connection thread outlives this handle — its driver belongs to the
1525 /// host, not to the session ([`super::IsleDrivers`]) — so the submitted
1526 /// event is still executed, and the host's shutdown drains it.
1527 ///
1528 /// Idempotent per handle, like [`Session::close`]: a session this handle
1529 /// already closed records nothing.
1530 pub fn close_detached(&mut self, reason: &str) {
1531 if self.closed {
1532 return;
1533 }
1534 self.store
1535 .detach_append(closing_event(Some(reason), None, Vec::new()));
1536 self.closed = true;
1537 }
1538
1539 /// A named projection over the history.
1540 ///
1541 /// `tail` is the only name, and it reads `opts.n` (default
1542 /// [`projection::DEFAULT_TAIL_N`]) events from the end. An unknown name
1543 /// is an error — the vocabulary is closed on purpose, and it is as short
1544 /// as it goes: a projection whose shape depends on what the caller does
1545 /// with it is built above the kernel, from [`Session::events`] or with
1546 /// SQL over the published schema ([`Session::query`]). The token
1547 /// account is one of those now: it reads the `llm_response` payload,
1548 /// which is the shell's vocabulary and not the kernel's.
1549 ///
1550 /// `&mut self` because the signature belongs to the vocabulary rather
1551 /// than to today's members of it — a fold the kernel names again may
1552 /// keep a cache, and a caller should not have to be recompiled when one
1553 /// does.
1554 pub async fn view(
1555 &mut self,
1556 name: &str,
1557 opts: Option<&Map<String, Value>>,
1558 ) -> KnlResult<Value> {
1559 match name {
1560 VIEW_TAIL => {
1561 let n = tail_count(opts)?;
1562 // Asked for from the end, not sliced off the front: the store
1563 // reads `n` rows ([`super::event_store::EventStore::read_last`])
1564 // rather than handing over the whole stream for the projection
1565 // to throw most of away. `tail_of` still renders the value,
1566 // and on a slice that is already at most `n` long it is the
1567 // identity — the cut is in the read now.
1568 let events = self.store.read_last(n).await?;
1569 Ok(projection::tail_of(&events, n))
1570 }
1571 other => Err(KnlError::Validation(format!("unknown view {other:?}"))),
1572 }
1573 }
1574
1575 /// Read the log with SQL.
1576 ///
1577 /// The other half of [`Session::view`], and the reason that list of names
1578 /// can stay short: a fold whose shape is the caller's — beats grouped,
1579 /// tool calls paired against their results, a ledger — is a `SELECT`
1580 /// against the table the log lives in, not a name the kernel had to be
1581 /// taught. What the kernel keeps is the boundary around it
1582 /// ([`super::query`]): one statement, and it reads; a connection that
1583 /// cannot write; values bound rather than pasted; a deadline; a row cap.
1584 ///
1585 /// Two names are reserved. `$stream` is this session's own stream, and
1586 /// `$sessions` is the set in `opts.sessions` — the session's own stream
1587 /// when that is omitted — expanded to one bound placeholder per id, so
1588 /// reading across a tree of sessions is one statement rather than a loop
1589 /// of them. The kernel does not judge the set: which streams a caller
1590 /// may read is a question about who the caller is, and that lives above
1591 /// the kernel.
1592 ///
1593 /// Reads keep working after this handle closed, like every other read
1594 /// here: the record outlives the session.
1595 pub async fn query(
1596 &self,
1597 sql: &str,
1598 params: QueryParams,
1599 opts: &QueryOpts,
1600 ) -> KnlResult<QueryRows> {
1601 let plan = query::plan(sql, params, opts, &self.id)?;
1602 self.store.query(&plan).await
1603 }
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608 use super::*;
1609 use crate::knl::event::{kind_of, FIELD_BEAT, FIELD_DATA};
1610 // The `Vec`-backed test store: the SPI, the seam and the folds are worth
1611 // exercising without a database underneath, and the failure injection
1612 // below is easier to build on a `Vec` than on a connection.
1613 use crate::knl::event_store::MemEventStore;
1614 use serde_json::json;
1615
1616 /// Object map for an event literal.
1617 fn obj(value: Value) -> Map<String, Value> {
1618 match value {
1619 Value::Object(map) => map,
1620 other => panic!("test fixture must be an object, got {other}"),
1621 }
1622 }
1623
1624 /// A grant of `amount`, tagged the way the shell tags one.
1625 fn grant(amount: i64) -> BudgetGrant {
1626 BudgetGrant {
1627 amount,
1628 tag: Some("tokens".to_string()),
1629 desc: None,
1630 }
1631 }
1632
1633 /// A session owned by the reserved anonymous principal.
1634 ///
1635 /// With a budget it opens with two events, not one: `session_opened` and
1636 /// the `budget_granted` that records what the owner allowed.
1637 ///
1638 /// The [`IsleDrivers`] it opens against is thrown away on the spot, and
1639 /// that is safe here: the connection thread lives while *any* handle on it
1640 /// does, and the store keeps one. What the discarded driver costs is the
1641 /// join at the end — which a test process does not need and a host does.
1642 async fn new_session(budget: Option<i64>) -> Session {
1643 Session::new(ANON.to_string(), budget.map(grant), &IsleDrivers::new())
1644 .await
1645 .expect("open")
1646 }
1647
1648 /// The balance the log implies, for checking the counter against it.
1649 async fn folded(s: &Session) -> Option<i64> {
1650 fold_balance(&s.events(0, usize::MAX).await.expect("events"))
1651 }
1652
1653 /// A raw backend read, as the folds take it.
1654 ///
1655 /// The tests that verify a durable stream reopen the backend directly —
1656 /// outside the seam, on purpose, to see what was really written — so
1657 /// they say where their `Current`s come from.
1658 fn as_current(events: Vec<Value>) -> Vec<Current> {
1659 events.into_iter().map(Current::assume_current).collect()
1660 }
1661
1662 /// The kinds of `events`, in order.
1663 fn kinds(events: &[Current]) -> Vec<&str> {
1664 events.iter().map(Current::kind).collect()
1665 }
1666
1667 /// The balance, as a test that is not about failure reads it.
1668 ///
1669 /// [`Session::remaining`] is fallible because a store that cannot be read
1670 /// has no balance to report; the stores these tests drive do not fail, so
1671 /// an error here is a broken fixture rather than an outcome to assert on.
1672 /// The tests that *are* about a failing store call the method directly.
1673 async fn remaining(session: &Session) -> Option<i64> {
1674 session.remaining().await.expect("the balance was readable")
1675 }
1676
1677 /// [`Session::exhausted`], read the same way and for the same reason.
1678 async fn exhausted(session: &Session) -> bool {
1679 session.exhausted().await.expect("the balance was readable")
1680 }
1681
1682 /// The `budget_*` events of a session, in seq order.
1683 async fn ledger(s: &Session) -> Vec<Current> {
1684 s.events(0, usize::MAX)
1685 .await
1686 .expect("events")
1687 .into_iter()
1688 .filter(|e| e.kind().starts_with("budget_"))
1689 .collect()
1690 }
1691
1692 /// An `llm_response` event charging `tokens`.
1693 ///
1694 /// A kind of the shell's, so its shape is the shell's too: the kernel
1695 /// takes the envelope and keeps whatever is under `data` verbatim.
1696 fn response(tokens: i64) -> Map<String, Value> {
1697 obj(json!({
1698 "kind": "llm_response",
1699 "data": {
1700 "content": [{ "type": "text", "text": "ok" }],
1701 "usage": { "input_tokens": tokens },
1702 "stop_reason": "end_turn"
1703 }
1704 }))
1705 }
1706
1707 /// A `data` field of a recorded event, for the assertions below.
1708 fn field<'a>(event: &'a Current, name: &str) -> &'a Value {
1709 data_field(event, name).unwrap_or_else(|| panic!("data.{name} is missing: {event}"))
1710 }
1711
1712 #[tokio::test]
1713 async fn a_new_session_already_carries_session_opened() {
1714 let s = new_session(None).await;
1715 assert_eq!(s.len().await.expect("len"), 1);
1716 let events = s.events(0, usize::MAX).await.expect("events");
1717 assert_eq!(events[0].kind(), KIND_SESSION_OPENED);
1718 assert_eq!(events[0].seq(), 1);
1719 assert!(!s.is_closed());
1720 assert!(!s.id().is_empty());
1721 }
1722
1723 /// The scope is issued when the session opens, and it is not the
1724 /// session: two ids, both real, and neither taken from a caller. Two
1725 /// sessions are two scopes.
1726 #[tokio::test]
1727 async fn open_issues_a_scope_id_distinct_from_the_session_id() {
1728 let a = new_session(None).await;
1729 let b = new_session(None).await;
1730
1731 assert!(!a.scope_id().is_empty(), "a session opens under a scope");
1732 assert!(!a.id().is_empty());
1733 assert_ne!(
1734 a.scope_id(),
1735 a.id(),
1736 "the scope id names the authority, the session id names the stream"
1737 );
1738 assert_eq!(a.scope().id(), a.scope_id(), "the delegate reads the scope");
1739 assert_eq!(a.scope().owner(), a.owner(), "and so does the owner");
1740
1741 assert_ne!(a.scope_id(), b.scope_id(), "two sessions, two scopes");
1742 assert_ne!(a.id(), b.id());
1743 }
1744
1745 /// The scope is in the log, not only in the value: it rides on the
1746 /// session's opening and on every entry of the ledger, so a reader can
1747 /// tell whose authority each move of the balance was made under.
1748 #[tokio::test]
1749 async fn session_opened_and_every_budget_event_carry_the_scope_id() {
1750 let mut s = new_session(Some(100)).await;
1751 assert_eq!(s.reserve(30).await, Ok(true));
1752 s.spend(10).await.expect("spend");
1753 assert_eq!(s.reserve(10_000).await, Ok(false));
1754
1755 let scope_id = s.scope_id().to_string();
1756 let events = s.events(0, usize::MAX).await.expect("events");
1757
1758 let opened = &events[0];
1759 assert_eq!(opened.kind(), KIND_SESSION_OPENED);
1760 assert_eq!(
1761 field(opened, FIELD_SCOPE_ID).as_str(),
1762 Some(scope_id.as_str()),
1763 "the scope rides on session_opened: {opened}"
1764 );
1765 assert_eq!(
1766 field(opened, FIELD_OWNER).as_str(),
1767 Some(ANON),
1768 "beside the owner: {opened}"
1769 );
1770
1771 let moves = ledger(&s).await;
1772 assert_eq!(
1773 kinds(&moves),
1774 vec![
1775 KIND_BUDGET_GRANTED,
1776 KIND_BUDGET_RESERVED,
1777 KIND_BUDGET_SPENT,
1778 KIND_BUDGET_REFUSED,
1779 ],
1780 "every kind of move is exercised"
1781 );
1782 for event in &moves {
1783 assert_eq!(
1784 field(event, FIELD_SCOPE_ID).as_str(),
1785 Some(scope_id.as_str()),
1786 "a ledger entry must name the scope it was allowed under: {event}"
1787 );
1788 }
1789
1790 // An event a caller appends carries no scope id: the field is the
1791 // kernel's, on the kinds only the kernel writes.
1792 s.append(obj(json!({ "kind": "note" })))
1793 .await
1794 .expect("append");
1795 let note = s
1796 .events(0, usize::MAX)
1797 .await
1798 .expect("events")
1799 .pop()
1800 .expect("note");
1801 assert_eq!(data_field(¬e, FIELD_SCOPE_ID), None, "{note}");
1802 assert_eq!(note[FIELD_DATA], json!({}), "and no data of its own");
1803 }
1804
1805 #[tokio::test]
1806 async fn the_owner_is_total_and_read_back_verbatim() {
1807 assert_eq!(new_session(None).await.owner(), ANON);
1808 assert_eq!(
1809 Session::new(SYSTEM.to_string(), None, &IsleDrivers::new())
1810 .await
1811 .expect("open")
1812 .owner(),
1813 SYSTEM
1814 );
1815 assert_eq!(
1816 Session::new("user-42".to_string(), None, &IsleDrivers::new())
1817 .await
1818 .expect("open")
1819 .owner(),
1820 "user-42"
1821 );
1822 }
1823
1824 #[tokio::test]
1825 async fn close_records_session_closed_once_with_the_given_reason() {
1826 let mut s = new_session(None).await;
1827 s.close(Some("budget_exhausted")).await.expect("close");
1828 s.close(Some("ignored")).await.expect("close (idempotent)");
1829 assert_eq!(s.len().await.expect("len"), 2, "close must be idempotent");
1830
1831 let last = s
1832 .events(2, usize::MAX)
1833 .await
1834 .expect("events")
1835 .pop()
1836 .expect("session_closed");
1837 assert_eq!(last.kind(), KIND_SESSION_CLOSED);
1838 assert_eq!(*field(&last, FIELD_REASON), json!("budget_exhausted"));
1839 assert!(s.is_closed());
1840 }
1841
1842 #[tokio::test]
1843 async fn close_without_a_reason_records_the_default() {
1844 let mut s = new_session(None).await;
1845 s.close(None).await.expect("close");
1846 let last = s
1847 .events(2, usize::MAX)
1848 .await
1849 .expect("events")
1850 .pop()
1851 .expect("session_closed");
1852 assert_eq!(*field(&last, FIELD_REASON), json!(DEFAULT_CLOSE_REASON));
1853 }
1854
1855 /// The detached close is the same boundary, written without waiting: the
1856 /// backstop's path, exercised here on the store that can take it.
1857 #[tokio::test]
1858 async fn a_detached_close_records_the_same_boundary() {
1859 let mut s = new_session(None).await;
1860 s.close_detached(CLOSE_REASON_DROPPED);
1861 assert!(s.is_closed(), "the handle is closed straight away");
1862 // …and closing again writes nothing, exactly as the awaited path.
1863 s.close_detached(CLOSE_REASON_DROPPED);
1864 s.close(Some("ignored")).await.expect("close is a no-op");
1865
1866 // Nothing was awaited above, so the read below is what waits: the
1867 // connection runs one job at a time in the order it took them, so a
1868 // read submitted after the detached write is answered after it.
1869 let last = s
1870 .events(0, usize::MAX)
1871 .await
1872 .expect("events")
1873 .pop()
1874 .expect("session_closed");
1875 assert_eq!(last.kind(), KIND_SESSION_CLOSED);
1876 assert_eq!(
1877 *field(&last, FIELD_REASON),
1878 json!(CLOSE_REASON_DROPPED),
1879 "exactly one boundary, carrying the backstop's reason"
1880 );
1881 assert_eq!(s.len().await.expect("len"), 2, "session_opened + closed");
1882 }
1883
1884 #[tokio::test]
1885 async fn a_closed_session_rejects_writes_but_keeps_serving_reads() {
1886 let mut s = new_session(Some(10)).await;
1887 s.append(obj(json!({ "kind": "note" })))
1888 .await
1889 .expect("append");
1890 s.spend(4).await.expect("spend");
1891 s.close(None).await.expect("close");
1892
1893 let err = s
1894 .append(obj(json!({ "kind": "note" })))
1895 .await
1896 .expect_err("append after close");
1897 assert_eq!(err.reason(), "session is closed");
1898 let err = s.spend(1).await.expect_err("spend after close");
1899 assert_eq!(err.reason(), "session is closed");
1900 let err = s.reserve(1).await.expect_err("reserve after close");
1901 assert_eq!(err.reason(), "session is closed");
1902
1903 assert_eq!(
1904 s.len().await.expect("len"),
1905 5,
1906 "session_opened + budget_granted + note + budget_spent + session_closed"
1907 );
1908 assert_eq!(remaining(&s).await, Some(6));
1909 assert_eq!(
1910 folded(&s).await,
1911 remaining(&s).await,
1912 "the ledger is the balance"
1913 );
1914 assert!(!exhausted(&s).await);
1915 assert_eq!(
1916 s.events(0, usize::MAX).await.expect("events")[2].kind(),
1917 "note"
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn two_sessions_share_nothing() {
1923 let mut a = new_session(Some(100)).await;
1924 let mut b = new_session(Some(100)).await;
1925 assert_ne!(a.id(), b.id());
1926
1927 a.append(obj(json!({ "kind": "only_in_a" })))
1928 .await
1929 .expect("append");
1930 a.spend(60).await.expect("spend");
1931
1932 assert_eq!(
1933 a.len().await.expect("len"),
1934 4,
1935 "session_opened + budget_granted + only_in_a + budget_spent"
1936 );
1937 assert_eq!(
1938 b.len().await.expect("len"),
1939 2,
1940 "session_opened + budget_granted"
1941 );
1942 assert_eq!(remaining(&a).await, Some(40));
1943 assert_eq!(remaining(&b).await, Some(100));
1944 // The ledgers are as separate as the histories.
1945 assert_eq!(folded(&a).await, Some(40));
1946 assert_eq!(folded(&b).await, Some(100));
1947
1948 a.close(None).await.expect("close");
1949 assert!(b.append(obj(json!({ "kind": "still_open" }))).await.is_ok());
1950 }
1951
1952 #[tokio::test]
1953 async fn view_serves_the_one_named_projection_and_rejects_anything_else() {
1954 let mut s = new_session(None).await;
1955 s.append(obj(
1956 json!({ "kind": "msg_user", "data": { "content": "hi" } }),
1957 ))
1958 .await
1959 .expect("append");
1960 s.append(response(9)).await.expect("recorded");
1961
1962 let tail = s
1963 .view(VIEW_TAIL, Some(&obj(json!({ "n": 1 }))))
1964 .await
1965 .expect("tail");
1966 assert_eq!(tail.as_array().map(Vec::len), Some(1));
1967
1968 let err = s.view("nope", None).await.expect_err("unknown view");
1969 assert_eq!(err.reason(), r#"unknown view "nope""#);
1970 }
1971
1972 /// A store that records what each read was asked for and what it handed
1973 /// back, so a test can say how much of a stream a view actually touched.
1974 ///
1975 /// Every read a session takes goes through one of these two methods, so
1976 /// the counters are the whole of what the session asked the backend for.
1977 #[derive(Default)]
1978 struct CountingStore {
1979 inner: MemEventStore,
1980 /// `(from_seq, limit)` of every range read, in order.
1981 ranges: Arc<Mutex<Vec<(u64, usize)>>>,
1982 /// `(n, rows handed back)` of every read from the end.
1983 tails: Arc<Mutex<Vec<(usize, usize)>>>,
1984 }
1985
1986 #[async_trait::async_trait]
1987 impl EventStore for CountingStore {
1988 async fn append(&mut self, event: Map<String, Value>) -> KnlResult<crate::knl::Committed> {
1989 self.inner.append(event).await
1990 }
1991
1992 async fn append_if(
1993 &mut self,
1994 kinds: Option<&[&str]>,
1995 decide: crate::knl::Decision,
1996 ) -> KnlResult<Option<crate::knl::Committed>> {
1997 self.inner.append_if(kinds, decide).await
1998 }
1999
2000 async fn read_kinds(
2001 &self,
2002 kinds: Option<&[&str]>,
2003 from_seq: u64,
2004 limit: usize,
2005 ) -> KnlResult<Vec<Value>> {
2006 self.ranges
2007 .lock()
2008 .unwrap_or_else(PoisonError::into_inner)
2009 .push((from_seq, limit));
2010 self.inner.read_kinds(kinds, from_seq, limit).await
2011 }
2012
2013 async fn read_last(&self, n: usize) -> KnlResult<Vec<Value>> {
2014 let events = self.inner.read_last(n).await?;
2015 self.tails
2016 .lock()
2017 .unwrap_or_else(PoisonError::into_inner)
2018 .push((n, events.len()));
2019 Ok(events)
2020 }
2021
2022 async fn head(&self) -> KnlResult<Option<u64>> {
2023 self.inner.head().await
2024 }
2025
2026 async fn len(&self) -> KnlResult<usize> {
2027 self.inner.len().await
2028 }
2029 }
2030
2031 /// `tail` asks the store for the end of the stream, not for the stream.
2032 ///
2033 /// The regression this pins: the view used to read every event, upcast
2034 /// every one of them and then keep the last `n`, so a five-event answer
2035 /// off a long log cost the whole log. A thousand events in, a `tail(5)`
2036 /// must reach the backend as "the last five" and come back as five —
2037 /// and no range read may go out behind it asking for everything.
2038 #[tokio::test]
2039 async fn tail_reads_the_end_of_the_stream_and_not_the_whole_of_it() {
2040 let store = CountingStore::default();
2041 let (ranges, tails) = (Arc::clone(&store.ranges), Arc::clone(&store.tails));
2042 let mut s = Session::open_on(ANON.to_string(), None, Box::new(store))
2043 .await
2044 .expect("open");
2045 for i in 0..1_000 {
2046 s.append(obj(json!({ "kind": format!("e{i}") })))
2047 .await
2048 .expect("append");
2049 }
2050
2051 // The opening plus the thousand: what a whole read would cost.
2052 assert_eq!(s.len().await.expect("len"), 1_001);
2053 ranges
2054 .lock()
2055 .unwrap_or_else(PoisonError::into_inner)
2056 .clear();
2057 tails.lock().unwrap_or_else(PoisonError::into_inner).clear();
2058
2059 let tail = s
2060 .view(VIEW_TAIL, Some(&obj(json!({ "n": 5 }))))
2061 .await
2062 .expect("tail");
2063 let tail = tail.as_array().expect("an array of events");
2064 assert_eq!(tail.len(), 5);
2065 assert_eq!(kind_of(&tail[4]), "e999", "the last event is the last one");
2066
2067 // One read, from the end, for exactly the five that were asked for.
2068 let taken = tails.lock().unwrap_or_else(PoisonError::into_inner).clone();
2069 let scanned = ranges
2070 .lock()
2071 .unwrap_or_else(PoisonError::into_inner)
2072 .clone();
2073 assert_eq!(
2074 taken,
2075 vec![(5, 5)],
2076 "tail must ask the store for five rows and get five"
2077 );
2078 assert!(
2079 scanned.is_empty(),
2080 "no range read goes out behind it: {scanned:?}"
2081 );
2082 }
2083
2084 /// The token account is not a name the kernel answers to any more: it
2085 /// reads the `llm_response` payload, so it is a query view written over
2086 /// the published schema. Asking the kernel for it is the same error as
2087 /// asking for any other name it does not have.
2088 #[tokio::test]
2089 async fn the_token_account_is_not_a_named_view() {
2090 let mut s = new_session(None).await;
2091 s.append(response(9)).await.expect("recorded");
2092
2093 let err = s.view("usage", None).await.expect_err("usage was served");
2094 assert_eq!(err.reason(), r#"unknown view "usage""#);
2095 assert_eq!(err.kind(), KnlError::VALIDATION);
2096
2097 // What it needs is in the log, verbatim, for a reader to sum.
2098 let recorded = s
2099 .events(2, usize::MAX)
2100 .await
2101 .expect("events")
2102 .pop()
2103 .expect("llm_response");
2104 assert_eq!(*field(&recorded, "usage"), json!({ "input_tokens": 9 }));
2105 }
2106
2107 /// The conversation is not one of the names: how a record becomes a
2108 /// request — which role each kind takes, whether a system message
2109 /// belongs in it, where to cut it off — is the shell's decision, and
2110 /// it builds it from `events` rather than asking the kernel for it.
2111 #[tokio::test]
2112 async fn the_conversation_is_not_a_named_view() {
2113 let mut s = new_session(None).await;
2114 s.append(obj(
2115 json!({ "kind": "msg_user", "data": { "content": "hi" } }),
2116 ))
2117 .await
2118 .expect("append");
2119
2120 let err = s
2121 .view("dialogue", None)
2122 .await
2123 .expect_err("dialogue was served");
2124 assert_eq!(err.reason(), r#"unknown view "dialogue""#);
2125
2126 let events = s.events(0, usize::MAX).await.expect("events");
2127 assert_eq!(events[1].kind(), "msg_user");
2128 assert_eq!(*field(&events[1], "content"), json!("hi"));
2129 }
2130
2131 /// Appending an `llm_response` records it — verbatim, beat included —
2132 /// and leaves the budget alone. What a call was allowed to cost was
2133 /// decided before it happened; the record of it happening is not a
2134 /// second place where that is decided.
2135 #[tokio::test]
2136 async fn appending_an_llm_response_records_it_verbatim_without_charging() {
2137 let mut s = new_session(Some(100)).await;
2138 let seq = s
2139 .append(obj(json!({
2140 "kind": "llm_response",
2141 // The beat is the caller's word and the kernel keeps it.
2142 "beat": "beat-7",
2143 "data": {
2144 "content": [{ "type": "text", "text": "hi" }],
2145 "usage": { "input_tokens": 20, "output_tokens": 10 }
2146 }
2147 })))
2148 .await
2149 .expect("append");
2150
2151 let recorded = s
2152 .events(seq, usize::MAX)
2153 .await
2154 .expect("events")
2155 .pop()
2156 .expect("llm_response");
2157 assert_eq!(recorded.kind(), "llm_response");
2158 assert_eq!(
2159 recorded[FIELD_BEAT],
2160 json!("beat-7"),
2161 "the declared beat is recorded as given"
2162 );
2163
2164 assert_eq!(remaining(&s).await, Some(100), "an append must not charge");
2165 assert_eq!(
2166 ledger(&s).await.len(),
2167 1,
2168 "only the opening grant is in the ledger"
2169 );
2170 assert_eq!(
2171 *field(&recorded, "usage"),
2172 json!({ "input_tokens": 20, "output_tokens": 10 }),
2173 "the counts are stored as they came: {recorded}"
2174 );
2175 assert_eq!(
2176 folded(&s).await,
2177 Some(100),
2178 "what was consumed and the balance are separate readings"
2179 );
2180 }
2181
2182 /// The grant is the first thing the ledger says, right after the
2183 /// session's own boundary, with the owner's words on it.
2184 #[tokio::test]
2185 async fn opening_with_a_grant_records_it() {
2186 let s = Session::new(
2187 ANON.to_string(),
2188 Some(BudgetGrant {
2189 amount: 500,
2190 tag: Some("tokens".to_string()),
2191 desc: Some("one nightly run".to_string()),
2192 }),
2193 &IsleDrivers::new(),
2194 )
2195 .await
2196 .expect("open");
2197
2198 let events = s.events(0, usize::MAX).await.expect("events");
2199 assert_eq!(events.len(), 2, "session_opened + budget_granted");
2200 assert_eq!(events[0].kind(), KIND_SESSION_OPENED);
2201 assert_eq!(
2202 data_field(&events[0], "budget"),
2203 None,
2204 "the grant is its own event, not a field on session_opened"
2205 );
2206
2207 let granted = &events[1];
2208 assert_eq!(granted.kind(), KIND_BUDGET_GRANTED);
2209 assert_eq!(*field(granted, FIELD_AMOUNT), json!(500));
2210 assert_eq!(*field(granted, FIELD_TAG), json!("tokens"));
2211 assert_eq!(*field(granted, FIELD_DESC), json!("one nightly run"));
2212 assert_eq!(remaining(&s).await, Some(500));
2213 assert_eq!(folded(&s).await, remaining(&s).await);
2214
2215 // A session with no grant keeps no ledger at all.
2216 let bare = new_session(None).await;
2217 assert_eq!(bare.len().await.expect("len"), 1, "session_opened only");
2218 assert!(ledger(&bare).await.is_empty());
2219 assert_eq!(folded(&bare).await, None);
2220 }
2221
2222 /// A reservation the balance covers is recorded once, deducts exactly
2223 /// what it asked for, and carries the grant's tag.
2224 #[tokio::test]
2225 async fn a_granted_reservation_is_one_event_and_one_deduction() {
2226 let mut s = new_session(Some(100)).await;
2227 assert_eq!(s.reserve(30).await, Ok(true));
2228
2229 let moves = ledger(&s).await;
2230 assert_eq!(moves.len(), 2, "the grant and the reservation");
2231 assert_eq!(moves[1].kind(), KIND_BUDGET_RESERVED);
2232 assert_eq!(*field(&moves[1], FIELD_AMOUNT), json!(30));
2233 assert_eq!(*field(&moves[1], FIELD_TAG), json!("tokens"));
2234 assert_eq!(remaining(&s).await, Some(70));
2235 assert_eq!(
2236 folded(&s).await,
2237 remaining(&s).await,
2238 "the ledger is the balance"
2239 );
2240 }
2241
2242 /// A refusal is a fact: it is recorded, with what was asked for and
2243 /// what there was, and it moves nothing.
2244 #[tokio::test]
2245 async fn a_refused_reservation_is_recorded_and_changes_no_balance() {
2246 let mut s = new_session(Some(10)).await;
2247 assert_eq!(s.reserve(11).await, Ok(false));
2248
2249 let moves = ledger(&s).await;
2250 assert_eq!(moves.len(), 2, "the grant and the refusal");
2251 assert_eq!(moves[1].kind(), KIND_BUDGET_REFUSED);
2252 assert_eq!(*field(&moves[1], FIELD_AMOUNT), json!(11));
2253 assert_eq!(
2254 *field(&moves[1], FIELD_REMAINING),
2255 json!(10),
2256 "what there was"
2257 );
2258 assert_eq!(*field(&moves[1], FIELD_TAG), json!("tokens"));
2259 assert_eq!(remaining(&s).await, Some(10), "a refusal must not deduct");
2260 assert!(!exhausted(&s).await);
2261 assert_eq!(folded(&s).await, remaining(&s).await);
2262
2263 // And the run can still spend what it has: nothing was consumed.
2264 assert_eq!(s.reserve(10).await, Ok(true));
2265 assert_eq!(remaining(&s).await, Some(0));
2266 assert_eq!(folded(&s).await, Some(0));
2267 }
2268
2269 /// The settlement is recorded like everything else, and what the session
2270 /// reports is the fold of the ledger after any sequence of moves.
2271 #[tokio::test]
2272 async fn the_balance_is_the_fold_after_any_sequence_of_moves() {
2273 let mut s = new_session(Some(1000)).await;
2274 assert_eq!(s.reserve(200).await, Ok(true));
2275 s.append(response(40)).await.expect("recorded");
2276 s.spend(50).await.expect("spend");
2277 assert_eq!(s.reserve(10_000).await, Ok(false));
2278 assert_eq!(s.reserve(300).await, Ok(true));
2279 s.spend(0).await.expect("spend");
2280
2281 let moves = ledger(&s).await;
2282 assert_eq!(
2283 kinds(&moves),
2284 vec![
2285 KIND_BUDGET_GRANTED,
2286 KIND_BUDGET_RESERVED,
2287 KIND_BUDGET_SPENT,
2288 KIND_BUDGET_REFUSED,
2289 KIND_BUDGET_RESERVED,
2290 KIND_BUDGET_SPENT,
2291 ],
2292 "every move left exactly one event"
2293 );
2294 assert_eq!(remaining(&s).await, Some(450), "1000 - 200 - 50 - 300");
2295 assert_eq!(folded(&s).await, remaining(&s).await);
2296 }
2297
2298 /// The ledger is the kernel's to write: a caller cannot grant itself a
2299 /// budget, or drain one, by appending the events the balance folds
2300 /// from.
2301 #[tokio::test]
2302 async fn a_caller_cannot_append_the_budget_kinds() {
2303 let mut s = new_session(Some(10)).await;
2304 for event in [
2305 json!({ "kind": "budget_granted", "data": { "amount": 1_000_000 } }),
2306 json!({ "kind": "budget_reserved", "data": { "amount": 5 } }),
2307 json!({ "kind": "budget_refused", "data": { "amount": 5, "remaining": 0 } }),
2308 json!({ "kind": "budget_spent", "data": { "amount": 5 } }),
2309 ] {
2310 let err = s
2311 .append(obj(event.clone()))
2312 .await
2313 .expect_err("kernel-only kind");
2314 assert!(
2315 err.reason().contains("kernel only"),
2316 "{event}: {}",
2317 err.reason()
2318 );
2319 }
2320
2321 assert_eq!(
2322 remaining(&s).await,
2323 Some(10),
2324 "no forged event moved the balance"
2325 );
2326 assert_eq!(ledger(&s).await.len(), 1, "nothing was recorded");
2327 assert_eq!(folded(&s).await, remaining(&s).await);
2328 }
2329
2330 /// The session's boundaries are the kernel's alone: a caller cannot
2331 /// hand-append either one, so a stream cannot claim an opening it never
2332 /// had or an ending it never reached. The refusal leaves the session
2333 /// open and the log untouched.
2334 #[tokio::test]
2335 async fn a_caller_cannot_append_the_session_boundary_kinds() {
2336 let mut s = new_session(Some(100)).await;
2337 for event in [
2338 json!({ "kind": "session_opened", "data": { "scope_id": "s", "owner": "me" } }),
2339 json!({ "kind": "session_closed", "data": { "reason": "carried over" } }),
2340 ] {
2341 let err = s
2342 .append(obj(event.clone()))
2343 .await
2344 .expect_err("kernel-only kind");
2345 assert!(
2346 err.reason().contains("kernel only"),
2347 "{event}: {}",
2348 err.reason()
2349 );
2350 }
2351
2352 assert!(!s.is_closed(), "a refused append ended the session");
2353 assert_eq!(s.len().await.expect("len"), 2, "nothing was recorded");
2354 assert_eq!(s.append(obj(json!({ "kind": "note" }))).await, Ok(3));
2355 assert_eq!(s.spend(10).await, Ok(()));
2356 assert_eq!(remaining(&s).await, Some(90), "the settlement landed");
2357 }
2358
2359 /// Only `close` records `session_closed`, and it records exactly one:
2360 /// the flag and the event move together, so the log and the state
2361 /// cannot disagree.
2362 #[tokio::test]
2363 async fn only_close_records_session_closed() {
2364 let mut s = new_session(Some(100)).await;
2365 s.append(obj(json!({ "kind": "note" })))
2366 .await
2367 .expect("append");
2368 assert!(
2369 !s.events(0, usize::MAX)
2370 .await
2371 .expect("events")
2372 .iter()
2373 .any(|e| e.kind() == KIND_SESSION_CLOSED),
2374 "nothing but close writes the boundary"
2375 );
2376
2377 s.close(Some("done")).await.expect("close");
2378 assert!(s.is_closed());
2379
2380 let closed: Vec<Current> = s
2381 .events(0, usize::MAX)
2382 .await
2383 .expect("events")
2384 .into_iter()
2385 .filter(|e| e.kind() == KIND_SESSION_CLOSED)
2386 .collect();
2387 assert_eq!(closed.len(), 1, "exactly one boundary: {closed:?}");
2388 assert_eq!(*field(&closed[0], FIELD_REASON), json!("done"));
2389 assert_eq!(
2390 s.append(obj(json!({ "kind": "note" })))
2391 .await
2392 .expect_err("append after close")
2393 .reason(),
2394 "session is closed"
2395 );
2396 }
2397
2398 /// The record and the account are separate readings of the same
2399 /// session: the response is in the history, counts and all, and the
2400 /// balance is exactly what was granted, because nobody reserved
2401 /// anything.
2402 #[tokio::test]
2403 async fn a_recorded_response_is_in_the_history_without_being_charged() {
2404 let mut s = new_session(Some(100)).await;
2405 s.append(response(30)).await.expect("recorded");
2406
2407 assert_eq!(remaining(&s).await, Some(100));
2408 assert!(!exhausted(&s).await);
2409
2410 let recorded = s
2411 .events(3, usize::MAX)
2412 .await
2413 .expect("events")
2414 .pop()
2415 .expect("llm_response");
2416 assert_eq!(recorded.kind(), "llm_response");
2417 assert_eq!(*field(&recorded, "stop_reason"), json!("end_turn"));
2418 assert_eq!(field(&recorded, "usage")["input_tokens"], json!(30));
2419 assert_eq!(
2420 folded(&s).await,
2421 Some(100),
2422 "the ledger recorded no consumption"
2423 );
2424 }
2425
2426 /// The beat belongs to the layer above: the kernel never mints one, so
2427 /// an event that declares none carries none, and one that declares a
2428 /// beat carries exactly the string it was given — on any kind, and
2429 /// repeated across the facts of one beat without the kernel objecting.
2430 #[tokio::test]
2431 async fn beats_are_the_callers_word_and_the_kernel_adds_none() {
2432 let mut s = new_session(None).await;
2433
2434 let seq = s.append(response(1)).await.expect("an undeclared beat");
2435 let bare = s
2436 .events(seq, usize::MAX)
2437 .await
2438 .expect("events")
2439 .pop()
2440 .expect("response");
2441 assert_eq!(
2442 bare.get(FIELD_BEAT),
2443 None,
2444 "the kernel must not invent a beat: {bare}"
2445 );
2446
2447 for event in [
2448 json!({
2449 "kind": "llm_response", "beat": "b-1",
2450 "data": { "content": [], "usage": { "input_tokens": 1 } }
2451 }),
2452 json!({
2453 "kind": "tool_call", "beat": "b-1",
2454 "data": { "call_id": "c1", "name": "sh", "args": {} }
2455 }),
2456 json!({
2457 "kind": "tool_result", "beat": "b-1",
2458 "data": { "call_id": "c1", "ok": true, "result": "ok" }
2459 }),
2460 json!({ "kind": "llm_call_failed", "beat": "b-1", "data": { "error": "boom" } }),
2461 ] {
2462 let seq = s.append(obj(event.clone())).await.expect("declared beat");
2463 let recorded = s
2464 .events(seq, usize::MAX)
2465 .await
2466 .expect("events")
2467 .pop()
2468 .expect("recorded");
2469 assert_eq!(recorded[FIELD_BEAT], json!("b-1"), "{event}");
2470 }
2471
2472 // A non-string beat is the one thing refused, on any kind.
2473 let err = s
2474 .append(obj(json!({ "kind": "note", "beat": 1 })))
2475 .await
2476 .expect_err("a numbered beat");
2477 assert!(err.reason().contains("beat must be a string"), "{err}");
2478 }
2479
2480 #[tokio::test]
2481 async fn a_closed_session_records_nothing() {
2482 let mut s = new_session(Some(100)).await;
2483 s.close(None).await.expect("close");
2484
2485 let err = s.append(response(10)).await.expect_err("closed session");
2486 assert_eq!(err.reason(), "session is closed");
2487
2488 assert_eq!(
2489 s.len().await.expect("len"),
2490 3,
2491 "session_opened + budget_granted + session_closed only"
2492 );
2493 assert_eq!(remaining(&s).await, Some(100), "nothing was consumed");
2494 }
2495
2496 /// The budget stops a session *before* it spends, not after: a
2497 /// reservation the balance cannot cover is refused, and the call it was
2498 /// for never happens. This replaces the old contract, where the budget
2499 /// was a flag that only stood up once a recorded call had already used
2500 /// the allowance up — by which time the spending was done.
2501 #[tokio::test]
2502 async fn the_budget_refuses_before_the_call_rather_than_flagging_after_it() {
2503 let mut s = new_session(Some(10)).await;
2504
2505 /// How many model responses the log holds.
2506 async fn responses(s: &Session) -> usize {
2507 s.events(0, usize::MAX)
2508 .await
2509 .expect("events")
2510 .iter()
2511 .filter(|e| e.kind() == "llm_response")
2512 .count()
2513 }
2514
2515 // The estimate fits, so the beat proceeds and records its response.
2516 assert_eq!(s.reserve(10).await, Ok(true));
2517 s.append(response(25)).await.expect("recorded");
2518 assert_eq!(remaining(&s).await, Some(0), "the reservation took it all");
2519 assert!(exhausted(&s).await);
2520
2521 // The next beat asks first and is turned away, so no second
2522 // response is recorded: the caller never made the call.
2523 assert_eq!(s.reserve(1).await, Ok(false));
2524 assert_eq!(responses(&s).await, 1, "the refused beat made no call");
2525 assert_eq!(remaining(&s).await, Some(0));
2526 assert_eq!(folded(&s).await, remaining(&s).await);
2527
2528 // The kernel still does not police it: a caller that ignores the
2529 // refusal can append anyway, and the history says that it did.
2530 s.append(response(5)).await.expect("recorded");
2531 assert_eq!(responses(&s).await, 2, "stopping is the caller's decision");
2532 }
2533
2534 #[tokio::test]
2535 async fn without_a_budget_a_call_reports_no_remaining_and_is_never_exhausted() {
2536 let mut s = new_session(None).await;
2537 s.append(response(9_000)).await.expect("recorded");
2538 assert_eq!(remaining(&s).await, None);
2539 assert!(!exhausted(&s).await);
2540
2541 // No budget, no ledger: reserve always grants, spend does nothing,
2542 // and neither leaves a trace.
2543 assert_eq!(s.reserve(1_000_000).await, Ok(true));
2544 assert_eq!(s.spend(1_000_000).await, Ok(()));
2545 assert!(
2546 ledger(&s).await.is_empty(),
2547 "a run with no quota keeps no ledger"
2548 );
2549 assert_eq!(remaining(&s).await, None);
2550 assert!(!exhausted(&s).await);
2551 }
2552
2553 #[tokio::test]
2554 async fn views_stay_readable_and_correct_after_close() {
2555 let mut s = new_session(None).await;
2556 s.append(response(9)).await.expect("recorded");
2557 let before = s
2558 .view(VIEW_TAIL, Some(&obj(json!({ "n": 1 }))))
2559 .await
2560 .expect("tail");
2561 s.close(None).await.expect("close");
2562 let after = s
2563 .view(VIEW_TAIL, Some(&obj(json!({ "n": 1 }))))
2564 .await
2565 .expect("tail after close");
2566
2567 // The read keeps working, and it reads the log as it now stands: the
2568 // ending this handle wrote is the last thing in it.
2569 assert_eq!(
2570 kind_of(&before.as_array().expect("array")[0]),
2571 "llm_response"
2572 );
2573 assert_eq!(
2574 kind_of(&after.as_array().expect("array")[0]),
2575 KIND_SESSION_CLOSED
2576 );
2577
2578 assert_eq!(s.len().await.expect("len"), 3);
2579 assert_eq!(
2580 s.events(0, usize::MAX).await.expect("events")[2].kind(),
2581 KIND_SESSION_CLOSED
2582 );
2583 }
2584
2585 /// `open_on` on a durable backend records the session's owner on the
2586 /// `session_opened` boundary, so resume can recover it from the log
2587 /// alone.
2588 #[tokio::test]
2589 async fn open_on_records_the_owner_on_session_opened() {
2590 use crate::knl::SqliteEventStore;
2591
2592 let store = SqliteEventStore::open_memory("owner-stream", &IsleDrivers::new())
2593 .await
2594 .expect("open");
2595 let s = Session::open_on("user-7".to_string(), Some(grant(100)), Box::new(store))
2596 .await
2597 .expect("open");
2598
2599 let events = s.events(0, usize::MAX).await.expect("events");
2600 let opened = events.first().expect("session_opened");
2601 assert_eq!(opened.kind(), KIND_SESSION_OPENED);
2602 assert_eq!(
2603 field(opened, FIELD_OWNER).as_str(),
2604 Some("user-7"),
2605 "owner rides on session_opened: {opened}"
2606 );
2607 assert_eq!(s.owner(), "user-7");
2608
2609 // The grant is durable too, as its own event.
2610 assert_eq!(events[1].kind(), KIND_BUDGET_GRANTED);
2611 assert_eq!(*field(&events[1], FIELD_AMOUNT), json!(100));
2612 }
2613
2614 /// Resume re-folds a persisted SQLite stream: the owner and the
2615 /// *balance* come back from the log, because every move of the balance
2616 /// is in it.
2617 #[tokio::test]
2618 async fn resume_restores_the_owner_and_the_folded_balance() {
2619 use crate::knl::SqliteEventStore;
2620
2621 let dir = tempfile::tempdir().expect("tempdir");
2622 let path = dir.path().join("events.db");
2623 let stream = "resume-stream";
2624 let drivers = IsleDrivers::new();
2625
2626 // A durable session: two beats that reserved and settled.
2627 let before_close = {
2628 let store = SqliteEventStore::open(&path, stream, &drivers)
2629 .await
2630 .expect("open");
2631 let mut s = Session::open_on("user-42".to_string(), Some(grant(100)), Box::new(store))
2632 .await
2633 .expect("open");
2634 assert_eq!(s.reserve(30).await, Ok(true));
2635 s.append(response(30)).await.expect("first response");
2636 s.append(obj(
2637 json!({ "kind": "msg_user", "data": { "content": "more" } }),
2638 ))
2639 .await
2640 .expect("msg_user");
2641 assert_eq!(s.reserve(15).await, Ok(true));
2642 s.append(response(20)).await.expect("second response");
2643 s.spend(5)
2644 .await
2645 .expect("the second call overran its estimate");
2646 assert_eq!(remaining(&s).await, Some(50), "100 - 30 - 15 - 5");
2647 assert_eq!(folded(&s).await, remaining(&s).await);
2648 remaining(&s).await
2649 }; // dropped: the handle goes, the log persists.
2650
2651 // Reopen the same stream and resume — no new session_opened is
2652 // written, and no new grant either.
2653 let store = SqliteEventStore::open(&path, stream, &drivers)
2654 .await
2655 .expect("reopen");
2656 let mut resumed = Session::resume(None, Box::new(store))
2657 .await
2658 .expect("resume");
2659
2660 assert_eq!(
2661 resumed.owner(),
2662 "user-42",
2663 "owner restored from session_opened"
2664 );
2665 assert_eq!(
2666 remaining(&resumed).await,
2667 before_close,
2668 "the balance is what the ledger says it was"
2669 );
2670 assert_eq!(
2671 resumed.grant().and_then(|g| g.tag.as_deref()),
2672 Some("tokens"),
2673 "the grant's words came back with it"
2674 );
2675 // Resume appended nothing: the log is exactly what was persisted.
2676 assert_eq!(
2677 resumed.len().await.expect("len"),
2678 8,
2679 "session_opened + granted + reserved + response + msg_user \
2680 + reserved + response + spent — and nothing from resume itself"
2681 );
2682
2683 // The record came back whole, so a reader that sums the counts —
2684 // the Lua query view, over the published schema — has both calls to
2685 // work from.
2686 let responses: Vec<Current> = resumed
2687 .events(0, usize::MAX)
2688 .await
2689 .expect("events")
2690 .into_iter()
2691 .filter(|e| e.kind() == "llm_response")
2692 .collect();
2693 assert_eq!(responses.len(), 2, "{responses:?}");
2694 assert_eq!(field(&responses[0], "usage")["input_tokens"], json!(30));
2695 assert_eq!(field(&responses[1], "usage")["input_tokens"], json!(20));
2696
2697 // The ledger continues: the next reservation comes off the restored
2698 // balance, and what the resumed session records is its own.
2699 assert_eq!(resumed.reserve(5).await, Ok(true));
2700 let seq = resumed.append(response(5)).await.expect("third response");
2701 let recorded = resumed
2702 .events(seq, usize::MAX)
2703 .await
2704 .expect("events")
2705 .pop()
2706 .expect("llm_response");
2707 assert_eq!(recorded.kind(), "llm_response");
2708 assert_eq!(remaining(&resumed).await, Some(45), "5 reserved off the 50");
2709 assert_eq!(folded(&resumed).await, remaining(&resumed).await);
2710 }
2711
2712 /// A `grant` on resume is the owner allowing *more*: it is recorded and
2713 /// added to what the log left, rather than replacing it.
2714 #[tokio::test]
2715 async fn resume_with_a_grant_records_it_and_raises_the_balance() {
2716 use crate::knl::SqliteEventStore;
2717
2718 let dir = tempfile::tempdir().expect("tempdir");
2719 let path = dir.path().join("events.db");
2720 let stream = "regrant-stream";
2721 let drivers = IsleDrivers::new();
2722
2723 {
2724 let store = SqliteEventStore::open(&path, stream, &drivers)
2725 .await
2726 .expect("open");
2727 let mut s = Session::open_on("user-9".to_string(), Some(grant(100)), Box::new(store))
2728 .await
2729 .expect("open");
2730 assert_eq!(s.reserve(80).await, Ok(true));
2731 assert_eq!(remaining(&s).await, Some(20));
2732 }
2733
2734 let store = SqliteEventStore::open(&path, stream, &drivers)
2735 .await
2736 .expect("reopen");
2737 let mut resumed = Session::resume(
2738 Some(BudgetGrant {
2739 amount: 50,
2740 tag: Some("tokens".to_string()),
2741 desc: Some("a little more".to_string()),
2742 }),
2743 Box::new(store),
2744 )
2745 .await
2746 .expect("resume");
2747
2748 assert_eq!(remaining(&resumed).await, Some(70), "20 left + 50 granted");
2749 assert_eq!(folded(&resumed).await, remaining(&resumed).await);
2750
2751 let moves = ledger(&resumed).await;
2752 assert_eq!(
2753 kinds(&moves),
2754 vec![
2755 KIND_BUDGET_GRANTED,
2756 KIND_BUDGET_RESERVED,
2757 KIND_BUDGET_GRANTED
2758 ],
2759 "the second grant is a recorded fact"
2760 );
2761 assert_eq!(*field(&moves[2], FIELD_AMOUNT), json!(50));
2762 assert_eq!(*field(&moves[2], FIELD_DESC), json!("a little more"));
2763
2764 // And the resumed run spends against the raised balance.
2765 assert_eq!(resumed.reserve(70).await, Ok(true));
2766 assert_eq!(resumed.reserve(1).await, Ok(false));
2767 assert_eq!(remaining(&resumed).await, Some(0));
2768 assert_eq!(folded(&resumed).await, remaining(&resumed).await);
2769 }
2770
2771 /// One session, one budget: a resume raises a ledger that exists and
2772 /// cannot start one that does not.
2773 ///
2774 /// The two-handle case this rules out: a session opens with no quota, so
2775 /// its handle refuses nothing and its caller was told there is nothing to
2776 /// refuse; a second handle resumes the same open stream with a grant, and
2777 /// from the next reservation on the first handle is bounded by an
2778 /// allowance nobody asked it about. Whether a session has a budget is
2779 /// settled when it opens.
2780 #[tokio::test]
2781 async fn a_resume_does_not_give_a_stream_the_budget_it_opened_without() {
2782 use crate::knl::SqliteEventStore;
2783
2784 let dir = tempfile::tempdir().expect("tempdir");
2785 let path = dir.path().join("events.db");
2786 let stream = "ungranted-stream";
2787 let drivers = IsleDrivers::new();
2788
2789 // Opened with no budget, and still open: the handle is held for the
2790 // whole test, so nothing has written an ending.
2791 let store = SqliteEventStore::open(&path, stream, &drivers)
2792 .await
2793 .expect("open");
2794 let first = Session::open_on("user-1".to_string(), None, Box::new(store))
2795 .await
2796 .expect("open");
2797 assert_eq!(
2798 first.len().await.expect("len"),
2799 1,
2800 "session_opened, and no grant beside it"
2801 );
2802
2803 let reopened = SqliteEventStore::open(&path, stream, &drivers)
2804 .await
2805 .expect("reopen");
2806 let err = Session::resume(Some(grant(100)), Box::new(reopened))
2807 .await
2808 .expect_err("a resume must not introduce a ledger");
2809 assert_eq!(
2810 err.kind(),
2811 KnlError::VALIDATION,
2812 "the caller's argument is what did not hold up: {err}"
2813 );
2814 assert!(
2815 err.reason().contains("opened with no budget"),
2816 "{}",
2817 err.reason()
2818 );
2819
2820 // Nothing was written for it: the refusal is decided in the same
2821 // transaction that would have written the grant.
2822 assert_eq!(
2823 first.len().await.expect("len"),
2824 1,
2825 "the stream is untouched"
2826 );
2827 assert_eq!(remaining(&first).await, None, "and still has no ledger");
2828
2829 // Resuming it *without* a grant is what a second handle does.
2830 let reopened = SqliteEventStore::open(&path, stream, &drivers)
2831 .await
2832 .expect("reopen");
2833 let second = Session::resume(None, Box::new(reopened))
2834 .await
2835 .expect("a resume with no grant is the ordinary one");
2836 assert_eq!(remaining(&second).await, None);
2837 assert_eq!(second.len().await.expect("len"), 1);
2838 }
2839
2840 /// Whether there is a budget is the *log's* answer, not the handle's.
2841 ///
2842 /// A handle opened without a grant used to short-circuit on its own
2843 /// cached scope: `reserve` answered `true` whatever the ledger said and
2844 /// `spend` wrote nothing, so a grant that reached the stream through
2845 /// another handle was invisible to it and the quota bounded nobody. The
2846 /// question is inside the decision now, so the grant in the log binds
2847 /// every handle on the stream.
2848 #[tokio::test]
2849 async fn a_handle_opened_without_a_grant_is_bound_by_the_grant_the_log_carries() {
2850 use crate::knl::SqliteEventStore;
2851
2852 let dir = tempfile::tempdir().expect("tempdir");
2853 let path = dir.path().join("events.db");
2854 let stream = "late-grant-stream";
2855 let drivers = IsleDrivers::new();
2856
2857 let store = SqliteEventStore::open(&path, stream, &drivers)
2858 .await
2859 .expect("open");
2860 let mut opened_without = Session::open_on("user-1".to_string(), None, Box::new(store))
2861 .await
2862 .expect("open");
2863
2864 // The owner grants, through a handle it holds on the same stream.
2865 let reopened = SqliteEventStore::open(&path, stream, &drivers)
2866 .await
2867 .expect("reopen");
2868 let mut owner_handle = Session::resume(None, Box::new(reopened))
2869 .await
2870 .expect("resume");
2871 owner_handle
2872 .grant_more(grant(100))
2873 .await
2874 .expect("the owner grants");
2875
2876 // The first handle's own scope still says there is no budget…
2877 assert_eq!(
2878 opened_without.grant(),
2879 None,
2880 "the cached grant is a hint, and this handle never got one"
2881 );
2882 // …and the ledger it is measured against is the log's.
2883 assert_eq!(
2884 opened_without.reserve(500).await,
2885 Ok(false),
2886 "500 does not fit in the 100 the log carries"
2887 );
2888 assert_eq!(opened_without.reserve(40).await, Ok(true));
2889 assert_eq!(remaining(&opened_without).await, Some(60));
2890 opened_without
2891 .spend(60)
2892 .await
2893 .expect("a deduction on a ledger this handle did not open");
2894 assert_eq!(remaining(&opened_without).await, Some(0));
2895 assert_eq!(
2896 folded(&opened_without).await,
2897 remaining(&opened_without).await
2898 );
2899
2900 // Every entry it wrote is tagged with the unit the *log's* grant
2901 // named, not with the nothing this handle was opened with.
2902 let moves = ledger(&opened_without).await;
2903 assert_eq!(
2904 kinds(&moves),
2905 vec![
2906 KIND_BUDGET_GRANTED,
2907 KIND_BUDGET_REFUSED,
2908 KIND_BUDGET_RESERVED,
2909 KIND_BUDGET_SPENT,
2910 ],
2911 );
2912 for event in &moves {
2913 assert_eq!(
2914 field(event, FIELD_TAG).as_str(),
2915 Some("tokens"),
2916 "the unit comes off the log's grant: {event}"
2917 );
2918 }
2919 assert_eq!(*field(&moves[1], FIELD_REMAINING), json!(100));
2920 }
2921
2922 /// Seed `stream` with a `session_opened` whose `data` is empty.
2923 ///
2924 /// The validator requires the scope on that kind, so this cannot be
2925 /// written through the store: the row goes in behind it, which is what a
2926 /// stream an upcaster could not bring all the way would look like. The
2927 /// resume fallbacks below are for exactly that, and this is the only way
2928 /// to reach them.
2929 async fn seed_an_opening_with_no_scope(path: &std::path::Path, stream: &str) {
2930 use crate::knl::SqliteEventStore;
2931
2932 // Open once so the table is there, then write past the validator.
2933 // The collection is shut down rather than dropped, so the connection
2934 // has actually finished before the direct write below.
2935 let drivers = IsleDrivers::new();
2936 drop(
2937 SqliteEventStore::open(path, stream, &drivers)
2938 .await
2939 .expect("open"),
2940 );
2941 assert!(drivers.shutdown().await.is_empty(), "the writer joined");
2942 let conn = rusqlite::Connection::open(path).expect("open the database directly");
2943 conn.execute(
2944 "INSERT INTO events \
2945 (stream, seq, epoch_ms, kind, schema_version, beat, meta, data) \
2946 VALUES (?1, 1, 0, ?2, 1, NULL, '{}', '{}')",
2947 rusqlite::params![stream, KIND_SESSION_OPENED],
2948 )
2949 .expect("seed the opening");
2950 }
2951
2952 /// A log whose `session_opened` carries no `owner` resumes as [`ANON`]
2953 /// rather than failing: a stream that arrives missing the field is still
2954 /// a session, and refusing it would lose the log rather than protect it.
2955 #[tokio::test]
2956 async fn resume_falls_back_to_anon_when_the_log_has_no_owner() {
2957 use crate::knl::SqliteEventStore;
2958
2959 let dir = tempfile::tempdir().expect("tempdir");
2960 let path = dir.path().join("events.db");
2961 let stream = "legacy-stream";
2962 seed_an_opening_with_no_scope(&path, stream).await;
2963
2964 let store = SqliteEventStore::open(&path, stream, &IsleDrivers::new())
2965 .await
2966 .expect("reopen");
2967 let resumed = Session::resume(None, Box::new(store))
2968 .await
2969 .expect("resume");
2970 assert_eq!(resumed.owner(), ANON);
2971 assert_eq!(
2972 remaining(&resumed).await,
2973 None,
2974 "resumed without a budget cap"
2975 );
2976 }
2977
2978 /// Resume restores the *scope*, not just a fresh one: the id and the
2979 /// owner come back off `session_opened`, so the session continues under
2980 /// the authority the log says it opened with, and the ledger it goes on
2981 /// writing names that same scope.
2982 #[tokio::test]
2983 async fn resume_restores_the_scope_id_and_owner_from_the_log() {
2984 use crate::knl::SqliteEventStore;
2985
2986 let dir = tempfile::tempdir().expect("tempdir");
2987 let path = dir.path().join("events.db");
2988 let stream = "scope-resume-stream";
2989 let drivers = IsleDrivers::new();
2990
2991 let opened_scope = {
2992 let store = SqliteEventStore::open(&path, stream, &drivers)
2993 .await
2994 .expect("open");
2995 let mut s = Session::open_on("user-11".to_string(), Some(grant(100)), Box::new(store))
2996 .await
2997 .expect("open");
2998 assert_eq!(s.reserve(40).await, Ok(true));
2999 s.scope_id().to_string()
3000 };
3001
3002 let store = SqliteEventStore::open(&path, stream, &drivers)
3003 .await
3004 .expect("reopen");
3005 let mut resumed = Session::resume(None, Box::new(store))
3006 .await
3007 .expect("resume");
3008 assert_eq!(
3009 resumed.scope_id(),
3010 opened_scope,
3011 "the scope id is restored from session_opened, not re-issued"
3012 );
3013 assert_eq!(resumed.owner(), "user-11");
3014 assert_eq!(resumed.scope().owner(), "user-11");
3015 assert_eq!(
3016 remaining(&resumed).await,
3017 Some(60),
3018 "the balance is the fold's"
3019 );
3020
3021 // What the resumed session records goes on naming the same scope.
3022 assert_eq!(resumed.reserve(10).await, Ok(true));
3023 let last = ledger(&resumed).await.pop().expect("budget_reserved");
3024 assert_eq!(last.kind(), KIND_BUDGET_RESERVED);
3025 assert_eq!(
3026 field(&last, FIELD_SCOPE_ID).as_str(),
3027 Some(opened_scope.as_str()),
3028 "{last}"
3029 );
3030 }
3031
3032 /// A log whose `session_opened` carries no `scope_id` resumes under a
3033 /// fresh kernel-issued one rather than failing — the sibling of the
3034 /// `owner` fallback above, and for the same reason.
3035 #[tokio::test]
3036 async fn resume_issues_a_fresh_scope_id_when_the_log_records_none() {
3037 use crate::knl::SqliteEventStore;
3038
3039 let dir = tempfile::tempdir().expect("tempdir");
3040 let path = dir.path().join("events.db");
3041 let stream = "legacy-scope-stream";
3042 seed_an_opening_with_no_scope(&path, stream).await;
3043
3044 let store = SqliteEventStore::open(&path, stream, &IsleDrivers::new())
3045 .await
3046 .expect("reopen");
3047 // Resumed with no grant, because a resume cannot give a stream one it
3048 // opened without ([`Session::grant_on_resume`]); the owner grants
3049 // through the handle below, which is what puts a `budget_granted` in
3050 // the log for the scope id to be read off.
3051 let mut resumed = Session::resume(None, Box::new(store))
3052 .await
3053 .expect("resume");
3054 resumed
3055 .grant_more(grant(50))
3056 .await
3057 .expect("the owner grants");
3058
3059 // The fallback is visible from both sides: the log says nothing…
3060 let opened = resumed
3061 .events(0, usize::MAX)
3062 .await
3063 .expect("events")
3064 .remove(0);
3065 assert_eq!(opened.kind(), KIND_SESSION_OPENED);
3066 assert_eq!(data_field(&opened, FIELD_SCOPE_ID), None, "{opened}");
3067 assert_eq!(data_field(&opened, FIELD_OWNER), None, "{opened}");
3068 // …and the resumed session has a real scope all the same.
3069 assert!(
3070 !resumed.scope_id().is_empty(),
3071 "an older log must still resume under a scope"
3072 );
3073 assert_eq!(resumed.owner(), ANON, "the sibling fallback");
3074
3075 // And it is the one everything written from here on names.
3076 let granted = ledger(&resumed).await.pop().expect("budget_granted");
3077 assert_eq!(granted.kind(), KIND_BUDGET_GRANTED);
3078 assert_eq!(
3079 field(&granted, FIELD_SCOPE_ID).as_str(),
3080 Some(resumed.scope_id()),
3081 "{granted}"
3082 );
3083 }
3084
3085 /// (Fix 5) Resuming an empty log is a caller error — a mistyped or
3086 /// nonexistent stream must not fold into an anonymous zero session.
3087 #[tokio::test]
3088 async fn resume_of_an_empty_store_is_a_caller_error_not_an_anon_session() {
3089 let err = Session::resume(Some(grant(100)), Box::new(MemEventStore::new()))
3090 .await
3091 .expect_err("an empty store has no session to resume");
3092 assert!(
3093 err.reason().contains("no session to resume"),
3094 "{}",
3095 err.reason()
3096 );
3097 }
3098
3099 /// (Fix 5) A log that has events but no opening the kernel recognises —
3100 /// under any shape it has ever been written in — is a caller error too:
3101 /// the ANON fallback is only for a real `session_opened`.
3102 #[tokio::test]
3103 async fn resume_of_a_store_without_an_opening_is_a_caller_error() {
3104 let mut store = MemEventStore::new();
3105 store
3106 .append(obj(json!({ "kind": "note" })))
3107 .await
3108 .expect("seed a non-opening event");
3109 let err = Session::resume(None, Box::new(store))
3110 .await
3111 .expect_err("a log with no opening has no session to resume");
3112 assert!(
3113 err.reason().contains("no session to resume"),
3114 "{}",
3115 err.reason()
3116 );
3117 }
3118
3119 /// A session is disposable: once its ending is in the log, the stream is
3120 /// not continued. What comes after an ending is a new session.
3121 #[tokio::test]
3122 async fn a_closed_stream_is_not_resumed() {
3123 let mut store = MemEventStore::new();
3124 store
3125 .append(obj(json!({
3126 "kind": "session_opened",
3127 "data": { "scope_id": "scope-5", "owner": "user-5" }
3128 })))
3129 .await
3130 .expect("seed the opening");
3131 store
3132 .append(obj(
3133 json!({ "kind": "budget_granted", "data": { "amount": 100 } }),
3134 ))
3135 .await
3136 .expect("seed the grant");
3137 store
3138 .append(obj(
3139 json!({ "kind": "session_closed", "data": { "reason": "done" } }),
3140 ))
3141 .await
3142 .expect("seed the ending");
3143
3144 let err = Session::resume(None, Box::new(store))
3145 .await
3146 .expect_err("a closed session must not be resumed");
3147 assert!(
3148 err.reason().contains("session is closed"),
3149 "{}",
3150 err.reason()
3151 );
3152 assert!(err.reason().contains("disposable"), "{}", err.reason());
3153 }
3154
3155 /// A competing writer lands an event between this session's writes, so
3156 /// the log has moved on at the moment this one appends. The append still
3157 /// lands — a fact is not refused for what its writer had seen — and the
3158 /// `seq` it comes back with is where it really landed, after whatever got
3159 /// in first.
3160 struct BusyStore {
3161 inner: MemEventStore,
3162 injected: bool,
3163 }
3164
3165 #[async_trait::async_trait]
3166 impl EventStore for BusyStore {
3167 /// A session's appends come through here, so this is where the
3168 /// competing writer gets in: once, just before the response this
3169 /// session is about to record.
3170 async fn append(&mut self, event: Map<String, Value>) -> KnlResult<crate::knl::Committed> {
3171 if !self.injected
3172 && event.get(FIELD_KIND).and_then(Value::as_str) == Some("llm_response")
3173 {
3174 self.injected = true;
3175 self.inner
3176 .append(obj(json!({ "kind": "sneaked_in" })))
3177 .await
3178 .expect("injected concurrent write");
3179 }
3180 self.inner.append(event).await
3181 }
3182
3183 async fn append_if(
3184 &mut self,
3185 kinds: Option<&[&str]>,
3186 decide: crate::knl::Decision,
3187 ) -> KnlResult<Option<crate::knl::Committed>> {
3188 self.inner.append_if(kinds, decide).await
3189 }
3190
3191 async fn read_kinds(
3192 &self,
3193 kinds: Option<&[&str]>,
3194 from_seq: u64,
3195 limit: usize,
3196 ) -> KnlResult<Vec<Value>> {
3197 self.inner.read_kinds(kinds, from_seq, limit).await
3198 }
3199
3200 async fn head(&self) -> KnlResult<Option<u64>> {
3201 self.inner.head().await
3202 }
3203
3204 async fn len(&self) -> KnlResult<usize> {
3205 self.inner.len().await
3206 }
3207 }
3208
3209 /// An append records a fact and the store orders it: another writer
3210 /// getting there first does not turn this session's append into a
3211 /// failure, it only decides where the two land.
3212 #[tokio::test]
3213 async fn an_append_lands_after_a_competing_write_rather_than_being_refused() {
3214 let store = BusyStore {
3215 inner: MemEventStore::new(),
3216 injected: false,
3217 };
3218 let mut s = Session::open_on("user".to_string(), Some(grant(1000)), Box::new(store))
3219 .await
3220 .expect("open");
3221 assert_eq!(
3222 s.len().await.expect("len"),
3223 2,
3224 "session_opened + budget_granted so far"
3225 );
3226
3227 // The competing write lands at seq 3, so the response lands at 4 —
3228 // and it lands.
3229 let seq = s
3230 .append(response(10))
3231 .await
3232 .expect("an append is not refused");
3233 assert_eq!(seq, 4, "the seq is where the event really landed");
3234 assert_eq!(s.len().await.expect("len"), 4, "both writes are in the log");
3235
3236 let log = s.events(0, usize::MAX).await.expect("events");
3237 assert_eq!(
3238 kinds(&log),
3239 [
3240 KIND_SESSION_OPENED,
3241 KIND_BUDGET_GRANTED,
3242 "sneaked_in",
3243 "llm_response"
3244 ],
3245 "the log interleaves in arrival order"
3246 );
3247 assert_eq!(
3248 remaining(&s).await,
3249 Some(1000),
3250 "an append still charges nothing"
3251 );
3252 }
3253
3254 /// A store that takes a decision's write and refuses a plain one, from
3255 /// the moment the test arms it.
3256 ///
3257 /// The two paths a `budget_*` event could reach the log by, told apart:
3258 /// what a decision returns goes in with the read it was decided against
3259 /// ([`EventStore::append_if`]), and anything else is a second write. A
3260 /// refusal that came out here as a plain append would fail on this store
3261 /// while the decision that produced it had already succeeded — which is
3262 /// precisely the state a caller cannot read back.
3263 struct DecidedWritesOnlyStore {
3264 inner: MemEventStore,
3265 armed: Arc<AtomicBool>,
3266 }
3267
3268 #[async_trait::async_trait]
3269 impl EventStore for DecidedWritesOnlyStore {
3270 async fn append(&mut self, event: Map<String, Value>) -> KnlResult<crate::knl::Committed> {
3271 if self.armed.load(Ordering::Relaxed) {
3272 return Err(KnlError::Storage(
3273 "this store takes only what a decision wrote".to_string(),
3274 ));
3275 }
3276 self.inner.append(event).await
3277 }
3278
3279 async fn append_if(
3280 &mut self,
3281 kinds: Option<&[&str]>,
3282 decide: crate::knl::Decision,
3283 ) -> KnlResult<Option<crate::knl::Committed>> {
3284 self.inner.append_if(kinds, decide).await
3285 }
3286
3287 async fn read_kinds(
3288 &self,
3289 kinds: Option<&[&str]>,
3290 from_seq: u64,
3291 limit: usize,
3292 ) -> KnlResult<Vec<Value>> {
3293 self.inner.read_kinds(kinds, from_seq, limit).await
3294 }
3295
3296 async fn head(&self) -> KnlResult<Option<u64>> {
3297 self.inner.head().await
3298 }
3299
3300 async fn len(&self) -> KnlResult<usize> {
3301 self.inner.len().await
3302 }
3303 }
3304
3305 /// A refusal is written by the decision that refused, not by a second
3306 /// append afterwards.
3307 ///
3308 /// It used to be the second append, and that made two different outcomes
3309 /// look the same: if the write of the `budget_refused` failed, the caller
3310 /// got a storage error with nothing in the log to say the reservation had
3311 /// been decided at all — indistinguishable from a decision that never
3312 /// happened. Now exactly one of `budget_reserved` / `budget_refused`
3313 /// lands, in the transaction that took the decision, and the answer this
3314 /// call gives is which of the two it was.
3315 #[tokio::test]
3316 async fn a_refusal_lands_in_the_transaction_that_decided_it() {
3317 let armed = Arc::new(AtomicBool::new(false));
3318 let store = DecidedWritesOnlyStore {
3319 inner: MemEventStore::new(),
3320 armed: Arc::clone(&armed),
3321 };
3322 let mut s = Session::open_on("user".to_string(), Some(grant(10)), Box::new(store))
3323 .await
3324 .expect("open");
3325
3326 // From here on, a plain append fails: only a decision may write.
3327 armed.store(true, Ordering::Relaxed);
3328
3329 assert_eq!(
3330 s.reserve(50).await,
3331 Ok(false),
3332 "the refusal is the decision's own write, so it lands"
3333 );
3334 assert_eq!(
3335 s.reserve(4).await,
3336 Ok(true),
3337 "and so is the reservation that fits"
3338 );
3339
3340 let moves = ledger(&s).await;
3341 assert_eq!(
3342 kinds(&moves),
3343 vec![
3344 KIND_BUDGET_GRANTED,
3345 KIND_BUDGET_REFUSED,
3346 KIND_BUDGET_RESERVED
3347 ],
3348 "exactly one entry per decision"
3349 );
3350 assert_eq!(*field(&moves[1], FIELD_AMOUNT), json!(50), "what was asked");
3351 assert_eq!(
3352 *field(&moves[1], FIELD_REMAINING),
3353 json!(10),
3354 "and the balance the decision measured it against"
3355 );
3356 assert_eq!(remaining(&s).await, Some(6), "a refusal moved nothing");
3357
3358 // The store really is refusing plain appends: an ordinary record is
3359 // the thing this session can no longer write.
3360 let err = s
3361 .append(obj(json!({ "kind": "note" })))
3362 .await
3363 .expect_err("a plain append");
3364 assert_eq!(err.kind(), KnlError::STORAGE);
3365 }
3366
3367 /// A store whose `head` read is down: appends land, but nothing can ask
3368 /// the ledger where it stands.
3369 struct HeadlessStore {
3370 inner: MemEventStore,
3371 }
3372
3373 #[async_trait::async_trait]
3374 impl EventStore for HeadlessStore {
3375 async fn append(&mut self, event: Map<String, Value>) -> KnlResult<crate::knl::Committed> {
3376 self.inner.append(event).await
3377 }
3378
3379 async fn append_if(
3380 &mut self,
3381 kinds: Option<&[&str]>,
3382 decide: crate::knl::Decision,
3383 ) -> KnlResult<Option<crate::knl::Committed>> {
3384 self.inner.append_if(kinds, decide).await
3385 }
3386
3387 async fn read_kinds(
3388 &self,
3389 kinds: Option<&[&str]>,
3390 from_seq: u64,
3391 limit: usize,
3392 ) -> KnlResult<Vec<Value>> {
3393 self.inner.read_kinds(kinds, from_seq, limit).await
3394 }
3395
3396 async fn head(&self) -> KnlResult<Option<u64>> {
3397 Err(KnlError::Busy("the head read is contended".to_string()))
3398 }
3399
3400 async fn len(&self) -> KnlResult<usize> {
3401 self.inner.len().await
3402 }
3403 }
3404
3405 /// A store that cannot be read has no balance to report, and the kernel
3406 /// says so rather than serving the last fold.
3407 ///
3408 /// Both values this call can otherwise hand back read as facts about the
3409 /// budget — a number says "you have this much", a `false` from
3410 /// `exhausted` says "carry on" — and the caller acting on either is a
3411 /// loop deciding whether it may go on spending. So the failure
3412 /// surfaces, classified, and what to do about a contended read is the
3413 /// caller's.
3414 #[tokio::test]
3415 async fn a_balance_that_cannot_be_read_is_an_error_not_a_stale_fold() {
3416 let store = HeadlessStore {
3417 inner: MemEventStore::new(),
3418 };
3419 let s = Session::open_on("user".to_string(), Some(grant(100)), Box::new(store))
3420 .await
3421 .expect("the appends land; only the head read is down");
3422
3423 let err = s
3424 .remaining()
3425 .await
3426 .expect_err("a failed read must not fold into a number");
3427 assert_eq!(err.kind(), KnlError::BUSY, "the class travels out intact");
3428 assert!(err.is_retryable(), "contention is the one retryable class");
3429
3430 let err = s.exhausted().await.expect_err("nor into a boolean");
3431 assert_eq!(err.kind(), KnlError::BUSY);
3432
3433 // Only the reading failed: the record itself is exactly as written.
3434 assert_eq!(
3435 s.len().await.expect("len"),
3436 2,
3437 "session_opened + budget_granted landed"
3438 );
3439 }
3440
3441 /// (Fix 5) Resuming a nonexistent SQLite stream is a caller error, not an
3442 /// anonymous empty session.
3443 #[tokio::test]
3444 async fn resume_of_a_nonexistent_sqlite_stream_is_a_caller_error() {
3445 use crate::knl::SqliteEventStore;
3446
3447 let dir = tempfile::tempdir().expect("tempdir");
3448 let path = dir.path().join("events.db");
3449 // A stream that was never opened as a session: its log is empty.
3450 let store = SqliteEventStore::open(&path, "ghost-stream", &IsleDrivers::new())
3451 .await
3452 .expect("open");
3453 let err = Session::resume(Some(grant(100)), Box::new(store))
3454 .await
3455 .expect_err("an empty stream has no session to resume");
3456 assert!(
3457 err.reason().contains("no session to resume"),
3458 "{}",
3459 err.reason()
3460 );
3461 }
3462
3463 /// (Concurrency) Two `Session` handles on ONE durable stream, each with a
3464 /// view of the head from before the other wrote: both append, and the log
3465 /// holds both in the order they arrived. This is the scenario that used
3466 /// to be a head conflict — an append records a fact, and a fact is not
3467 /// refused for what its writer had last seen.
3468 #[tokio::test]
3469 async fn two_sessions_on_one_stream_both_append_and_the_log_interleaves() {
3470 use crate::knl::SqliteEventStore;
3471
3472 let dir = tempfile::tempdir().expect("tempdir");
3473 let path = dir.path().join("events.db");
3474 let stream = "interleave-stream";
3475 let drivers = IsleDrivers::new();
3476
3477 // A opens the session on the shared stream: `session_opened` at seq 1
3478 // and its `budget_granted` at seq 2, so A has seen head 2.
3479 let store_a = SqliteEventStore::open(&path, stream, &drivers)
3480 .await
3481 .expect("open A");
3482 let mut a = Session::open_on("user".to_string(), Some(grant(1000)), Box::new(store_a))
3483 .await
3484 .expect("open A");
3485
3486 // B resumes the SAME stream while it holds only those two, so both
3487 // handles have seen exactly head 2. (It resumes before A closes: a
3488 // closed session is not resumable.)
3489 let store_b = SqliteEventStore::open(&path, stream, &drivers)
3490 .await
3491 .expect("open B");
3492 let mut b = Session::resume(None, Box::new(store_b))
3493 .await
3494 .expect("resume B");
3495 assert_eq!(remaining(&b).await, Some(1000), "B resumed on A's ledger");
3496 assert_eq!(
3497 (a.len().await.expect("len"), b.len().await.expect("len")),
3498 (2, 2),
3499 "both see the same two events"
3500 );
3501
3502 // A appends, so B's view is now out of date — and B appends anyway.
3503 assert_eq!(a.append(response(10)).await.expect("A appends"), 3);
3504 assert_eq!(
3505 b.append(response(20)).await.expect("B appends too"),
3506 4,
3507 "B's write lands after A's, rather than being refused"
3508 );
3509 // And A, now out of date in its turn, goes on writing.
3510 assert_eq!(a.append(response(30)).await.expect("A appends again"), 5);
3511
3512 // The durable log holds all three, in arrival order.
3513 let verify = SqliteEventStore::open(&path, stream, &drivers)
3514 .await
3515 .expect("reopen to verify");
3516 let log = as_current(verify.read(0, usize::MAX).await.expect("read log"));
3517 let responses: Vec<u64> = log
3518 .iter()
3519 .filter(|e| e.kind() == "llm_response")
3520 .map(Current::seq)
3521 .collect();
3522 assert_eq!(responses, [3, 4, 5], "every append landed, in order");
3523 }
3524
3525 /// (Concurrency) The invariant that *is* a decision: two handles on one
3526 /// stream, ten granted, each asking for six. The decision is taken inside
3527 /// the store, against the ledger as it stands there, so exactly one is
3528 /// allowed — and the fold says four, not minus two.
3529 #[tokio::test]
3530 async fn two_sessions_cannot_both_reserve_the_same_allowance() {
3531 use crate::knl::SqliteEventStore;
3532
3533 let dir = tempfile::tempdir().expect("tempdir");
3534 let path = dir.path().join("events.db");
3535 let stream = "reserve-race-stream";
3536 let drivers = IsleDrivers::new();
3537
3538 let store_a = SqliteEventStore::open(&path, stream, &drivers)
3539 .await
3540 .expect("open A");
3541 let mut a = Session::open_on("user".to_string(), Some(grant(10)), Box::new(store_a))
3542 .await
3543 .expect("open A");
3544 let store_b = SqliteEventStore::open(&path, stream, &drivers)
3545 .await
3546 .expect("open B");
3547 let mut b = Session::resume(None, Box::new(store_b))
3548 .await
3549 .expect("resume B");
3550 assert_eq!(remaining(&b).await, Some(10), "both see the whole grant");
3551 assert_eq!(remaining(&a).await, Some(10));
3552
3553 // A takes six. B still believes it has ten — and is refused all the
3554 // same, because the balance it is measured against is the one in the
3555 // store, not the one it cached.
3556 assert_eq!(a.reserve(6).await, Ok(true), "the first reservation fits");
3557 assert_eq!(b.reserve(6).await, Ok(false), "the second does not");
3558 assert_eq!(remaining(&b).await, Some(4), "B's balance is the ledger's");
3559 assert_eq!(remaining(&a).await, Some(4), "and so is A's");
3560
3561 // The ledger is the answer: 10 granted − 6 reserved = 4, with the
3562 // refusal recorded and moving nothing.
3563 let verify = SqliteEventStore::open(&path, stream, &drivers)
3564 .await
3565 .expect("reopen to verify");
3566 let log = as_current(verify.read(0, usize::MAX).await.expect("read log"));
3567 assert_eq!(fold_balance(&log), Some(4), "no allowance was taken twice");
3568 let moves: Vec<&str> = kinds(&log)
3569 .into_iter()
3570 .filter(|k| k.starts_with("budget_"))
3571 .collect();
3572 assert_eq!(
3573 moves,
3574 [
3575 KIND_BUDGET_GRANTED,
3576 KIND_BUDGET_RESERVED,
3577 KIND_BUDGET_REFUSED
3578 ],
3579 "one grant, one reservation, one refusal"
3580 );
3581 let refused = log.last().expect("the refusal");
3582 assert_eq!(*field(refused, FIELD_AMOUNT), json!(6));
3583 assert_eq!(
3584 *field(refused, FIELD_REMAINING),
3585 json!(4),
3586 "what there really was"
3587 );
3588 }
3589
3590 /// (Concurrency) "Closed" is the handle's, and the log records what
3591 /// arrives after it. Three handles, one close: the two that never saw it
3592 /// go on writing, and their writes land *after* the `session_closed` —
3593 /// which is the fact an audit is reading for, and would be gone if the
3594 /// store had refused them. A second handle closing writes a second
3595 /// ending, because that is what happened.
3596 #[tokio::test]
3597 async fn a_close_is_the_handles_and_the_log_records_what_arrives_after_it() {
3598 use crate::knl::SqliteEventStore;
3599
3600 let dir = tempfile::tempdir().expect("tempdir");
3601 let path = dir.path().join("events.db");
3602 let stream = "close-race-stream";
3603 let drivers = IsleDrivers::new();
3604
3605 let store_a = SqliteEventStore::open(&path, stream, &drivers)
3606 .await
3607 .expect("open A");
3608 let mut a = Session::open_on("user".to_string(), Some(grant(100)), Box::new(store_a))
3609 .await
3610 .expect("open A");
3611 // Both resume while the stream is open — a closed one is not
3612 // resumable — so both hold `closed = false` across A's close.
3613 let store_b = SqliteEventStore::open(&path, stream, &drivers)
3614 .await
3615 .expect("open B");
3616 let mut b = Session::resume(None, Box::new(store_b))
3617 .await
3618 .expect("resume B");
3619 let store_c = SqliteEventStore::open(&path, stream, &drivers)
3620 .await
3621 .expect("open C");
3622 let mut c = Session::resume(None, Box::new(store_c))
3623 .await
3624 .expect("resume C");
3625
3626 a.close(Some("done")).await.expect("A closes");
3627 assert!(a.is_closed());
3628 assert!(!b.is_closed(), "B's flag is its own and has not moved");
3629 assert!(!c.is_closed());
3630
3631 // B writes, and the write lands: the store serializes appends, it does
3632 // not adjudicate them.
3633 assert_eq!(
3634 b.append(obj(json!({ "kind": "note" }))).await,
3635 Ok(4),
3636 "an append after another handle's close is recorded"
3637 );
3638 assert!(!b.is_closed(), "landing a write closed nothing");
3639
3640 // C's budget moves are decided on the balance alone — 100 granted,
3641 // nothing spent, so both go through.
3642 assert_eq!(c.reserve(5).await, Ok(true), "the ledger covers it");
3643 assert_eq!(c.spend(10).await, Ok(()), "the settlement lands");
3644 assert_eq!(
3645 remaining(&c).await,
3646 Some(85),
3647 "100 − 5 − 10, folded in the tx"
3648 );
3649 assert!(!c.is_closed());
3650
3651 // B closing writes a *second* ending; A closing again writes nothing,
3652 // because A's own flag is set.
3653 b.close(Some("late")).await.expect("B closes");
3654 a.close(Some("again"))
3655 .await
3656 .expect("A is idempotent per handle");
3657
3658 let verify = SqliteEventStore::open(&path, stream, &drivers)
3659 .await
3660 .expect("reopen to verify");
3661 let log = as_current(verify.read(0, usize::MAX).await.expect("read log"));
3662 assert_eq!(
3663 kinds(&log),
3664 [
3665 KIND_SESSION_OPENED,
3666 KIND_BUDGET_GRANTED,
3667 KIND_SESSION_CLOSED,
3668 "note",
3669 KIND_BUDGET_RESERVED,
3670 KIND_BUDGET_SPENT,
3671 KIND_SESSION_CLOSED,
3672 ],
3673 "everything that happened, in the order it arrived"
3674 );
3675
3676 let endings: Vec<&Current> = log
3677 .iter()
3678 .filter(|event| event.kind() == KIND_SESSION_CLOSED)
3679 .collect();
3680 assert_eq!(endings.len(), 2, "two handles closed, two endings recorded");
3681 assert_eq!(*field(endings[0], FIELD_REASON), json!("done"));
3682 assert_eq!(*field(endings[1], FIELD_REASON), json!("late"));
3683 assert_eq!(
3684 fold_balance(&log),
3685 Some(85),
3686 "the ledger is what the moves that landed add up to"
3687 );
3688 }
3689
3690 /// (Concurrency) A settlement records the move and says nothing else; the
3691 /// balance afterwards is the ledger's, so it is exact on a stream two
3692 /// handles write to — B reads what the log says, not a number it was
3693 /// holding before A spent.
3694 #[tokio::test]
3695 async fn a_settlement_records_the_move_and_the_balance_is_the_ledgers() {
3696 use crate::knl::SqliteEventStore;
3697
3698 let dir = tempfile::tempdir().expect("tempdir");
3699 let path = dir.path().join("events.db");
3700 let stream = "spend-race-stream";
3701 let drivers = IsleDrivers::new();
3702
3703 let store_a = SqliteEventStore::open(&path, stream, &drivers)
3704 .await
3705 .expect("open A");
3706 let mut a = Session::open_on("user".to_string(), Some(grant(100)), Box::new(store_a))
3707 .await
3708 .expect("open A");
3709 let store_b = SqliteEventStore::open(&path, stream, &drivers)
3710 .await
3711 .expect("open B");
3712 let mut b = Session::resume(None, Box::new(store_b))
3713 .await
3714 .expect("resume B");
3715 assert_eq!(
3716 (remaining(&a).await, remaining(&b).await),
3717 (Some(100), Some(100))
3718 );
3719
3720 assert_eq!(a.spend(30).await, Ok(()), "A settles 30 of the 100");
3721 assert_eq!(
3722 remaining(&a).await,
3723 Some(70),
3724 "and reads the balance separately"
3725 );
3726 assert_eq!(
3727 remaining(&b).await,
3728 Some(70),
3729 "B wrote nothing and still reads A's settlement off the ledger"
3730 );
3731
3732 // B's own settlement measures against the ledger — 100 − 30 − 20 —
3733 // rather than subtracting 20 from a number it was holding.
3734 assert_eq!(b.spend(20).await, Ok(()), "B settles 20");
3735 assert_eq!(remaining(&b).await, Some(50), "both settlements are in it");
3736
3737 let verify = SqliteEventStore::open(&path, stream, &drivers)
3738 .await
3739 .expect("reopen to verify");
3740 let log = as_current(verify.read(0, usize::MAX).await.expect("read log"));
3741 assert_eq!(fold_balance(&log), Some(50), "the balance is the fold");
3742 let moves: Vec<&str> = kinds(&log)
3743 .into_iter()
3744 .filter(|kind| kind.starts_with("budget_"))
3745 .collect();
3746 assert_eq!(
3747 moves,
3748 [KIND_BUDGET_GRANTED, KIND_BUDGET_SPENT, KIND_BUDGET_SPENT],
3749 "one grant and two settlements"
3750 );
3751
3752 // A settlement never refuses: it floors at zero, as the fold does.
3753 assert_eq!(a.spend(1_000).await, Ok(()));
3754 assert_eq!(b.spend(1).await, Ok(()));
3755 assert_eq!(remaining(&a).await, Some(0));
3756 assert_eq!(remaining(&b).await, Some(0));
3757 let verify = SqliteEventStore::open(&path, stream, &drivers)
3758 .await
3759 .expect("reopen to verify");
3760 assert_eq!(
3761 fold_balance(&as_current(
3762 verify.read(0, usize::MAX).await.expect("read log")
3763 )),
3764 Some(0),
3765 "the ledger floors at zero rather than going into debt"
3766 );
3767 }
3768
3769 /// (Concurrency) The balance is the ledger and nothing else, so a handle
3770 /// that has written nothing at all still reports what the other one
3771 /// spent: `B` never calls a write in this test, and every answer it gives
3772 /// comes from folding the stream it shares with `A`.
3773 #[tokio::test]
3774 async fn a_handle_that_wrote_nothing_reports_what_the_other_spent() {
3775 use crate::knl::SqliteEventStore;
3776
3777 let dir = tempfile::tempdir().expect("tempdir");
3778 let path = dir.path().join("events.db");
3779 let stream = "shared-balance-stream";
3780 let drivers = IsleDrivers::new();
3781
3782 let store_a = SqliteEventStore::open(&path, stream, &drivers)
3783 .await
3784 .expect("open A");
3785 let mut a = Session::open_on("user".to_string(), Some(grant(100)), Box::new(store_a))
3786 .await
3787 .expect("open A");
3788 let store_b = SqliteEventStore::open(&path, stream, &drivers)
3789 .await
3790 .expect("open B");
3791 // Not `mut`: reading a balance is a read, and B does nothing else.
3792 let b = Session::resume(None, Box::new(store_b))
3793 .await
3794 .expect("resume B");
3795 assert_eq!(
3796 remaining(&b).await,
3797 Some(100),
3798 "both start on the same ledger"
3799 );
3800
3801 assert_eq!(a.spend(30).await, Ok(()), "A settles 30");
3802 assert_eq!(
3803 remaining(&b).await,
3804 Some(70),
3805 "B sees the settlement it did not make"
3806 );
3807
3808 assert_eq!(a.reserve(20).await, Ok(true), "A reserves 20");
3809 assert_eq!(remaining(&b).await, Some(50), "and the reservation too");
3810 assert!(!exhausted(&b).await);
3811
3812 // Reading twice over a stream that has not moved repeats the fold's
3813 // answer rather than drifting from it.
3814 assert_eq!(
3815 remaining(&b).await,
3816 Some(50),
3817 "a second read is the same read"
3818 );
3819
3820 assert_eq!(a.spend(1_000).await, Ok(()), "A overspends");
3821 assert_eq!(remaining(&b).await, Some(0), "the floor is the ledger's");
3822 assert!(exhausted(&b).await);
3823
3824 // And the log is the whole of the story: nothing B holds was needed.
3825 let verify = SqliteEventStore::open(&path, stream, &drivers)
3826 .await
3827 .expect("reopen to verify");
3828 assert_eq!(
3829 fold_balance(&as_current(
3830 verify.read(0, usize::MAX).await.expect("read log")
3831 )),
3832 remaining(&b).await
3833 );
3834 }
3835
3836 /// A test-local step, standing in for a real one: it renames the two kinds
3837 /// a hypothetical earlier shape used. The kernel chain is empty until the
3838 /// first release, so the seam is exercised with a chain the test owns.
3839 ///
3840 /// It leaves the version alone. The version a step *produces* is
3841 /// [`CURRENT_SCHEMA_VERSION`], which is still `1` — the same one these
3842 /// rows were written under — and a `Current` is asserted to be at it, so
3843 /// a fixture that stamped `2` would be claiming a version the kernel does
3844 /// not have.
3845 struct RenameLegacyKinds;
3846
3847 impl crate::knl::Upcaster for RenameLegacyKinds {
3848 fn upcast(&self, mut event: Value) -> Value {
3849 // Not an object at all: unchanged. An upcaster is total and
3850 // infallible.
3851 let Some(map) = event.as_object_mut() else {
3852 return event;
3853 };
3854 let renamed = match map.get(FIELD_KIND).and_then(Value::as_str) {
3855 Some("legacy_opened") => Some(KIND_SESSION_OPENED),
3856 Some("legacy_response") => Some("llm_response"),
3857 _ => None,
3858 };
3859 if let Some(kind) = renamed {
3860 map.insert(FIELD_KIND.to_string(), Value::from(kind));
3861 }
3862 event
3863 }
3864 }
3865
3866 /// (Upcasting seam) Every read a session makes goes through the chain
3867 /// wrapped round its backend — the restore fold a resume takes, `events`,
3868 /// the `tail` view and the balance fold alike — while the rows on disk
3869 /// keep the shape they were written in.
3870 #[tokio::test]
3871 async fn a_session_reads_every_path_through_the_upcaster_seam() {
3872 use crate::knl::{
3873 SqliteEventStore, Upcaster, CURRENT_SCHEMA_VERSION, SCHEMA_VERSION_FIELD,
3874 };
3875
3876 let dir = tempfile::tempdir().expect("tempdir");
3877 let path = dir.path().join("events.db");
3878 let stream = "seam-stream";
3879 let drivers = IsleDrivers::new();
3880
3881 // Seeded under the older kind names, through the store itself: the
3882 // rows are ordinary appends, so they carry the version they were
3883 // written under.
3884 {
3885 let mut store = SqliteEventStore::open(&path, stream, &drivers)
3886 .await
3887 .expect("open");
3888 store
3889 .append(obj(json!({
3890 "kind": "legacy_opened",
3891 "data": { "owner": "user-3", "scope_id": "scope-from-the-log" }
3892 })))
3893 .await
3894 .expect("the opening");
3895 store
3896 .append(obj(json!({
3897 "kind": "budget_granted",
3898 "data": { "amount": 100, "tag": "tokens" }
3899 })))
3900 .await
3901 .expect("the grant");
3902 store
3903 .append(obj(json!({
3904 "kind": "legacy_response", "beat": "b-1",
3905 "data": {
3906 "content": [{ "type": "text", "text": "ok" }],
3907 "usage": { "input_tokens": 7 }
3908 }
3909 })))
3910 .await
3911 .expect("the response");
3912 }
3913
3914 // The seam the session reads through, carrying the test's own chain:
3915 // `resume_on` is the body of `resume` for exactly this, so the
3916 // session is otherwise the one production builds.
3917 let chain: Vec<Arc<dyn Upcaster>> = vec![Arc::new(RenameLegacyKinds)];
3918 let seamed = CurrentStore::new(
3919 Box::new(
3920 SqliteEventStore::open(&path, stream, &drivers)
3921 .await
3922 .expect("reopen"),
3923 ),
3924 chain,
3925 );
3926 let mut resumed = Session::resume_on(None, seamed)
3927 .await
3928 .expect("resume through the seam");
3929
3930 // The restore read went through the chain: the opening was only a
3931 // `session_opened` after the step, and the scope came off it.
3932 assert_eq!(resumed.owner(), "user-3", "the owner the step revealed");
3933 assert_eq!(resumed.scope_id(), "scope-from-the-log");
3934 assert_eq!(
3935 resumed.grant().and_then(|g| g.tag.as_deref()),
3936 Some("tokens"),
3937 "and the grant with it"
3938 );
3939
3940 // …and so do `events`, the `tail` view and the balance fold.
3941 let log = resumed.events(0, usize::MAX).await.expect("events");
3942 assert_eq!(
3943 kinds(&log),
3944 [KIND_SESSION_OPENED, KIND_BUDGET_GRANTED, "llm_response"],
3945 "every read is projected"
3946 );
3947 let tail = resumed
3948 .view(VIEW_TAIL, Some(&obj(json!({ "n": 1 }))))
3949 .await
3950 .expect("tail");
3951 let last = &tail.as_array().expect("array")[0];
3952 assert_eq!(
3953 kind_of(last),
3954 "llm_response",
3955 "the view read the projected kind: {last}"
3956 );
3957 assert_eq!(last[FIELD_DATA]["usage"]["input_tokens"], json!(7));
3958 assert_eq!(
3959 remaining(&resumed).await,
3960 Some(100),
3961 "the balance folds too"
3962 );
3963
3964 // The stored rows were not rewritten: read them without the seam and
3965 // the old names are still there, under the version they were written
3966 // with.
3967 let raw = SqliteEventStore::open(&path, stream, &drivers)
3968 .await
3969 .expect("reopen raw");
3970 let stored = raw.read(0, usize::MAX).await.expect("read raw");
3971 assert_eq!(kind_of(&stored[0]), "legacy_opened", "{}", stored[0]);
3972 assert_eq!(kind_of(&stored[2]), "legacy_response", "{}", stored[2]);
3973 // And a filtered read of the *stored* stream selects on those old
3974 // names, which is the obligation a renaming step takes on: the
3975 // projected name finds nothing until the rows are rewritten, and they
3976 // never are.
3977 assert!(
3978 raw.read_kinds(Some(&[KIND_SESSION_OPENED]), 0, usize::MAX)
3979 .await
3980 .expect("read raw")
3981 .is_empty(),
3982 "the kind filter selects on what is stored"
3983 );
3984 assert_eq!(
3985 stored[0].get(SCHEMA_VERSION_FIELD).and_then(Value::as_u64),
3986 Some(CURRENT_SCHEMA_VERSION),
3987 "an untouched row keeps the version it was written under: {}",
3988 stored[0]
3989 );
3990 }
3991
3992 /// (Upcasting seam) A stream whose ending is only visible *after* the
3993 /// step is still an ending: the disposable rule reads the projected log,
3994 /// not the stored one.
3995 #[tokio::test]
3996 async fn a_closed_stream_seen_through_the_seam_is_still_refused() {
3997 use crate::knl::{SqliteEventStore, Upcaster};
3998
3999 let dir = tempfile::tempdir().expect("tempdir");
4000 let path = dir.path().join("events.db");
4001 let stream = "seam-closed-stream";
4002 let drivers = IsleDrivers::new();
4003
4004 {
4005 let mut store = SqliteEventStore::open(&path, stream, &drivers)
4006 .await
4007 .expect("open");
4008 store
4009 .append(obj(json!({
4010 "kind": "legacy_opened",
4011 "data": { "owner": "user-3", "scope_id": "scope-from-the-log" }
4012 })))
4013 .await
4014 .expect("the opening");
4015 store
4016 .append(obj(json!({
4017 "kind": "session_closed", "data": { "reason": "done" }
4018 })))
4019 .await
4020 .expect("the ending");
4021 }
4022
4023 let chain: Vec<Arc<dyn Upcaster>> = vec![Arc::new(RenameLegacyKinds)];
4024 let seamed = CurrentStore::new(
4025 Box::new(
4026 SqliteEventStore::open(&path, stream, &drivers)
4027 .await
4028 .expect("reopen"),
4029 ),
4030 chain,
4031 );
4032 let err = Session::resume_on(None, seamed)
4033 .await
4034 .expect_err("a stream that ended must not be resumed");
4035 assert!(
4036 err.reason().contains("session is closed"),
4037 "{}",
4038 err.reason()
4039 );
4040 }
4041
4042 // -- the in-memory database, and reading the log with SQL ---------------
4043
4044 /// An in-memory session is a session, not a lesser one: it is a stream in
4045 /// a real database, so a second handle on its name finds the same log and
4046 /// resuming it restores the state. What it cannot do is outlive the
4047 /// process — the database is reclaimed when the last handle on it goes —
4048 /// and nothing here pretends otherwise.
4049 #[tokio::test]
4050 async fn an_in_memory_stream_is_resumable_while_it_is_open() {
4051 let mut s = new_session(Some(100)).await;
4052 assert_eq!(s.reserve(30).await, Ok(true));
4053 s.append(obj(json!({ "kind": "note", "data": { "text": "hi" } })))
4054 .await
4055 .expect("append");
4056
4057 // The session id *is* the stream, so it is what a resume names.
4058 let store = SqliteEventStore::open_memory(s.id(), &IsleDrivers::new())
4059 .await
4060 .expect("reopen the stream");
4061 let resumed = Session::resume(None, Box::new(store))
4062 .await
4063 .expect("resume");
4064 assert_eq!(resumed.owner(), ANON);
4065 assert_eq!(remaining(&resumed).await, Some(70), "the ledger came back");
4066 assert_eq!(
4067 kinds(&resumed.events(0, usize::MAX).await.expect("events")),
4068 vec![
4069 KIND_SESSION_OPENED,
4070 KIND_BUDGET_GRANTED,
4071 KIND_BUDGET_RESERVED,
4072 "note"
4073 ]
4074 );
4075
4076 // Two sessions are two databases: neither name is the other's.
4077 let other = new_session(Some(100)).await;
4078 assert_ne!(other.id(), s.id());
4079 assert_eq!(
4080 other.len().await.expect("len"),
4081 2,
4082 "opened + granted, and no note"
4083 );
4084 }
4085
4086 /// A session reads its own log with SQL, and `$stream` is what makes
4087 /// "its own" true without the caller having to know its id.
4088 #[tokio::test]
4089 async fn a_session_reads_its_own_log_with_sql() {
4090 let mut s = new_session(None).await;
4091 s.append(obj(
4092 json!({ "kind": "msg_user", "data": { "content": "hi" } }),
4093 ))
4094 .await
4095 .expect("append");
4096 s.append(response(9)).await.expect("recorded");
4097
4098 let found = s
4099 .query(
4100 "SELECT kind, seq FROM events WHERE stream = $stream ORDER BY seq",
4101 QueryParams::None,
4102 &QueryOpts::default(),
4103 )
4104 .await
4105 .expect("query");
4106 assert!(!found.truncated);
4107 let kinds: Vec<&str> = found
4108 .rows
4109 .iter()
4110 .map(|row| row["kind"].as_str().expect("a kind"))
4111 .collect();
4112 assert_eq!(kinds, [KIND_SESSION_OPENED, "msg_user", "llm_response"]);
4113
4114 // A fold the kernel does not name — how many events of each kind —
4115 // is a query rather than a view it had to be taught.
4116 let counted = s
4117 .query(
4118 "SELECT kind, COUNT(*) AS n FROM events WHERE stream = $stream \
4119 GROUP BY kind ORDER BY kind",
4120 QueryParams::None,
4121 &QueryOpts::default(),
4122 )
4123 .await
4124 .expect("query");
4125 assert_eq!(counted.rows.len(), 3);
4126
4127 // Another session's stream is not this one's, even in the same
4128 // process: the set a query reads is the set it named.
4129 let mut other = new_session(None).await;
4130 other
4131 .append(obj(json!({ "kind": "only_theirs" })))
4132 .await
4133 .expect("append");
4134 let mine = s
4135 .query(
4136 "SELECT kind FROM events WHERE stream IN $sessions",
4137 QueryParams::None,
4138 &QueryOpts::default(),
4139 )
4140 .await
4141 .expect("query");
4142 assert!(
4143 !mine.rows.iter().any(|row| row["kind"] == "only_theirs"),
4144 "{:?}",
4145 mine.rows
4146 );
4147
4148 // Reads keep working after the handle closed, like every other read.
4149 s.close(None).await.expect("close");
4150 assert!(s
4151 .query("SELECT 1 AS one", QueryParams::None, &QueryOpts::default())
4152 .await
4153 .is_ok());
4154 }
4155
4156 // -- children: the parent link, and the allocation that paid for it ------
4157
4158 /// A store for `stream` on the database `parent` is already on.
4159 ///
4160 /// What [`Session::open_child`] requires, built the way the bridge builds
4161 /// it: the parent is asked where it is, and the child's store is opened
4162 /// there. The drivers are thrown away on the spot for the same reason
4163 /// the rest of these tests throw them away — the connection thread lives
4164 /// as long as the store holding its handle does.
4165 async fn store_beside(parent: &Session, stream: &str) -> Box<dyn EventStore> {
4166 let db = parent.database().expect("the parent is on a database");
4167 Box::new(
4168 SqliteEventStore::open(std::path::Path::new(db), stream, &IsleDrivers::new())
4169 .await
4170 .expect("a store on the parent's database"),
4171 )
4172 }
4173
4174 /// A fresh stream id, as the layer that opens a child mints one.
4175 fn stream_id() -> String {
4176 uuid::Uuid::new_v4().to_string()
4177 }
4178
4179 /// A second handle on `of`'s stream: another store on the same database,
4180 /// resumed.
4181 ///
4182 /// Two handles on one stream is a supported shape — the store serializes
4183 /// their writes — and it is the only way to have two callers allocating
4184 /// from one parent at the same time.
4185 async fn another_handle(of: &Session) -> Session {
4186 let db = of.database().expect("a database");
4187 let store = SqliteEventStore::open(std::path::Path::new(db), of.id(), &IsleDrivers::new())
4188 .await
4189 .expect("reopen the stream");
4190 let mut handle = Session::resume(None, Box::new(store))
4191 .await
4192 .expect("resume");
4193 handle.adopt_id(of.id().to_string());
4194 handle
4195 }
4196
4197 /// A session with `budget` on the file at `path`, so two handles can
4198 /// contend for one balance through two real connections.
4199 async fn file_session(path: &std::path::Path, budget: i64, drivers: &IsleDrivers) -> Session {
4200 let stream = stream_id();
4201 let store = SqliteEventStore::open(path, stream.clone(), drivers)
4202 .await
4203 .expect("open the stream");
4204 let mut session = Session::open_on(ANON.to_string(), Some(grant(budget)), Box::new(store))
4205 .await
4206 .expect("open");
4207 session.adopt_id(stream);
4208 session
4209 }
4210
4211 /// (2a) The allocation lands on both sides: the child opens with the
4212 /// units and with its parent named, and the parent's ledger carries the
4213 /// reservation that paid for them — one transaction, two streams.
4214 ///
4215 /// And nothing comes back. A child closing is not a refund: the balance
4216 /// only rises when an owner grants.
4217 #[tokio::test]
4218 async fn an_allocation_opens_the_child_and_moves_the_units() {
4219 let mut parent = new_session(Some(100)).await;
4220 let stream = stream_id();
4221 let store = store_beside(&parent, &stream).await;
4222
4223 let mut child = parent
4224 .open_child(
4225 stream.clone(),
4226 "user-42".to_string(),
4227 Allocation::new(40),
4228 store,
4229 )
4230 .await
4231 .expect("the parent's balance covers it");
4232
4233 assert_eq!(child.id(), stream, "the child is the stream it was given");
4234 assert_eq!(child.owner(), "user-42");
4235 assert_eq!(remaining(&parent).await, Some(60), "the parent paid");
4236 assert_eq!(remaining(&child).await, Some(40), "and the child holds it");
4237
4238 // The child's log: it opened, and it opened with the grant. Both name
4239 // the parent, and the scope the handle reports is the one the opening
4240 // recorded — the child is a resumed session over what was written for
4241 // it, not a value built beside the log.
4242 let opened = child.events(0, usize::MAX).await.expect("events");
4243 assert_eq!(
4244 kinds(&opened),
4245 vec![KIND_SESSION_OPENED, KIND_BUDGET_GRANTED]
4246 );
4247 assert_eq!(
4248 field(&opened[0], FIELD_PARENT).as_str(),
4249 Some(parent.id()),
4250 "{}",
4251 opened[0]
4252 );
4253 assert_eq!(
4254 field(&opened[0], FIELD_SCOPE_ID).as_str(),
4255 Some(child.scope_id())
4256 );
4257 assert_eq!(*field(&opened[1], FIELD_AMOUNT), json!(40));
4258 assert_eq!(field(&opened[1], FIELD_PARENT).as_str(), Some(parent.id()));
4259 assert_eq!(
4260 field(&opened[1], FIELD_TAG).as_str(),
4261 Some("tokens"),
4262 "the child counts in the parent's unit unless it was renamed"
4263 );
4264
4265 // The parent's ledger: an ordinary reservation, naming where the
4266 // units went.
4267 let moves = ledger(&parent).await;
4268 assert_eq!(
4269 kinds(&moves),
4270 vec![KIND_BUDGET_GRANTED, KIND_BUDGET_RESERVED]
4271 );
4272 assert_eq!(*field(&moves[1], FIELD_AMOUNT), json!(40));
4273 assert_eq!(
4274 field(&moves[1], FIELD_CHILD).as_str(),
4275 Some(stream.as_str())
4276 );
4277
4278 // A child that closes gives nothing back.
4279 child.close(Some("done")).await.expect("close the child");
4280 assert_eq!(
4281 remaining(&parent).await,
4282 Some(60),
4283 "an allocation is a spend"
4284 );
4285 }
4286
4287 /// A child may count in a unit of its own, and the parent's own ledger
4288 /// entry stays in the parent's.
4289 #[tokio::test]
4290 async fn an_allocation_may_rename_the_unit_for_the_child() {
4291 let mut parent = new_session(Some(100)).await;
4292 let stream = stream_id();
4293 let store = store_beside(&parent, &stream).await;
4294 let child = parent
4295 .open_child(
4296 stream,
4297 "user-42".to_string(),
4298 Allocation {
4299 amount: 10,
4300 tag: Some("turns".to_string()),
4301 },
4302 store,
4303 )
4304 .await
4305 .expect("the allocation");
4306
4307 let opened = child.events(0, usize::MAX).await.expect("events");
4308 assert_eq!(field(&opened[1], FIELD_TAG).as_str(), Some("turns"));
4309 let moves = ledger(&parent).await;
4310 assert_eq!(
4311 field(&moves[1], FIELD_TAG).as_str(),
4312 Some("tokens"),
4313 "the parent's entry counts what the parent counts"
4314 );
4315 }
4316
4317 /// (2b) A balance that will not cover it: the refusal is recorded on the
4318 /// parent, nothing is opened, and the caller is told with the one class
4319 /// that reports a decision rather than a fault.
4320 #[tokio::test]
4321 async fn an_allocation_the_balance_cannot_cover_is_refused_and_recorded() {
4322 let mut parent = new_session(Some(10)).await;
4323 let stream = stream_id();
4324 let store = store_beside(&parent, &stream).await;
4325
4326 let err = parent
4327 .open_child(
4328 stream.clone(),
4329 "user-42".to_string(),
4330 Allocation::new(40),
4331 store,
4332 )
4333 .await
4334 .expect_err("10 does not cover 40");
4335 assert_eq!(err.kind(), KnlError::REFUSED, "{err}");
4336 assert!(
4337 !err.is_retryable(),
4338 "the same balance answers the same: {err}"
4339 );
4340 assert!(err.reason().contains("40"), "{err}");
4341
4342 // The balance did not move, and the refusal says what it was measured
4343 // against and which child it was for.
4344 assert_eq!(remaining(&parent).await, Some(10));
4345 let moves = ledger(&parent).await;
4346 assert_eq!(
4347 kinds(&moves),
4348 vec![KIND_BUDGET_GRANTED, KIND_BUDGET_REFUSED]
4349 );
4350 assert_eq!(*field(&moves[1], FIELD_AMOUNT), json!(40));
4351 assert_eq!(*field(&moves[1], FIELD_REMAINING), json!(10));
4352 assert_eq!(
4353 field(&moves[1], FIELD_CHILD).as_str(),
4354 Some(stream.as_str())
4355 );
4356
4357 // …and the child's stream was never written: a refused allocation
4358 // leaves no half-opened session behind.
4359 let unused = store_beside(&parent, &stream).await;
4360 assert_eq!(unused.len().await.expect("len"), 0);
4361 }
4362
4363 /// A parent with no budget has no ledger to measure against, so the
4364 /// allocation is allowed — the same rule `reserve` follows. The child
4365 /// gets a ledger of its own all the same, because a grant is what starts
4366 /// one.
4367 #[tokio::test]
4368 async fn a_parent_with_no_budget_allocates_without_a_balance_to_measure() {
4369 let mut parent = new_session(None).await;
4370 let stream = stream_id();
4371 let store = store_beside(&parent, &stream).await;
4372 let child = parent
4373 .open_child(stream, "user-42".to_string(), Allocation::new(7), store)
4374 .await
4375 .expect("there is no balance to refuse against");
4376
4377 assert_eq!(remaining(&parent).await, None, "still no budget here");
4378 assert_eq!(remaining(&child).await, Some(7));
4379 }
4380
4381 /// The tree is one log. A child store on another database is refused
4382 /// before anything is written, because the two halves of an allocation
4383 /// share a transaction and a transaction covers one database.
4384 #[tokio::test]
4385 async fn a_child_on_another_database_is_refused() {
4386 let mut parent = new_session(Some(100)).await;
4387
4388 let stranger = stream_id();
4389 let elsewhere = SqliteEventStore::open_memory(stranger.clone(), &IsleDrivers::new())
4390 .await
4391 .expect("another in-memory database");
4392 let err = parent
4393 .open_child(
4394 stranger,
4395 "user-42".to_string(),
4396 Allocation::new(10),
4397 Box::new(elsewhere),
4398 )
4399 .await
4400 .expect_err("that is a different log");
4401 assert_eq!(err.kind(), KnlError::VALIDATION, "{err}");
4402 assert!(err.reason().contains("one log"), "{err}");
4403 assert_eq!(
4404 parent.len().await.expect("len"),
4405 2,
4406 "opened + granted, and nothing else"
4407 );
4408
4409 // A store that is not a database at all has no database to share, and
4410 // says so rather than being taken as "the same one".
4411 let mut single = Session::open_on(
4412 ANON.to_string(),
4413 Some(grant(50)),
4414 Box::new(MemEventStore::new()),
4415 )
4416 .await
4417 .expect("open");
4418 let err = single
4419 .open_child(
4420 stream_id(),
4421 "user-42".to_string(),
4422 Allocation::new(1),
4423 Box::new(MemEventStore::new()),
4424 )
4425 .await
4426 .expect_err("no database to open a child on");
4427 assert_eq!(err.kind(), KnlError::VALIDATION, "{err}");
4428 }
4429
4430 /// A child opens on a stream of its own. A stream that already carries
4431 /// events is a bad argument rather than a decision about the balance:
4432 /// nothing is written on either side, so a stream id that came round twice
4433 /// cannot leave a log with two `session_opened`s and a `budget_granted`
4434 /// nobody's owner allowed.
4435 #[tokio::test]
4436 async fn a_child_does_not_open_on_a_stream_that_already_has_events() {
4437 let mut parent = new_session(Some(100)).await;
4438
4439 // A stream a child is already on: the same id, offered twice.
4440 let taken = stream_id();
4441 let store = store_beside(&parent, &taken).await;
4442 parent
4443 .open_child(
4444 taken.clone(),
4445 "user-42".to_string(),
4446 Allocation::new(10),
4447 store,
4448 )
4449 .await
4450 .expect("the first allocation");
4451 let before = parent.len().await.expect("len");
4452 assert_eq!(remaining(&parent).await, Some(90));
4453
4454 let store = store_beside(&parent, &taken).await;
4455 let err = parent
4456 .open_child(
4457 taken.clone(),
4458 "user-42".to_string(),
4459 Allocation::new(10),
4460 store,
4461 )
4462 .await
4463 .expect_err("that stream is a session already");
4464 assert_eq!(err.kind(), KnlError::VALIDATION, "{err}");
4465 assert!(err.reason().contains("already has events"), "{err}");
4466
4467 // Neither side moved: no reservation here, and over there the opening
4468 // and the grant the first child really got, with nothing after them.
4469 assert_eq!(parent.len().await.expect("len"), before);
4470 assert_eq!(remaining(&parent).await, Some(90));
4471 let occupied = store_beside(&parent, &taken).await;
4472 assert_eq!(occupied.len().await.expect("len"), 2);
4473
4474 // Any event is enough — the stream does not have to be a session for
4475 // a child's opening to be the wrong thing to write onto it.
4476 let stranger = stream_id();
4477 let mut seeded = store_beside(&parent, &stranger).await;
4478 seeded.append(response(1)).await.expect("seed the stream");
4479 let store = store_beside(&parent, &stranger).await;
4480 let err = parent
4481 .open_child(stranger, "user-42".to_string(), Allocation::new(10), store)
4482 .await
4483 .expect_err("something is written there already");
4484 assert_eq!(err.kind(), KnlError::VALIDATION, "{err}");
4485 assert_eq!(parent.len().await.expect("len"), before);
4486 assert_eq!(seeded.len().await.expect("len"), 1, "and nothing was added");
4487
4488 // The ledger carries no refusal either: a balance that was never
4489 // measured has nothing to record.
4490 assert_eq!(
4491 kinds(&ledger(&parent).await),
4492 vec![KIND_BUDGET_GRANTED, KIND_BUDGET_RESERVED]
4493 );
4494
4495 // …and a stream nothing has been written to still opens.
4496 let fresh = stream_id();
4497 let store = store_beside(&parent, &fresh).await;
4498 let child = parent
4499 .open_child(fresh, "user-42".to_string(), Allocation::new(10), store)
4500 .await
4501 .expect("an empty stream is what a child opens on");
4502 assert_eq!(remaining(&child).await, Some(10));
4503 assert_eq!(remaining(&parent).await, Some(80));
4504 }
4505
4506 /// A closed parent opens nothing — whether this handle knows it closed,
4507 /// or whether the ending is only in the log. The second is decided
4508 /// *inside* the write, so a parent that closes between the read and the
4509 /// insert cannot get a child anyway.
4510 #[tokio::test]
4511 async fn a_closed_parent_opens_no_child() {
4512 let mut parent = new_session(Some(100)).await;
4513 // Taken while the stream is open: a resume refuses a closed one.
4514 let mut other = another_handle(&parent).await;
4515 parent.close(Some("done")).await.expect("close");
4516
4517 let stream = stream_id();
4518 let store = store_beside(&parent, &stream).await;
4519 let err = parent
4520 .open_child(stream, "user-42".to_string(), Allocation::new(1), store)
4521 .await
4522 .expect_err("this handle closed");
4523 assert_eq!(err.kind(), KnlError::CLOSED, "{err}");
4524
4525 // The other handle never saw the ending; the decision does.
4526 let stream = stream_id();
4527 let store = store_beside(&other, &stream).await;
4528 let err = other
4529 .open_child(
4530 stream.clone(),
4531 "user-42".to_string(),
4532 Allocation::new(1),
4533 store,
4534 )
4535 .await
4536 .expect_err("the log carries an ending");
4537 assert_eq!(err.kind(), KnlError::CLOSED, "{err}");
4538 let unused = store_beside(&other, &stream).await;
4539 assert_eq!(unused.len().await.expect("len"), 0, "nothing was opened");
4540 }
4541
4542 /// A close records the children that had not ended, and lands anyway: the
4543 /// log never refuses a write, and "this ended while what it started was
4544 /// still going" is the fact worth having.
4545 #[tokio::test]
4546 async fn a_close_records_the_children_that_had_not_ended() {
4547 let mut parent = new_session(Some(100)).await;
4548
4549 let still_open = stream_id();
4550 let store = store_beside(&parent, &still_open).await;
4551 let _running = parent
4552 .open_child(
4553 still_open.clone(),
4554 "user-42".to_string(),
4555 Allocation::new(10),
4556 store,
4557 )
4558 .await
4559 .expect("the allocation");
4560
4561 let ended = stream_id();
4562 let store = store_beside(&parent, &ended).await;
4563 let mut done = parent
4564 .open_child(ended, "user-42".to_string(), Allocation::new(10), store)
4565 .await
4566 .expect("the allocation");
4567 done.close(Some("done")).await.expect("close the child");
4568
4569 parent.close(Some("done")).await.expect("close");
4570 let boundary = parent
4571 .events(0, usize::MAX)
4572 .await
4573 .expect("events")
4574 .pop()
4575 .expect("the boundary");
4576 assert_eq!(boundary.kind(), KIND_SESSION_CLOSED);
4577 assert_eq!(
4578 *field(&boundary, FIELD_OPEN_CHILDREN),
4579 json!([still_open]),
4580 "the child that had ended is not among them: {boundary}"
4581 );
4582 }
4583
4584 /// A session with no children says nothing about them: an absent field,
4585 /// not an empty list, so "there were none" reads the same as it always
4586 /// did.
4587 #[tokio::test]
4588 async fn a_close_with_no_open_children_records_no_such_field() {
4589 let mut s = new_session(None).await;
4590 s.close(Some("done")).await.expect("close");
4591 let boundary = s
4592 .events(0, usize::MAX)
4593 .await
4594 .expect("events")
4595 .pop()
4596 .expect("the boundary");
4597 assert_eq!(
4598 data_field(&boundary, FIELD_OPEN_CHILDREN),
4599 None,
4600 "{boundary}"
4601 );
4602 }
4603
4604 /// Two callers allocating from one parent at the same time cannot both be
4605 /// paid: the decision and the write share a transaction, so the second
4606 /// measures a balance the first has already spent from.
4607 ///
4608 /// On a file, through two connections, because that is where the
4609 /// contention is real — the loser waits out the winner's `IMMEDIATE`
4610 /// transaction and then decides against what it committed.
4611 #[tokio::test]
4612 async fn two_children_allocating_at_once_never_over_allocate() {
4613 let dir = tempfile::tempdir().expect("tempdir");
4614 let path = dir.path().join("events.db");
4615 let drivers = IsleDrivers::new();
4616
4617 let mut one = file_session(&path, 100, &drivers).await;
4618 let mut two = another_handle(&one).await;
4619
4620 let first = stream_id();
4621 let second = stream_id();
4622 let first_store = store_beside(&one, &first).await;
4623 let second_store = store_beside(&two, &second).await;
4624
4625 let (a, b) = tokio::join!(
4626 one.open_child(
4627 first,
4628 "child-a".to_string(),
4629 Allocation::new(60),
4630 first_store
4631 ),
4632 two.open_child(
4633 second,
4634 "child-b".to_string(),
4635 Allocation::new(60),
4636 second_store
4637 ),
4638 );
4639
4640 // 60 + 60 is more than the parent had, so exactly one of them was
4641 // paid for and the sum of what was granted is within the balance.
4642 let granted: i64 = [&a, &b].iter().filter(|outcome| outcome.is_ok()).count() as i64 * 60;
4643 assert_eq!(granted, 60, "exactly one allocation may land");
4644 assert!(
4645 granted <= 100,
4646 "the sum of the grants is within the balance"
4647 );
4648
4649 let refused = match (&a, &b) {
4650 (Err(e), Ok(_)) | (Ok(_), Err(e)) => e,
4651 _ => panic!("one grant and one refusal, got {a:?} / {b:?}"),
4652 };
4653 assert_eq!(refused.kind(), KnlError::REFUSED, "{refused}");
4654
4655 // The parent's log tells the same story: it paid once and turned the
4656 // other down, and the balance is what is left after the one it paid.
4657 assert_eq!(remaining(&one).await, Some(40));
4658 assert_eq!(
4659 kinds(&ledger(&one).await),
4660 vec![
4661 KIND_BUDGET_GRANTED,
4662 KIND_BUDGET_RESERVED,
4663 KIND_BUDGET_REFUSED
4664 ]
4665 );
4666 }
4667
4668 /// An allocation is decided against the whole ledger and against the
4669 /// ending, so the kinds it asks the store for have to be exactly those.
4670 /// A kind added to the ledger and missed here would silently fall out of
4671 /// the balance an allocation measures.
4672 #[test]
4673 fn an_allocation_folds_the_ledger_and_looks_for_the_ending() {
4674 for kind in BUDGET_KINDS {
4675 assert!(
4676 ALLOCATION_KINDS.contains(kind),
4677 "the ledger's {kind} must reach an allocation's decision"
4678 );
4679 }
4680 assert!(ALLOCATION_KINDS.contains(&KIND_SESSION_CLOSED));
4681 assert_eq!(ALLOCATION_KINDS.len(), BUDGET_KINDS.len() + 1);
4682 }
4683}