Skip to main content

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}