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