agent_block_core/knl/mod.rs
1//! `knl` — the kernel core: the log, the ledger, the session and its scope.
2//!
3//! This module doc is the kernel's design, stated once. Each section below
4//! is an invariant the code is held to, named so that the code depending on
5//! it can cite it.
6//!
7//! # The kernel and the shell
8//!
9//! The kernel is written in two halves, and only the first of them is here.
10//!
11//! The **Rust half** — this module — is the kernel context: the session's
12//! state, the syscalls that move it, and two fixed reads. It is pure Rust:
13//! nothing here knows about Lua. Events are plain `serde_json` objects, so
14//! the Lua ⇄ JSON conversion — and the re-entrancy discipline that comes
15//! with walking a Lua table — stays in the [`crate::bridge::knl`] adapter,
16//! one place, while the domain rules below stay unit-testable without a VM.
17//!
18//! The **Lua half** is the shell's kernel library (`knl`): the beat — one
19//! model call plus the tools that call asks for — the device a beat calls
20//! through, and the query views. What a conversation looks like on the
21//! wire, what a beat is allowed to cost, which tools may run, when to stop
22//! asking: all of that is the shell's, and none of it is here.
23//!
24//! The line between the halves is what each of them refuses to renegotiate.
25//! The kernel fixes the record, the quota and the boundaries of a session.
26//! Everything a caller could reasonably want different sits above it.
27//!
28//! # Session and scope
29//!
30//! A session has a scope. The two are different concepts sharing one
31//! lifetime: the session is the stream (its history and the projections over
32//! it), the [`Scope`] is the authority it is written under — a kernel-issued
33//! [`ScopeId`], the owner, the granted quota. A session holds its scope by
34//! value, since neither outlives the other.
35//!
36//! The scope id is recorded on `session_opened` and on every `budget_*`
37//! event, so the boundary is recoverable from the log — and unforgeable,
38//! since those kinds are the kernel's alone to write ([`is_kernel_only`]).
39//!
40//! There is no per-event author: a session holds only its own events, so
41//! ownership is the scope-level [`Session::owner`] — a real principal id, or
42//! the reserved [`session::ANON`] / [`session::SYSTEM`] — total, and read by
43//! the policy layer above the kernel. An accounting of what was consumed
44//! keys on the `kind`: in a session's own log an `llm_response` is a call it
45//! made, so a reader that sums the counts needs no author to key on.
46//!
47//! All state lives inside a [`Session`] value — no statics — so two sessions
48//! are fully independent.
49//!
50//! # A beat is declared, not numbered
51//!
52//! A `beat` is an opaque string the layer above mints and stamps on the
53//! facts that belong together, written as `meta.beat` — a label like any
54//! other, under the rule `meta` already has. The kernel never generates one
55//! and never requires one. Grouping and ordering read it back, and nothing
56//! else does.
57//!
58//! Numbering it here would put a cursor back into kernel state, and that
59//! number would then have to survive a resume, two handles, and a store that
60//! serializes writes in arrival order. A declared id survives all three
61//! while the kernel holds nothing.
62//!
63//! # The budget is a quota
64//!
65//! The budget is what an owner allows a session to consume — not a record of
66//! what it used. It buys two things: a stopping guarantee (termination is
67//! undecidable, so a monotonically decreasing resource is injected from
68//! outside) and an authority boundary (whatever the model decides, the owner
69//! has bounded the run).
70//!
71//! **Two deductions, and neither holds anything for the other.**
72//! [`Session::reserve`] is a deduction that *asks*: it refuses, without
73//! deducting, when the balance will not cover `n`. [`Session::spend`] is a
74//! deduction that does not ask: it takes `n` off (flooring at zero) and
75//! reports only that it was recorded. There is no hold and no settlement
76//! between them — nothing is reserved *for* a later spend to release or
77//! reconcile — so **a beat that calls both deducts twice**, and which of the
78//! two a beat uses is the layer above's to decide. A run that wants to be
79//! stopped before it spends asks with `reserve`; a run that only meters what
80//! already happened deducts with `spend`. No `append` moves the balance.
81//!
82//! **Every move is an event, and the balance is a fold over them.** A
83//! grant, a reservation, a refusal and an unasked deduction are each a
84//! `budget_*` event ([`BUDGET_KINDS`]), written by the kernel alone, and
85//! [`fold_balance`] over those events *is* the balance — there is no counter
86//! beside them. [`Session::remaining`] reads it back off the stream (cached
87//! against the store's head, refolded when the head moves), so two handles
88//! on one stream cannot hold two different answers. A refusal is recorded
89//! like the rest: that a request was turned down is a fact about the run.
90//!
91//! **Monotonicity.** The ledger accepts non-negative amounts only, and
92//! within a session the balance can only decrease. It rises only when an
93//! owner grants again ([`BudgetGrant`]), which a resumed session records like
94//! any other fact. There is no API to raise or reset it, and no release: a
95//! reservation that was made is not handed back.
96//!
97//! **Usage is not accounting.** What the providers reported is a separate
98//! reading, taken off the recorded responses, and the kernel never folds it
99//! into the balance. A budget denominated in tokens will — if the layer
100//! above deducts honestly, and deducts once — end with `granted - remaining`
101//! equal to the usage total, because both are folds over the same log. That
102//! is a consequence, not a requirement, and nothing here checks it.
103//!
104//! **Allocation, not limit.** A budget is an *allocation* axis: units are
105//! consumed and do not come back, and a child scope can only be given what
106//! its parent already holds. A rate limit is a *limit* axis — replenished
107//! by the passage of time — with different arithmetic, and it does not
108//! belong in the same counter. If the ledger ever grows named axes, each
109//! axis declares which of the two it is.
110//!
111//! # Facts live in the kernel, structure is run by the supervisor
112//!
113//! A session can be opened *from* another one ([`Session::open_child`]), and
114//! the kernel records exactly two facts about that and no more: the child's
115//! stream names its parent on its `session_opened`, and the allocation that
116//! paid for it is one transaction on the parent's ledger — a
117//! `budget_reserved` naming the child, against a `budget_granted` on the
118//! child naming the parent, or a `budget_refused` and no child at all. The
119//! child is opened on the *same database* as its parent, because a tree that
120//! spanned two logs could not be read back by one statement, and both halves
121//! of an allocation have to land or neither may.
122//!
123//! Nothing is released when a child closes. An allocation is a spend from
124//! the parent's point of view (§ *The budget is a quota*, "Allocation, not
125//! limit"): the units left with the child, and a refund would be the balance
126//! rising without an owner granting.
127//!
128//! **A close is never refused for a child that is still running.** It
129//! records them — `session_closed.data.open_children` — and lands, in the
130//! same transaction as the scan that found them, because the log never turns
131//! a write away and "this ended while its children had not" is precisely the
132//! fact an audit is reading for.
133//!
134//! What the kernel does *not* know is what a tree is. It does not walk one,
135//! does not stop a close, does not cascade an ending, does not decide who may
136//! allocate to whom, and holds no parent pointer in memory — the facts are in
137//! the log and a reader assembles them ([`Session::query`]; the Lua kernel's
138//! `knl.views.tree` is one recursive `SELECT` over exactly these fields). A
139//! supervisor pack above the kernel is where a policy over a tree belongs,
140//! and it needs the kernel only for the part it cannot do for itself: making
141//! the two sides of an allocation one write.
142//!
143//! # Views: the log is the only source of truth
144//!
145//! A *view* ([`projection`]) is derived from the log. Folding never changes
146//! the history, and a view's result is a cache rather than a capture —
147//! whatever it says is recomputable from the events, so reading one is never
148//! what makes it true, and a view that disagreed with the log would be the
149//! view that is wrong.
150//!
151//! The views are deliberately spread across the two halves. **The Rust half
152//! has two built-in reads and they never grow**: [`Session::events`]
153//! (`events(from)`, the record from a position on) and [`Session::view`]
154//! (`tail`, the last events verbatim). **Everything else is a Lua query
155//! view** over [`Session::query`] — the conversation a provider is sent, the
156//! beats of a run, the tool pairs, the ledger, the token account — each of
157//! them one `SELECT` over the published event schema rather than a name the
158//! kernel had to be taught.
159//!
160//! So the Rust half names a fold only when its consumer is fixed in kernel
161//! terms, and `tail` is the one that is. Token usage is not: the counts are
162//! what an adapter normalized out of a provider's answer, which is the
163//! shell's vocabulary, so it is a query view written in Lua.
164//!
165//! **Why the Rust side does not take query or fold features.** There is no
166//! query language here and no way to register a fold into one, and that is
167//! the design rather than a gap. The whole expressiveness of SQLite is
168//! already reachable through [`Session::query`] ([`query`]) — one statement,
169//! read-only, over a table whose columns are published ([`events_schema`]) —
170//! so "the kernel needs dynamic queries" is answered by writing a Lua query
171//! view. A second, weaker query surface in Rust would only give the same
172//! answers a name the kernel then has to keep. A fold-registration hook
173//! would be worse: it moves a caller's code inside the kernel, where neither
174//! its cost nor its purity is the caller's problem any more. That the table
175//! *is* the read interface is what makes this a contract rather than a leak
176//! — changing it is a change to the interface.
177//!
178//! **One backend, and the log is a table.** A session's events live in
179//! SQLite whether the session is durable (a file) or ephemeral (an in-memory
180//! database) — [`SqliteEventStore`], the only [`EventStore`] the product has.
181//! That is not an implementation detail: the read side above is SQL, and a
182//! log that could not be queried would be a second, lesser kind of session.
183//! The `Vec`-backed store is `#[cfg(test)]`.
184//!
185//! **The store is an adapter, and the log underneath it is not the kernel's.**
186//! [`SqliteEventStore`] translates this SPI onto `eventsdb` — a SQLite event
187//! log with a writer thread of its own, read-only connections beside it, a
188//! migration ladder for the *table's* shape and a transaction hatch for the
189//! two writes that span more than one stream. What stays the kernel's is what
190//! only the kernel can say: which events are well formed
191//! ([`validate_event`]), which shape they are written under
192//! ([`CURRENT_SCHEMA_VERSION`]), and what a session *is*.
193//!
194//! **A log is opened once, and the sessions in it are streams.** [`Logs`] is
195//! the host's collection of open logs: a file is opened once per process and
196//! shared, because the upcaster chain is per log and two logs on one file
197//! would be two chains and two write locks. A session a script opens with no
198//! `store` of its own goes to the database the host owns — one file per
199//! project — and `store = "mem"` goes to the one in-memory log of the run.
200//! The in-memory one is not a lesser kind of session: it is a database with
201//! one writer like any other, so a tree works in it and a stream in it can be
202//! resumed by name, for as long as the host lives. What it cannot do is
203//! survive the process.
204//!
205//! # Stored shape: envelope, meta, data
206//!
207//! An event is an envelope ([`FIELD_KIND`] and the kernel's `seq` /
208//! `epoch_ms` / `_schema_version`), a shallow `meta`, and a `data` object
209//! holding the kind's own content. Nothing else may sit at the top level.
210//! The three levels are separated so that a reader can tell which of them it
211//! is reading:
212//!
213//! ```text
214//! envelope kind, seq, epoch_ms, _schema_version ← columns; never renamed
215//! meta { label = "a", attempt = 2, beat = "b1" } ← shallow by rule: scalars only
216//! data { content = { … }, usage = { … } } ← the kind's own, any depth
217//! ```
218//!
219//! - The **envelope** is the stable contract. Its keys are the columns of
220//! the `events` table ([`events_schema`]), and they do not get renamed: a
221//! view built on them is unaffected by any kind changing shape.
222//! - **`meta`** holds scalars — a string, a number or a boolean — and
223//! nesting is refused. That is what makes it readable without knowing the
224//! kind: a label to group by, a flag to filter on.
225//! - **`data`** is the only place structured JSON lives, and its shape
226//! belongs to whoever writes the kind. The kernel checks the `data` of the
227//! six kinds it writes itself ([`is_kernel_only`]) and of no others; the
228//! kinds a beat is made of are the Lua kernel's, declared where they are
229//! written.
230//!
231//! The rule that follows, and the reason for the split: **a SQL view that
232//! reads a `data` path is updated in the same round as the kind whose shape
233//! it reads.** When everything sat at one level, a change to what one kind
234//! recorded broke a `json_extract` path with nothing to say which change had
235//! done it. Structured JSON is unavoidable in an event log; confining it to
236//! one key is what makes its evolution reviewable.
237//!
238//! `_schema_version` is the whole *object's*, not `data`'s: the upcaster seam
239//! ([`Upcaster`], [`Current`]) applies to the event as it was stored, and
240//! `data` is simply where the changes it will have to absorb happen.
241//!
242//! # An append lands; a command decides in the store
243//!
244//! **The record is append-only.** [`History`] has no mutation API — no
245//! `update`, `delete` or `replace`. `seq` is assigned by the kernel, starts
246//! at `1` and increases strictly; a caller-supplied `seq` / `epoch_ms` is
247//! overwritten rather than trusted. Reads hand back clones, so a caller
248//! cannot reach recorded state through a returned value.
249//!
250//! **An append lands.** Recording a fact is never refused for what the
251//! writing handle last saw: the store assigns the `seq` and serializes
252//! writes per stream, so two handles on one stream both append and the log
253//! interleaves in arrival order. The one place a check belongs — "reserve
254//! `n` only if the balance covers it" — runs inside that same serialized
255//! write ([`EventStore::append_if`]), never against a cached balance.
256//!
257//! **The lifecycle is the session's own.** There is no "run" inside a
258//! session: it is bracketed by the `session_opened` that
259//! [`Session::open_on`] records and the `session_closed` that
260//! [`Session::close`] records. Both are kernel-only ([`is_kernel_only`]), so
261//! a caller can neither fake an opening nor end a session by appending an
262//! event.
263//!
264//! **Closed is the handle's, not the stream's.** A handle that closed
265//! refuses its own later `append` / `spend`, while the log itself never
266//! refuses a write: one arriving from another handle after an ending lands,
267//! as evidence, and two handles that both close leave two endings rather
268//! than one. Exactly one reader consults `session_closed`, and it is
269//! [`Session::resume`].
270//!
271//! **A session is disposable.** It opens once and closes once, and
272//! [`Session::resume`] refuses a stream whose `session_closed` is already in
273//! the log: after an ending there is a new session, not a second life for
274//! the old one.
275//!
276//! # Errors
277//!
278//! A failure is classified rather than described. [`KnlError`] is a closed
279//! set of eight classes ([`KnlError::KINDS`]) — `busy`, `storage`,
280//! `corruption`, `closed`, `validation`, `unsupported`, `timeout`,
281//! `refused` — and the
282//! variant *is* the classification: the payload is a human-readable reason
283//! and nothing a caller should branch on. [`KnlError::is_retryable`] answers
284//! the one question that belongs to a program rather than to a person, and
285//! it is true for `busy` and nothing else.
286//!
287//! The core does not know which method a caller invoked, so
288//! [`Display`](std::fmt::Display) writes `<kind>: <reason>` and the adapter
289//! adds the attribution: a failure reaches Lua as the raised text
290//! `knl: <method>: <kind>: <reason>`, which `knl.error(e)` reads back as
291//! `{ kind, method, retryable, message }`. The message is given a shape
292//! because mlua cannot carry a table out of a Rust callback — the first
293//! three fields are a closed vocabulary and only the last is prose. See
294//! [`crate::bridge::knl`].
295//!
296//! # Upcasting: a stored shape change ships with its upcaster
297//!
298//! Stored bytes are never rewritten. A change to the shape of a stored
299//! event ships with a bump of [`CURRENT_SCHEMA_VERSION`] and the matching
300//! read-time [`Upcaster`] ([`kernel_upcasters`]), which every session's reads
301//! pass through.
302//!
303//! The chain is registered on the log ([`Logs`]), because that is where it is
304//! applied: the backend runs it over everything it reads, including the events
305//! a decision is shown inside its own transaction. The seam above it is a
306//! *type*: a backend deals in raw `Value`s, [`CurrentStore`] hands back
307//! [`Current`]s, and every fold takes those — so a read that went round the
308//! seam does not compile, and an event the chain did not bring to
309//! [`CURRENT_SCHEMA_VERSION`] does not get past it.
310//!
311//! # Async: everything that waits, yields
312//!
313//! **The VM thread never waits on the OS.** That is the whole rule, and the
314//! syscalls follow it: `append`, `reserve`, `spend`, the reads and `close`
315//! are `async fn`, and so are `Session::new` / `open_on` / `resume` and the
316//! [`EventStore`] SPI underneath them.
317//!
318//! The thread this matters for is the Lua VM's. It is the *only* worker of
319//! the runtime that also drives every other coroutine that VM owns, every
320//! timer they set and every cancellation watching them, so a syscall that
321//! parked it would stop all of them — for as long as a contended SQLite write
322//! takes, which is bounded by a busy timeout and a retry policy rather than by
323//! anything a caller chose. These calls used to be synchronous, and the
324//! reasoning for that ("a local SQLite write is quick") mistook *where* the
325//! blocking landed: the connection lives on its own thread, but the caller
326//! was waiting on it from the one thread that must not wait.
327//!
328//! So waiting is yielding, everywhere. [`SqliteEventStore`] sends each call
329//! to the thread that owns its connection ([`rusqlite_isle::AsyncIsle`]) and
330//! suspends on the answer; the bridge binds the session's methods with
331//! `add_async_method`, so `s:append(...)` is a coroutine yield on the Lua
332//! side and the beat's `pcall` / `<close>` / step structure is unchanged
333//! (Lua 5.4 yields across all three). Device I/O — an HTTP request, an MCP
334//! call — was already async and is unaffected; what changed is that the
335//! syscalls now behave the same way it does.
336//!
337//! Two things stay synchronous, and both are deliberate: the identity reads
338//! (`id` / `scope_id` / `owner`), which answer out of the value and touch no
339//! store, and [`Session::close_detached`], the drop backstop — `Drop` cannot
340//! await and must not block, so it hands its `session_closed` to the log's
341//! own queue and lets go ([`EventStore::detach_append`]). That works because
342//! the log outlives the sessions in it: the logs belong to a [`Logs`] the host
343//! holds and drains once, at shutdown.
344
345pub mod budget;
346pub mod event;
347pub mod event_store;
348pub mod history;
349pub mod logs;
350pub mod projection;
351pub mod query;
352pub mod scope;
353pub mod session;
354pub mod sqlite_store;
355
356pub use budget::{fold_balance, Allocation, BudgetGrant};
357pub use event::{
358 is_kernel_only, now_ms, validate_event, BUDGET_KINDS, FIELD_AMOUNT, FIELD_CHILD, FIELD_DESC,
359 FIELD_DETAIL, FIELD_EPOCH_MS, FIELD_KIND, FIELD_OPEN_CHILDREN, FIELD_OWNER, FIELD_PARENT,
360 FIELD_REASON, FIELD_REMAINING, FIELD_SCOPE_ID, FIELD_SEQ, FIELD_TAG,
361};
362#[cfg(test)]
363pub use event_store::MemEventStore;
364pub use event_store::{
365 apply_upcasters, kernel_upcasters, ChildScan, ChildrenDecision, Committed, Current,
366 CurrentDecision, CurrentSplitDecision, CurrentStore, Decision, EventStore, Split,
367 SplitDecision, Upcaster, CURRENT_SCHEMA_VERSION, SCHEMA_VERSION_FIELD,
368};
369pub use history::History;
370pub use logs::Logs;
371pub use query::{QueryOpts, QueryParams, QueryPlan, QueryRows, DEFAULT_LIMIT, DEFAULT_TIMEOUT_MS};
372pub use scope::{Scope, ScopeId};
373pub use session::{
374 Session, ANON, CLOSE_REASON_DROPPED, CLOSE_REASON_ERROR, CLOSE_REASON_SCOPE_EXIT,
375 DEFAULT_CLOSE_REASON, SYSTEM,
376};
377pub use sqlite_store::{events_schema, SchemaColumn, SqliteEventStore, EVENTS_TABLE};
378
379/// What went wrong in the kernel core, classified.
380///
381/// A failure is not one thing. A contended database will succeed if it is
382/// asked again; a row that will not decode never will. A caller that passed
383/// a negative amount has a bug in its own code; a caller that wrote to a
384/// closed handle has finished with the session and needs a new one. Folding
385/// all four into one opaque string leaves every caller — the Lua shell most
386/// of all — matching on message text to tell them apart, and message text is
387/// the one part of an error that is meant to change.
388///
389/// So the variant *is* the classification, and it is the whole of it: the
390/// payload is a human-readable sentence and nothing a caller should branch
391/// on. [`KnlError::kind`] names the class in one stable word, and
392/// [`KnlError::is_retryable`] answers the only question whose answer is a
393/// program's rather than a person's.
394///
395/// The core does not know which Lua method the caller invoked, so the
396/// message carries the reason only; the adapter renders the
397/// `knl: <method>: <kind>: <reason>` attribution.
398#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
399pub enum KnlError {
400 /// Lock contention: the store was busy and the same call may succeed if
401 /// it is made again. The one retryable class ([`KnlError::is_retryable`]).
402 #[error("busy: {0}")]
403 Busy(String),
404 /// The store could not do the work — an IO fault, a connection that is
405 /// gone, an encode failure on the way in. Not busy, so retrying it is a
406 /// caller's gamble rather than the kernel's advice.
407 #[error("storage: {0}")]
408 Storage(String),
409 /// A stored row could not be read back as the event it was written as.
410 /// Distinct from [`KnlError::Storage`] on purpose: the IO succeeded and
411 /// the bytes came back, so what is wrong is the data, and no retry and
412 /// no reconnect will change it.
413 #[error("corruption: {0}")]
414 Corruption(String),
415 /// The session is over — this handle closed, or a resume was pointed at
416 /// a stream whose log already carries its ending. A session is
417 /// disposable, so the answer is to open another, not to try again.
418 #[error("closed: {0}")]
419 Closed(String),
420 /// The caller asked for something the kernel refuses to record: an event
421 /// that does not meet its kind's shape, a kernel-only kind, a negative
422 /// amount, an unknown view or a malformed option. Nothing was written.
423 #[error("validation: {0}")]
424 Validation(String),
425 /// The request is well-formed but this backend cannot serve it — a query
426 /// put to a store that keeps no queryable table.
427 ///
428 /// **Internal to the SPI: nothing a Lua caller does produces one today.**
429 /// Both sites that raise it are [`EventStore`] trait *defaults* — the
430 /// `query` a backend with no table cannot answer, and the two-stream
431 /// `append_if_many` a backend with one stream cannot write — and the only
432 /// backend the product has ([`SqliteEventStore`]) overrides both. The
433 /// shape a caller might expect here answers differently on purpose: an
434 /// unknown view name is a [`KnlError::Validation`], because the argument
435 /// was wrong and the message says which.
436 ///
437 /// It stays in the vocabulary all the same, and is published to Lua with
438 /// the rest ([`KnlError::KINDS`]): a store *may* return it — the trait
439 /// says so — and a class a backend can produce but a caller was never told
440 /// about is a class nobody handles. A test double that keeps one stream
441 /// reaches both defaults today.
442 #[error("unsupported: {0}")]
443 Unsupported(String),
444 /// A read ran past the time it was given and was cut short. Distinct
445 /// from [`KnlError::Busy`] on purpose: nothing was contended, the work
446 /// itself was too slow, so making the same call again buys nothing —
447 /// what changes the answer is a narrower query or a longer deadline.
448 #[error("timeout: {0}")]
449 Timeout(String),
450 /// A quota did not cover what was asked for, and the refusal was
451 /// recorded.
452 ///
453 /// The one class that reports a *decision* rather than a fault. Nothing
454 /// is wrong: the request was well-formed, the store answered, and the
455 /// answer is no — [`Session::open_child`] raises it when the parent's
456 /// balance will not cover the allocation, having written the
457 /// `budget_refused` that says so. Distinct from
458 /// [`KnlError::Validation`] because the caller's arguments were fine, and
459 /// not retryable, because the same call against the same balance gets the
460 /// same answer; what changes it is the owner granting more.
461 ///
462 /// [`Session::reserve`] does *not* raise this — it answers `false`,
463 /// because a reservation is asked for in a loop that is expected to be
464 /// told no. An allocation is not: it either produced a child or it did
465 /// not, and there is no half-opened session to hand back.
466 #[error("refused: {0}")]
467 Refused(String),
468}
469
470impl KnlError {
471 /// The stable name of the [`KnlError::Busy`] class.
472 pub const BUSY: &'static str = "busy";
473 /// The stable name of the [`KnlError::Storage`] class.
474 pub const STORAGE: &'static str = "storage";
475 /// The stable name of the [`KnlError::Corruption`] class.
476 pub const CORRUPTION: &'static str = "corruption";
477 /// The stable name of the [`KnlError::Closed`] class.
478 pub const CLOSED: &'static str = "closed";
479 /// The stable name of the [`KnlError::Validation`] class.
480 pub const VALIDATION: &'static str = "validation";
481 /// The stable name of the [`KnlError::Unsupported`] class.
482 pub const UNSUPPORTED: &'static str = "unsupported";
483 /// The stable name of the [`KnlError::Timeout`] class.
484 pub const TIMEOUT: &'static str = "timeout";
485 /// The stable name of the [`KnlError::Refused`] class.
486 pub const REFUSED: &'static str = "refused";
487
488 /// Every class a kernel failure can have, in one closed list.
489 ///
490 /// Published so a caller can hold its own error vocabulary against the
491 /// kernel's — the Lua bridge hands this to `knl.api()`, and the shell's
492 /// declaration is checked against it rather than against a list somebody
493 /// retyped.
494 pub const KINDS: &'static [&'static str] = &[
495 Self::BUSY,
496 Self::STORAGE,
497 Self::CORRUPTION,
498 Self::CLOSED,
499 Self::VALIDATION,
500 Self::UNSUPPORTED,
501 Self::TIMEOUT,
502 Self::REFUSED,
503 ];
504
505 /// This failure's class, as one of [`KnlError::KINDS`].
506 pub fn kind(&self) -> &'static str {
507 match self {
508 Self::Busy(_) => Self::BUSY,
509 Self::Storage(_) => Self::STORAGE,
510 Self::Corruption(_) => Self::CORRUPTION,
511 Self::Closed(_) => Self::CLOSED,
512 Self::Validation(_) => Self::VALIDATION,
513 Self::Unsupported(_) => Self::UNSUPPORTED,
514 Self::Timeout(_) => Self::TIMEOUT,
515 Self::Refused(_) => Self::REFUSED,
516 }
517 }
518
519 /// Whether making the same call again could succeed.
520 ///
521 /// True for [`KnlError::Busy`] and nothing else. A storage fault *might*
522 /// clear, but the kernel does not know that it will, and an error that
523 /// says "try again" when it means "maybe" is how a retry loop becomes an
524 /// infinite one.
525 pub fn is_retryable(&self) -> bool {
526 matches!(self, Self::Busy(_))
527 }
528
529 /// Whether a class *name* is the retryable one.
530 ///
531 /// The same answer as [`KnlError::is_retryable`], for a caller that has
532 /// the word rather than the value — the Lua bridge, which parses a kind
533 /// back out of an attributed message.
534 pub fn kind_is_retryable(kind: &str) -> bool {
535 kind == Self::BUSY
536 }
537
538 /// The reason, without the class name or any attribution prefix.
539 ///
540 /// [`Display`](std::fmt::Display) writes `<kind>: <reason>`; this is the
541 /// second half alone, for a caller that renders the class itself.
542 pub fn reason(&self) -> &str {
543 match self {
544 Self::Busy(reason)
545 | Self::Storage(reason)
546 | Self::Corruption(reason)
547 | Self::Closed(reason)
548 | Self::Validation(reason)
549 | Self::Unsupported(reason)
550 | Self::Timeout(reason)
551 | Self::Refused(reason) => reason,
552 }
553 }
554}
555
556/// Result alias for the kernel core.
557pub type KnlResult<T> = Result<T, KnlError>;
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 /// One error of every class, so a test over the classification cannot
564 /// quietly skip a variant that was added later.
565 fn one_of_each() -> Vec<KnlError> {
566 vec![
567 KnlError::Busy("contended".to_string()),
568 KnlError::Storage("gone".to_string()),
569 KnlError::Corruption("not json".to_string()),
570 KnlError::Closed("session is closed".to_string()),
571 KnlError::Validation("kind is required".to_string()),
572 KnlError::Unsupported("this store keeps no queryable table".to_string()),
573 KnlError::Timeout("query interrupted".to_string()),
574 KnlError::Refused("the balance does not cover it".to_string()),
575 ]
576 }
577
578 /// Every variant names its class with a stable word, and the published
579 /// list is exactly those words in that order.
580 #[test]
581 fn every_variant_names_its_class() {
582 let kinds: Vec<&str> = one_of_each().iter().map(KnlError::kind).collect();
583 assert_eq!(
584 kinds,
585 vec![
586 "busy",
587 "storage",
588 "corruption",
589 "closed",
590 "validation",
591 "unsupported",
592 "timeout",
593 "refused"
594 ]
595 );
596 assert_eq!(
597 kinds,
598 KnlError::KINDS.to_vec(),
599 "KINDS is the vocabulary itself, not a second copy of it"
600 );
601 }
602
603 /// Only contention says "ask again". A storage fault might clear on its
604 /// own, but the kernel does not know that, and an error that promises a
605 /// retry it cannot back is how a loop stops terminating.
606 #[test]
607 fn only_busy_is_retryable() {
608 for error in one_of_each() {
609 let expected = error.kind() == KnlError::BUSY;
610 assert_eq!(error.is_retryable(), expected, "{error}");
611 assert_eq!(
612 KnlError::kind_is_retryable(error.kind()),
613 expected,
614 "the name and the value must agree: {error}"
615 );
616 }
617 assert!(!KnlError::kind_is_retryable("nonsense"));
618 }
619
620 /// `Display` is `<kind>: <reason>`, and `reason` is the second half on
621 /// its own — the adapter renders the class itself, so it must be able to
622 /// get the sentence without it.
623 #[test]
624 fn display_carries_the_class_and_reason_carries_only_the_sentence() {
625 let error = KnlError::Validation("kind is required (string)".to_string());
626 assert_eq!(error.to_string(), "validation: kind is required (string)");
627 assert_eq!(error.reason(), "kind is required (string)");
628
629 for error in one_of_each() {
630 assert_eq!(
631 error.to_string(),
632 format!("{}: {}", error.kind(), error.reason())
633 );
634 }
635 }
636}