Skip to main content

localharness/
error_codes.rs

1//! The one `LHxxxx` error-code registry — a single source of truth spanning
2//! the three failure families an agent (or a user) hits on this platform:
3//!
4//!   * `LH0xxx` — **rustlite COMPILE errors** (lexer / parser / typecheck /
5//!     codegen). The most numerous + most valuable: every compiler diagnostic
6//!     carries one (see [`crate::rustlite::CompileError::code`]).
7//!   * `LH1xxx` — **cartridge RUNTIME errors** (the Web-Worker cartridge engine:
8//!     a hung/trapped `frame()`, a missing entry, an instantiate failure). The
9//!     worker reports the code in its `{type:'error', code, detail}` message and
10//!     the "CARTRIDGE STOPPED" overlay shows it.
11//!   * `LH2xxx` — **on-chain TX REVERTS** (the known facet custom-error
12//!     selectors). [`crate::registry`]'s revert decoder maps a 4-byte selector
13//!     to its code so a revert surfaces `LH2xxx: <name> — <meaning>` instead of
14//!     a bare hash.
15//!   * `LH3xxx` — **BACKEND / agent-runtime** failures (the chat-facing ones):
16//!     a model provider rate-limit / quota, a rejected API key, out-of-credits,
17//!     a request timeout, an empty/truncated response, a transport failure. The
18//!     `.turn-error` chat line shows the code; [`classify`](crate::error_codes::classify) maps a raw error
19//!     string to one of these.
20//!   * `LH4xxx` — **SDK CORE** errors — one per [`crate::Error`] variant, so
21//!     `Error::code()` always resolves to a stable code (the CLI prints it).
22//!
23//! Numbering scheme (stable — codes are NEVER renumbered, only appended):
24//!
25//! | Range        | Family                | Sub-range by stage            |
26//! |--------------|-----------------------|-------------------------------|
27//! | `LH0001`–`LH0099` | compile: lexer   | byte/string/char/number lexing |
28//! | `LH0100`–`LH0199` | compile: parser  | unexpected token / structure   |
29//! | `LH0200`–`LH0299` | compile: typecheck | types / arity / scope        |
30//! | `LH0300`–`LH0399` | compile: codegen | lowering / unsupported emit    |
31//! | `LH1000`–`LH1099` | runtime          | cartridge worker failures      |
32//! | `LH2000`–`LH2099` | tx revert        | facet custom-error selectors   |
33//! | `LH3000`–`LH3099` | backend          | provider/transport/agent runtime |
34//! | `LH4000`–`LH4099` | core             | one per `Error` enum variant   |
35//!
36//! A code is a small stable integer + a static category + a one-line meaning +
37//! a fix hint. The full human/agent index is `docs/error-codes.md`; a compact
38//! list is injected into `self_docs::RUNTIME_SUMMARY` so the agent knows the
39//! codes it will see. This module is pure data — no feature gates, no deps — so
40//! it compiles on every target and is unit-testable headlessly.
41
42/// The families a code belongs to.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Family {
45    /// `LH0xxx` — a rustlite compile error.
46    Compile,
47    /// `LH1xxx` — a cartridge runtime error.
48    Runtime,
49    /// `LH2xxx` — an on-chain transaction revert.
50    TxRevert,
51    /// `LH3xxx` — a backend / agent-runtime failure (provider/transport/chat).
52    Backend,
53    /// `LH4xxx` — an SDK core error (one per [`crate::Error`] variant).
54    Core,
55}
56
57/// The order families are listed in the compact agent-facing index.
58pub const FAMILIES: [Family; 5] = [
59    Family::Compile,
60    Family::Runtime,
61    Family::TxRevert,
62    Family::Backend,
63    Family::Core,
64];
65
66impl Family {
67    /// The family of a numeric code by its thousands digit.
68    pub fn of(code: u16) -> Option<Family> {
69        match code {
70            1..=999 => Some(Family::Compile),
71            1000..=1999 => Some(Family::Runtime),
72            2000..=2999 => Some(Family::TxRevert),
73            3000..=3999 => Some(Family::Backend),
74            4000..=4999 => Some(Family::Core),
75            _ => None,
76        }
77    }
78
79    /// A short label for the index / overlay.
80    pub fn label(self) -> &'static str {
81        match self {
82            Family::Compile => "compile",
83            Family::Runtime => "runtime",
84            Family::TxRevert => "tx-revert",
85            Family::Backend => "backend",
86            Family::Core => "core",
87        }
88    }
89}
90
91/// One registry entry: a stable code, its family, a one-line meaning, and a
92/// fix hint. `code` is the integer behind the `LHxxxx` label (e.g. `20` →
93/// `LH0020`), so the printed form is always four zero-padded digits.
94#[derive(Debug, Clone, Copy)]
95pub struct ErrorCode {
96    /// The stable integer (printed zero-padded to 4 digits after `LH`).
97    pub code: u16,
98    /// Which of the three families this belongs to.
99    pub family: Family,
100    /// A short, stable meaning (no trailing period).
101    pub meaning: &'static str,
102    /// A one-line, actionable fix hint.
103    pub hint: &'static str,
104}
105
106impl ErrorCode {
107    /// The canonical `LHxxxx` label, e.g. `LH0020`.
108    pub fn label(&self) -> String {
109        format!("LH{:04}", self.code)
110    }
111}
112
113/// Format an `LHxxxx` label from a bare integer without a registry lookup.
114/// (`fmt_label(20)` → `"LH0020"`.)
115pub fn fmt_label(code: u16) -> String {
116    format!("LH{code:04}")
117}
118
119// ── LH0xxx — rustlite COMPILE codes ─────────────────────────────────────────
120// Lexer LH00xx.
121/// `LH0001` — an unexpected byte the lexer can't begin a token with.
122pub const UNEXPECTED_BYTE: u16 = 1;
123/// `LH0002` — a string literal with no closing quote (or a newline inside).
124pub const UNTERMINATED_STRING: u16 = 2;
125/// `LH0003` — an unknown `\x` escape in a string or char literal.
126pub const UNKNOWN_ESCAPE: u16 = 3;
127/// `LH0004` — a char literal that isn't exactly one byte (empty/multi/unclosed).
128pub const BAD_CHAR_LITERAL: u16 = 4;
129/// `LH0005` — a malformed numeric literal (bad int/float/hex digits).
130pub const BAD_NUMBER: u16 = 5;
131
132// Parser LH01xx.
133/// `LH0100` — a token didn't match what the grammar required here.
134pub const UNEXPECTED_TOKEN: u16 = 100;
135/// `LH0101` — expected an item (fn/struct/enum/const) at the top level.
136pub const EXPECTED_ITEM: u16 = 101;
137/// `LH0102` — expected a type in a type position.
138pub const EXPECTED_TYPE: u16 = 102;
139/// `LH0103` — expected the start of an expression.
140pub const EXPECTED_EXPRESSION: u16 = 103;
141/// `LH0104` — expected a pattern in a `match` arm / `let`.
142pub const EXPECTED_PATTERN: u16 = 104;
143/// `LH0105` — a statement isn't terminated by `;` or `}`.
144pub const MISSING_SEMICOLON: u16 = 105;
145/// `LH0106` — the assignment target isn't an assignable place (incl. `arr[i] = v`).
146pub const INVALID_ASSIGN_TARGET: u16 = 106;
147/// `LH0107` — expression/block nesting exceeded the parser's recursion cap.
148pub const NESTING_TOO_DEEP: u16 = 107;
149
150// Typecheck LH02xx.
151/// `LH0200` — a use of an unknown type name.
152pub const UNKNOWN_TYPE: u16 = 200;
153/// `LH0201` — a reference to a variable that isn't in scope.
154pub const UNDEFINED_VARIABLE: u16 = 201;
155/// `LH0202` — a call to a function rustlite doesn't know.
156pub const UNKNOWN_FUNCTION: u16 = 202;
157/// `LH0203` — a call with the wrong number of arguments.
158pub const ARITY_MISMATCH: u16 = 203;
159/// `LH0204` — an operand/argument/binding type didn't match what's required.
160pub const TYPE_MISMATCH: u16 = 204;
161/// `LH0205` — assignment to a binding declared without `mut`.
162pub const NOT_MUTABLE: u16 = 205;
163/// `LH0206` — a field access on something that isn't that struct.
164pub const BAD_FIELD_ACCESS: u16 = 206;
165/// `LH0207` — indexing a non-array, a non-i32 index, or an unsupported array.
166pub const BAD_INDEX: u16 = 207;
167/// `LH0208` — an `as` cast between non-numeric types.
168pub const BAD_CAST: u16 = 208;
169/// `LH0209` — an unknown struct in a struct-literal.
170pub const UNKNOWN_STRUCT: u16 = 209;
171
172// Codegen LH03xx.
173/// `LH0300` — codegen hit a construct it can't lower to wasm.
174pub const UNSUPPORTED_FEATURE: u16 = 300;
175/// `LH0301` — a host import the codegen tables don't know (wrong `host::` path).
176pub const UNKNOWN_HOST_IMPORT: u16 = 301;
177/// `LH0302` — the compiled cartridge has no `frame`/`render` entry export.
178pub const NO_ENTRY: u16 = 302;
179/// `LH0303` — the compiled cartridge exceeds the on-chain publish size cap.
180pub const OVERSIZE: u16 = 303;
181
182// ── LH1xxx — cartridge RUNTIME codes ────────────────────────────────────────
183/// `LH1001` — a frame stopped posting; the watchdog terminated a hung cartridge.
184pub const FRAME_TIMEOUT: u16 = 1001;
185/// `LH1002` — the cartridge trapped during `frame()`/`render()` (unreachable / OOB).
186pub const WASM_TRAP: u16 = 1002;
187/// `LH1003` — `WebAssembly.instantiate` failed (a bad/incompatible module).
188pub const INSTANTIATE_FAILED: u16 = 1003;
189/// `LH1004` — the loaded module exports neither `frame` nor `render`.
190pub const NO_ENTRY_RUNTIME: u16 = 1004;
191
192// ── LH2xxx — on-chain TX-REVERT codes ───────────────────────────────────────
193// Each maps to a facet custom-error selector in `registry::decode_known_revert`.
194/// `LH2001` — ScheduleFacet `NotDue()`.
195pub const TX_NOT_DUE: u16 = 2001;
196/// `LH2002` — ScheduleFacet `StaleNextRun()`.
197pub const TX_STALE_NEXT_RUN: u16 = 2002;
198/// `LH2003` — ScheduleFacet `SpendExceedsBudget()`.
199pub const TX_SPEND_EXCEEDS_BUDGET: u16 = 2003;
200/// `LH2004` — ScheduleFacet `NotScheduler()`.
201pub const TX_NOT_SCHEDULER: u16 = 2004;
202/// `LH2005` — ScheduleFacet `NotJobOwner()`.
203pub const TX_NOT_JOB_OWNER: u16 = 2005;
204/// `LH2006` — ScheduleFacet `UnknownJob()`.
205pub const TX_UNKNOWN_JOB: u16 = 2006;
206/// `LH2007` — ScheduleFacet `JobNotActive()`.
207pub const TX_JOB_NOT_ACTIVE: u16 = 2007;
208/// `LH2008` — ScheduleFacet `JobNotPaused()`.
209pub const TX_JOB_NOT_PAUSED: u16 = 2008;
210/// `LH2009` — ScheduleFacet `UnregisteredTarget()`.
211pub const TX_UNREGISTERED_TARGET: u16 = 2009;
212/// `LH2010` — ScheduleFacet `ZeroInterval()`.
213pub const TX_ZERO_INTERVAL: u16 = 2010;
214/// `LH2011` — ScheduleFacet `ZeroRuns()`.
215pub const TX_ZERO_RUNS: u16 = 2011;
216/// `LH2012` — InviteFacet `CodeTaken()`.
217pub const TX_CODE_TAKEN: u16 = 2012;
218/// `LH2013` — InviteFacet `BadTtl()`.
219pub const TX_BAD_TTL: u16 = 2013;
220/// `LH2014` — InviteFacet `EscrowCapExceeded()`.
221pub const TX_ESCROW_CAP_EXCEEDED: u16 = 2014;
222/// `LH2015` — InviteFacet `UnknownInvite()`.
223pub const TX_UNKNOWN_INVITE: u16 = 2015;
224/// `LH2016` — InviteFacet `NotOpen()`.
225pub const TX_NOT_OPEN: u16 = 2016;
226/// `LH2017` — InviteFacet `Expired()`.
227pub const TX_EXPIRED: u16 = 2017;
228/// `LH2018` — InviteFacet `NotYetExpired()`.
229pub const TX_NOT_YET_EXPIRED: u16 = 2018;
230/// `LH2019` — shared `ZeroBudget()`.
231pub const TX_ZERO_BUDGET: u16 = 2019;
232/// `LH2020` — shared `ZeroAmount()`.
233pub const TX_ZERO_AMOUNT: u16 = 2020;
234/// `LH2021` — shared `NotConfigured()`.
235pub const TX_NOT_CONFIGURED: u16 = 2021;
236/// `LH2022` — a `require(reason)` / `Error(string)` revert (reason decoded inline).
237pub const TX_REASON_STRING: u16 = 2022;
238/// `LH2023` — a `Panic(uint256)` (internal assert) revert — a platform bug.
239pub const TX_PANIC: u16 = 2023;
240/// `LH2024` — CreditMeterFacet `InsufficientCredits()` on `withdrawCredits` —
241/// the chat-meter credits being pulled out are LOCKED (fiat-minted $LH must be
242/// spent on inference, not transferred/bridged to the wallet) or simply short.
243pub const TX_INSUFFICIENT_CREDITS: u16 = 2024;
244
245// ── LH3xxx — BACKEND / agent-runtime codes ──────────────────────────────────
246// The chat-facing failures. [`classify`] maps a raw error string to one of
247// these; the `.turn-error` line and the telemetry signature carry the label.
248/// `LH3001` — the model provider rate-limited the request or the project quota
249/// / spending cap is exhausted (HTTP 429 / `RESOURCE_EXHAUSTED`).
250pub const BACKEND_RATE_LIMIT: u16 = 3001;
251/// `LH3002` — the model rejected the API key / the request was unauthorized
252/// (HTTP 401/403, `PERMISSION_DENIED`, `UNAUTHENTICATED`).
253pub const BACKEND_AUTH: u16 = 3002;
254/// `LH3003` — out of platform credits: the proxy 402'd (no $LH / no session).
255pub const BACKEND_CREDITS: u16 = 3003;
256/// `LH3004` — the model request timed out / produced no response in time.
257pub const BACKEND_TIMEOUT: u16 = 3004;
258/// `LH3005` — the model returned an empty or truncated response.
259pub const BACKEND_EMPTY: u16 = 3005;
260/// `LH3006` — the model backend errored (HTTP 5xx / internal server error).
261pub const BACKEND_SERVER: u16 = 3006;
262/// `LH3007` — a network / transport failure reaching the backend or proxy.
263pub const BACKEND_NETWORK: u16 = 3007;
264/// `LH3008` — request auth went stale: the device clock is off by more than the
265/// proxy's freshness window (a `stale or future timestamp` rejection).
266pub const BACKEND_STALE_AUTH: u16 = 3008;
267/// `LH3009` — the request POST failed at the transport layer with NO response
268/// (reqwest's bare "error sending request" — on wasm a rejected `fetch()`,
269/// flaky mobile networks; telemetry #41). Unlike `LH3007`'s named causes, this
270/// wording is ambiguous about whether the request reached the server, so the
271/// stream-open retry treats it more conservatively (ONE retry).
272pub const BACKEND_SEND: u16 = 3009;
273
274// ── LH4xxx — SDK CORE codes (one per `Error` variant) ───────────────────────
275/// `LH4001` — `Error::Io` / `Error::Fs`: an OS-level I/O error or a
276/// filesystem-operation failure (native/OPFS impls). `Fs` maps here
277/// STRUCTURALLY — never through [`classify`](crate::error_codes::classify),
278/// so path/OS/JS prose can't false-positive into an `LH3xxx` backend class.
279pub const CORE_IO: u16 = 4001;
280/// `LH4002` — `Error::Json`: a (de)serialization error.
281pub const CORE_JSON: u16 = 4002;
282/// `LH4003` — `Error::Http`: an HTTP transport error not matched by
283/// [`classify`](crate::error_codes::classify).
284pub const CORE_HTTP: u16 = 4003;
285/// `LH4004` — `Error::Closed`: the connection closed unexpectedly.
286pub const CORE_CLOSED: u16 = 4004;
287/// `LH4005` — `Error::NotStarted`: the operation needs a started agent.
288pub const CORE_NOT_STARTED: u16 = 4005;
289/// `LH4006` — `Error::AlreadyStarted`: `start()` was called more than once.
290pub const CORE_ALREADY_STARTED: u16 = 4006;
291/// `LH4007` — `Error::Config`: invalid configuration.
292pub const CORE_CONFIG: u16 = 4007;
293/// `LH4008` — `Error::ToolNotFound`: no tool registered under that name.
294pub const CORE_TOOL_NOT_FOUND: u16 = 4008;
295/// `LH4009` — `Error::ToolFailed`: a tool errored during execution.
296/// (`Error::BadArgs` — a tool rejecting its arguments — shares this code
297/// structurally: no consumer branches on the distinction, so a new code
298/// doesn't pay.)
299pub const CORE_TOOL_FAILED: u16 = 4009;
300/// `LH4010` — `Error::PolicyDenied`: a policy blocked the operation.
301pub const CORE_POLICY_DENIED: u16 = 4010;
302/// `LH4011` — `Error::Timeout`: an operation exceeded its deadline.
303pub const CORE_TIMEOUT: u16 = 4011;
304/// `LH4012` — `Error::Other`: a catch-all not matched by
305/// [`classify`](crate::error_codes::classify).
306pub const CORE_OTHER: u16 = 4012;
307/// `LH4013` — `Error::Decode`: a payload failed to decode (provider JSON/SSE
308/// frame, restored history bytes) and [`classify`](crate::error_codes::classify)
309/// matched nothing. (`Error::Transport` has no own code — an unmatched
310/// transport failure falls back to [`BACKEND_NETWORK`].)
311pub const CORE_DECODE: u16 = 4013;
312
313/// The full registry — the SINGLE source of truth. `docs/error-codes.md` is a
314/// hand-maintained index checked against this table (the
315/// `index_doc_lists_every_code` test asserts the doc lists every code's label),
316/// and `self_docs` injects a compact slice into the system prompt.
317pub const REGISTRY: &[ErrorCode] = &[
318    // LH0xxx compile — lexer
319    ec(UNEXPECTED_BYTE, Family::Compile, "unexpected byte in source",
320       "remove the stray character; rustlite only accepts ASCII Rust-subset source"),
321    ec(UNTERMINATED_STRING, Family::Compile, "unterminated string literal",
322       "add the closing \" on the same line (strings can't span newlines)"),
323    ec(UNKNOWN_ESCAPE, Family::Compile, "unknown string/char escape",
324       "use a supported escape: \\n \\t \\\\ \\\" \\0"),
325    ec(BAD_CHAR_LITERAL, Family::Compile, "malformed char literal",
326       "a 'x' char is exactly one byte; use a \"string\" for text"),
327    ec(BAD_NUMBER, Family::Compile, "malformed numeric literal",
328       "check the digits/suffix; hex is 0xFF, floats need a fractional digit"),
329    // LH0xxx compile — parser
330    ec(UNEXPECTED_TOKEN, Family::Compile, "unexpected token",
331       "the grammar expected a different token here — read the [start..end] span"),
332    ec(EXPECTED_ITEM, Family::Compile, "expected a top-level item",
333       "only fn/struct/enum/const are allowed at the top level"),
334    ec(EXPECTED_TYPE, Family::Compile, "expected a type",
335       "supply a known type (i32/i64/f32/f64/bool or a declared struct/enum)"),
336    ec(EXPECTED_EXPRESSION, Family::Compile, "expected an expression",
337       "an expression is required here; check for a dangling operator"),
338    ec(EXPECTED_PATTERN, Family::Compile, "expected a pattern",
339       "a match arm / let needs a pattern (binding, literal, path, or range)"),
340    ec(MISSING_SEMICOLON, Family::Compile, "missing ';' after a statement",
341       "terminate the statement with ';' (or close the block with '}')"),
342    ec(INVALID_ASSIGN_TARGET, Family::Compile, "invalid assignment target",
343       "assign to a variable, struct field, or arr[i]; non-places (5 = 9) and indexed writes through struct fields (s.arr[i] = v) are unsupported"),
344    ec(NESTING_TOO_DEEP, Family::Compile, "nesting too deep",
345       "flatten deeply-nested expressions/blocks; the parser caps recursion depth"),
346    // LH0xxx compile — typecheck
347    ec(UNKNOWN_TYPE, Family::Compile, "unknown type name",
348       "declare the struct/enum, or use a primitive (i32/i64/f32/f64/bool)"),
349    ec(UNDEFINED_VARIABLE, Family::Compile, "undefined variable",
350       "declare it with let before use, or fix the spelling"),
351    ec(UNKNOWN_FUNCTION, Family::Compile, "unknown function",
352       "define the fn, or use a valid host fn (host::display::*, host::net::*, …)"),
353    ec(ARITY_MISMATCH, Family::Compile, "wrong number of arguments",
354       "match the function's parameter count exactly"),
355    ec(TYPE_MISMATCH, Family::Compile, "type mismatch",
356       "convert with an `as` cast or fix the operand types so they agree"),
357    ec(NOT_MUTABLE, Family::Compile, "assignment to a non-mut binding",
358       "declare it `let mut` to reassign"),
359    ec(BAD_FIELD_ACCESS, Family::Compile, "field access on a non-struct / missing field",
360       "access a real field of a struct value"),
361    ec(BAD_INDEX, Family::Compile, "invalid index expression",
362       "index an array with an i32; only arrays of i32 are indexable"),
363    ec(BAD_CAST, Family::Compile, "invalid `as` cast",
364       "`as` only converts between numbers (i32/i64/f32/f64)"),
365    ec(UNKNOWN_STRUCT, Family::Compile, "unknown struct in a literal",
366       "declare the struct before constructing it"),
367    // LH0xxx compile — codegen
368    ec(UNSUPPORTED_FEATURE, Family::Compile, "unsupported language feature",
369       "rustlite lacks traits/generics/references/heap types (Vec/String/Box)/globals"),
370    ec(UNKNOWN_HOST_IMPORT, Family::Compile, "unknown host import",
371       "use a registered host fn — check the host::display / host::net / host::audio names + arity"),
372    ec(NO_ENTRY, Family::Compile, "no frame/render entry export",
373       "add `fn frame(t: i32)` (animated) or `fn render()` (one-shot) — the loader calls one of these"),
374    ec(OVERSIZE, Family::Compile, "cartridge exceeds the publish size cap",
375       "shrink the cartridge below the on-chain publish cap before publishing"),
376    // LH1xxx runtime
377    ec(FRAME_TIMEOUT, Family::Runtime, "cartridge hung (watchdog terminated it)",
378       "a frame() ran too long / looped unbounded — bound your loops; reload to retry"),
379    ec(WASM_TRAP, Family::Runtime, "cartridge trapped during a frame",
380       "a wasm trap (unreachable / out-of-bounds) — check array indices + arithmetic"),
381    ec(INSTANTIATE_FAILED, Family::Runtime, "cartridge failed to instantiate",
382       "the wasm module is invalid/incompatible — recompile with compile_rustlite"),
383    ec(NO_ENTRY_RUNTIME, Family::Runtime, "cartridge exports neither frame nor render",
384       "export `fn frame(t: i32)` or `fn render()` so the engine has an entry to call"),
385    // LH2xxx tx reverts
386    ec(TX_NOT_DUE, Family::TxRevert, "NotDue — job not due yet",
387       "the scheduler only fires on the interval; check `localharness jobs`"),
388    ec(TX_STALE_NEXT_RUN, Family::TxRevert, "StaleNextRun — run already fired",
389       "the on-chain clock already advanced; nothing to do"),
390    ec(TX_SPEND_EXCEEDS_BUDGET, Family::TxRevert, "SpendExceedsBudget — over the job budget",
391       "top up the job or it will be marked exhausted"),
392    ec(TX_NOT_SCHEDULER, Family::TxRevert, "NotScheduler — scheduler-only call",
393       "only the scheduler worker can record a run; not a user action"),
394    ec(TX_NOT_JOB_OWNER, Family::TxRevert, "NotJobOwner — you don't own this job",
395       "use the right `--as` identity; check `localharness jobs`"),
396    ec(TX_UNKNOWN_JOB, Family::TxRevert, "UnknownJob — no job with that id",
397       "list yours with `localharness jobs` (the id is the #N)"),
398    ec(TX_JOB_NOT_ACTIVE, Family::TxRevert, "JobNotActive — already cancelled/exhausted",
399       "nothing to cancel; see `localharness jobs`"),
400    ec(TX_JOB_NOT_PAUSED, Family::TxRevert, "JobNotPaused — can't resume a running job",
401       "only a paused job can be resumed"),
402    ec(TX_UNREGISTERED_TARGET, Family::TxRevert, "UnregisteredTarget — target isn't an agent",
403       "confirm it exists first (`localharness whoami <target>`)"),
404    ec(TX_ZERO_INTERVAL, Family::TxRevert, "ZeroInterval — interval below the 60s minimum",
405       "use `--every 60s` or more"),
406    ec(TX_ZERO_RUNS, Family::TxRevert, "ZeroRuns — max-runs must be >= 1",
407       "drop `--runs 0`"),
408    ec(TX_CODE_TAKEN, Family::TxRevert, "CodeTaken — invite code already exists",
409       "generate a fresh code (`invite create` makes a new one each time)"),
410    ec(TX_BAD_TTL, Family::TxRevert, "BadTtl — TTL outside 1h..90d",
411       "use e.g. `--ttl 7d`"),
412    ec(TX_ESCROW_CAP_EXCEEDED, Family::TxRevert, "EscrowCapExceeded — past the per-funder cap",
413       "reclaim an expired invite or use a smaller amount"),
414    ec(TX_UNKNOWN_INVITE, Family::TxRevert, "UnknownInvite — no invite for that code",
415       "double-check you copied the full code (incl. the inv- prefix)"),
416    ec(TX_NOT_OPEN, Family::TxRevert, "NotOpen — invite already accepted/reclaimed",
417       "it's spent; ask for a fresh invite"),
418    ec(TX_EXPIRED, Family::TxRevert, "Expired — invite past its TTL",
419       "it can only be reclaimed by its funder now (`invite reclaim <code>`)"),
420    ec(TX_NOT_YET_EXPIRED, Family::TxRevert, "NotYetExpired — reclaim only after the TTL",
421       "until then it can still be accepted"),
422    ec(TX_ZERO_BUDGET, Family::TxRevert, "ZeroBudget — budget must be > 0",
423       "supply a positive budget"),
424    ec(TX_ZERO_AMOUNT, Family::TxRevert, "ZeroAmount — amount must be > 0",
425       "supply a positive amount"),
426    ec(TX_NOT_CONFIGURED, Family::TxRevert, "NotConfigured — credits token unset",
427       "a platform-side misconfiguration; report it via `localharness feedback`"),
428    ec(TX_REASON_STRING, Family::TxRevert, "Error(string) — reverted with a reason",
429       "the decoded reason is shown inline; an escrow/balance reason means you need more $LH"),
430    ec(TX_PANIC, Family::TxRevert, "Panic — internal assertion failed",
431       "a platform bug, not your input; please `localharness feedback` it"),
432    ec(TX_INSUFFICIENT_CREDITS, Family::TxRevert, "InsufficientCredits — chat-meter credits locked or short",
433       "fiat-minted $LH is locked for spending on inference, not withdraw/transfer; check_balances shows the withdrawable amount + unlock time"),
434    // LH3xxx backend / agent runtime
435    ec(BACKEND_RATE_LIMIT, Family::Backend, "model provider rate-limited / over quota",
436       "the platform's model provider is throttled or over its spend cap — wait a moment and retry; not a problem with your account"),
437    ec(BACKEND_AUTH, Family::Backend, "model API key rejected",
438       "check the Gemini/model API key (BYOK); on the platform path this is a server-side key issue to report"),
439    ec(BACKEND_CREDITS, Family::Backend, "out of platform credits ($LH)",
440       "redeem a code or top up — this signing address has no active session / no $LH"),
441    ec(BACKEND_TIMEOUT, Family::Backend, "the model request timed out",
442       "the backend didn't respond in time — retry; if it persists the provider may be degraded"),
443    ec(BACKEND_EMPTY, Family::Backend, "empty or truncated model response",
444       "the model returned nothing usable — retry; shortening the input can help"),
445    ec(BACKEND_SERVER, Family::Backend, "model backend error (5xx)",
446       "the provider returned a server error — transient; retry shortly"),
447    ec(BACKEND_NETWORK, Family::Backend, "network / transport failure",
448       "couldn't reach the backend or proxy — check connectivity and retry"),
449    ec(BACKEND_STALE_AUTH, Family::Backend, "request auth went stale (device clock skew)",
450       "your device clock is off by more than ~5 minutes — sync it and retry"),
451    ec(BACKEND_SEND, Family::Backend, "request POST failed in transit (no response)",
452       "the network dropped the request before a response arrived — usually a flaky connection; retry"),
453    // LH4xxx SDK core
454    ec(CORE_IO, Family::Core, "I/O error",
455       "an OS-level read/write failed — check paths and permissions"),
456    ec(CORE_JSON, Family::Core, "JSON (de)serialization error",
457       "malformed or unexpected JSON — verify the payload shape"),
458    ec(CORE_HTTP, Family::Core, "HTTP transport error",
459       "the request failed at the transport layer — retry; check the endpoint"),
460    ec(CORE_CLOSED, Family::Core, "connection closed unexpectedly",
461       "the stream/connection dropped — restart the operation"),
462    ec(CORE_NOT_STARTED, Family::Core, "agent not started",
463       "call start() before using the agent"),
464    ec(CORE_ALREADY_STARTED, Family::Core, "agent already started",
465       "start() was called more than once — reuse the running agent"),
466    ec(CORE_CONFIG, Family::Core, "invalid configuration",
467       "fix the configuration value named in the message"),
468    ec(CORE_TOOL_NOT_FOUND, Family::Core, "tool not found",
469       "no tool is registered under that name — register it or fix the name"),
470    ec(CORE_TOOL_FAILED, Family::Core, "tool execution failed",
471       "the tool returned an error — see the inline message for the cause"),
472    ec(CORE_POLICY_DENIED, Family::Core, "policy denied the operation",
473       "a policy blocked this action — adjust the request or the policy"),
474    ec(CORE_TIMEOUT, Family::Core, "operation timed out",
475       "the operation exceeded its deadline — raise the timeout or retry"),
476    ec(CORE_OTHER, Family::Core, "unspecified error",
477       "a catch-all error — see the inline message for details"),
478    ec(CORE_DECODE, Family::Core, "payload decode error",
479       "the bytes didn't match the expected shape — the message names the codec boundary"),
480];
481
482/// `const`-friendly constructor for a [`ErrorCode`] table entry.
483const fn ec(code: u16, family: Family, meaning: &'static str, hint: &'static str) -> ErrorCode {
484    ErrorCode { code, family, meaning, hint }
485}
486
487/// Look up an entry by its numeric code.
488pub fn lookup(code: u16) -> Option<&'static ErrorCode> {
489    REGISTRY.iter().find(|e| e.code == code)
490}
491
492/// The cartridge-lifecycle phase a runtime (`LH1xxx`) failure happened in:
493/// `"instantiate"` (the module never came up — bad wasm or no entry export)
494/// or `"run"` (it instantiated, then trapped or hung). Tool results carry
495/// this so an agent knows whether to recompile (instantiate) or fix its
496/// frame logic (run) without decoding the numeric code first.
497pub fn runtime_phase(code: u16) -> &'static str {
498    match code {
499        INSTANTIATE_FAILED | NO_ENTRY_RUNTIME => "instantiate",
500        _ => "run",
501    }
502}
503
504/// How an `LH3002` ("model API key rejected") should surface to a human —
505/// which depends entirely on WHOSE key the provider rejected.
506///
507/// On **BYOK** the user owns the key: naming it, prompting for a new one, and
508/// showing the provider's raw text is exactly right. On the **platform** path
509/// they have no key at all, so "check your Gemini key" sends them after
510/// something they do not own, and the raw body is a server-side blob carrying
511/// provider-internal ids. Telemetry #90 is what that costs: the only reading a
512/// user could make of a dead PLATFORM key was "am i out of credits?".
513///
514/// Same shape as the `LH3001` copy that already reassures a funded user their
515/// `$LH` is intact — `LH3002` simply never got that pass.
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub struct AuthFailureCopy {
518    /// The user-facing line for the transcript (prefixed with the code label).
519    pub line: &'static str,
520    /// Short marker for the status line (announced by the aria-live region).
521    pub status: &'static str,
522    /// Whether to prompt for a BYOK key. Never true on the platform path.
523    pub prompt_for_key: bool,
524    /// Whether the provider's raw error text is fit to show the user.
525    pub show_raw: bool,
526}
527
528/// [`AuthFailureCopy`] for an `LH3002`, given whether this session is BYOK
529/// (`true`) or running on platform `$LH` credits (`false`).
530pub const fn auth_failure_copy(byok: bool) -> AuthFailureCopy {
531    if byok {
532        AuthFailureCopy {
533            line: "model rejected the API key — check your Gemini key",
534            status: "API key rejected — check your Gemini key.",
535            prompt_for_key: true,
536            show_raw: true,
537        }
538    } else {
539        AuthFailureCopy {
540            line: "the platform's model key was rejected upstream — a server-side problem \
541                   on our end, not your $LH and not a key of yours. it is reported \
542                   automatically; retry in a moment.",
543            status: "platform model key rejected — server-side, not your $LH",
544            prompt_for_key: false,
545            show_raw: false,
546        }
547    }
548}
549
550/// "LH0204: type mismatch" — the label + meaning, for prefixing a message.
551pub fn describe(code: u16) -> String {
552    match lookup(code) {
553        Some(e) => format!("{}: {}", e.label(), e.meaning),
554        None => fmt_label(code),
555    }
556}
557
558/// A compact, agent-facing list of every code (label + meaning), grouped by
559/// family. Injected into the system prompt via `self_docs` so the agent learns
560/// the codes once. Newline-separated, no trailing newline.
561pub fn compact_index() -> String {
562    let mut out = String::new();
563    for fam in FAMILIES {
564        out.push_str(fam.label());
565        out.push_str(":\n");
566        for e in REGISTRY.iter().filter(|e| e.family == fam) {
567            out.push_str(&format!("  {} {}\n", e.label(), e.meaning));
568        }
569    }
570    out.trim_end().to_string()
571}
572
573/// Map a REAL HTTP status code to a stable `LH3xxx` backend code — the
574/// structured twin of [`classify`], which has to substring-match "429"/"503"
575/// out of prose. Used by [`classify_http`] for [`crate::Error::HttpStatus`];
576/// `None` for statuses that carry no backend meaning on their own (e.g. 400).
577pub fn classify_status(status: u16) -> Option<u16> {
578    match status {
579        429 => Some(BACKEND_RATE_LIMIT),
580        401 | 403 => Some(BACKEND_AUTH),
581        402 => Some(BACKEND_CREDITS),
582        408 => Some(BACKEND_TIMEOUT),
583        500..=599 => Some(BACKEND_SERVER),
584        _ => None,
585    }
586}
587
588/// Structured classification for an HTTP failure with a KNOWN status code
589/// (`Error::HttpStatus`): the one body-borne semantic override that must win
590/// regardless of status runs first (a stale device clock arrives as a 401 but
591/// is NOT an auth-key problem), then the real status decides
592/// ([`classify_status`]), then full string classification of the body is the
593/// fallback (a provider 400 whose body says "API key not valid" is still an
594/// auth failure). Legacy string-only errors keep using [`classify`] directly.
595pub fn classify_http(status: u16, body: &str) -> Option<u16> {
596    let l = body.to_lowercase();
597    if l.contains("stale or future timestamp") || l.contains("clock") {
598        return Some(BACKEND_STALE_AUTH);
599    }
600    classify_status(status).or_else(|| classify(body))
601}
602
603/// Map a raw error string to a stable `LH3xxx` backend/runtime code — the SINGLE
604/// source of truth for turning an opaque provider/proxy/transport message into a
605/// code. Used by both the chat `.turn-error` surface and [`crate::Error::code`]
606/// (for the string-wrapping `Http`/`Other`/`ToolFailed` variants). Returns
607/// `None` when nothing matches, so the caller can fall back to a core code.
608/// When the real numeric status is known, prefer the structured
609/// [`classify_http`] over substring-matching the digits out of prose.
610///
611/// Order matters — most specific first. Pure + case-insensitive; no deps, so it
612/// is unit-tested headlessly.
613pub fn classify(s: &str) -> Option<u16> {
614    let l = s.to_lowercase();
615    // Stale device clock first — the proxy phrases it distinctively and it must
616    // NOT be mistaken for an auth-key problem.
617    if l.contains("stale or future timestamp") || l.contains("clock") {
618        return Some(BACKEND_STALE_AUTH);
619    }
620    // Rate-limit / quota before credits: a provider 429 / spend-cap is NOT the
621    // user being out of $LH (the historic conflation that showed a "redeem" card
622    // for a provider quota error).
623    if l.contains("429")
624        || l.contains("rate limit")
625        || l.contains("rate-limit")
626        || l.contains("resource_exhausted")
627        || l.contains("spending cap")
628        || l.contains("spend cap")
629        || l.contains("too many requests")
630        || l.contains("quota")
631        || l.contains("overloaded")
632    {
633        return Some(BACKEND_RATE_LIMIT);
634    }
635    if l.contains("401")
636        || l.contains("403")
637        || l.contains("api key")
638        || l.contains("api_key")
639        || l.contains("permission_denied")
640        || l.contains("unauthenticated")
641        || l.contains("unauthorized")
642    {
643        return Some(BACKEND_AUTH);
644    }
645    if l.contains("402")
646        || l.contains("payment required")
647        || l.contains("no $lh")
648        || l.contains("no credit")
649        || (l.contains("insufficient")
650            && (l.contains("credit")
651                || l.contains("balance")
652                || l.contains("funds")
653                || l.contains("$lh")))
654        || l.contains("no active session")
655    {
656        return Some(BACKEND_CREDITS);
657    }
658    if l.contains("timed out") || l.contains("timeout") || l.contains("deadline") {
659        return Some(BACKEND_TIMEOUT);
660    }
661    if l.contains("empty response")
662        || l.contains("response truncated")
663        || l.contains("output truncated")
664        || l.contains("truncated response")
665        || l.contains("no response")
666    {
667        return Some(BACKEND_EMPTY);
668    }
669    if l.contains("500")
670        || l.contains("502")
671        || l.contains("503")
672        || l.contains("504")
673        || l.contains("internal server")
674    {
675        return Some(BACKEND_SERVER);
676    }
677    if l.contains("network")
678        || l.contains("connection")
679        || l.contains("failed to fetch")
680        || l.contains("dns")
681    {
682        return Some(BACKEND_NETWORK);
683    }
684    // reqwest's opaque transport wording for a POST that produced NO response
685    // (telemetry #41: "gemini POST: error sending request" on mobile — on wasm
686    // a rejected fetch() carries no detail). Checked AFTER the named-cause
687    // classes above: a message that also says connection/dns/tls names a
688    // definitive pre-send failure and stays LH3007; the bare form can't prove
689    // the request never reached the server, hence its own code.
690    if l.contains("error sending request") {
691        return Some(BACKEND_SEND);
692    }
693    None
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn codes_are_unique_and_in_family_range() {
702        let mut seen = std::collections::HashSet::new();
703        for e in REGISTRY {
704            assert!(seen.insert(e.code), "duplicate code LH{:04}", e.code);
705            assert_eq!(
706                Family::of(e.code),
707                Some(e.family),
708                "LH{:04} family {:?} doesn't match its numeric range",
709                e.code,
710                e.family
711            );
712            // Every entry must have a non-empty meaning + hint.
713            assert!(!e.meaning.is_empty() && !e.hint.is_empty(), "LH{:04} blank text", e.code);
714        }
715    }
716
717    #[test]
718    fn label_is_zero_padded() {
719        assert_eq!(fmt_label(1), "LH0001");
720        assert_eq!(fmt_label(204), "LH0204");
721        assert_eq!(fmt_label(2001), "LH2001");
722        assert_eq!(lookup(TYPE_MISMATCH).unwrap().label(), "LH0204");
723    }
724
725    #[test]
726    fn runtime_phase_maps_every_lh1xxx_code() {
727        assert_eq!(runtime_phase(INSTANTIATE_FAILED), "instantiate");
728        assert_eq!(runtime_phase(NO_ENTRY_RUNTIME), "instantiate");
729        assert_eq!(runtime_phase(WASM_TRAP), "run");
730        assert_eq!(runtime_phase(FRAME_TIMEOUT), "run");
731        // every registered runtime code yields one of the two phases
732        for e in REGISTRY.iter().filter(|e| e.family == Family::Runtime) {
733            assert!(matches!(runtime_phase(e.code), "instantiate" | "run"));
734        }
735    }
736
737    #[test]
738    fn describe_falls_back_for_unknown() {
739        assert_eq!(describe(TYPE_MISMATCH), "LH0204: type mismatch");
740        assert_eq!(describe(9999), "LH9999");
741    }
742
743    #[test]
744    fn index_doc_lists_every_code() {
745        // The human/agent index `docs/error-codes.md` must mention every
746        // registry code's label, so the doc can't silently drift from the
747        // source-of-truth table. (Run from the crate root via CARGO_MANIFEST_DIR.)
748        let doc = std::fs::read_to_string(concat!(
749            env!("CARGO_MANIFEST_DIR"),
750            "/docs/error-codes.md"
751        ))
752        .expect("docs/error-codes.md must exist");
753        for e in REGISTRY {
754            let label = e.label();
755            assert!(
756                doc.contains(&label),
757                "docs/error-codes.md is missing {label} ({})",
758                e.meaning
759            );
760        }
761    }
762
763    #[test]
764    fn compact_index_covers_all_families() {
765        let idx = compact_index();
766        for fam in FAMILIES {
767            assert!(idx.contains(&format!("{}:", fam.label())), "missing family {}", fam.label());
768        }
769        assert!(idx.contains("LH0204"));
770        assert!(idx.contains("LH1001"));
771        assert!(idx.contains("LH2003"));
772        assert!(idx.contains("LH3001"));
773        assert!(idx.contains("LH4001"));
774    }
775
776    #[test]
777    fn classify_maps_common_backend_errors() {
778        assert_eq!(classify("gemini HTTP 429 Too Many Requests"), Some(BACKEND_RATE_LIMIT));
779        assert_eq!(classify("status: RESOURCE_EXHAUSTED, spending cap"), Some(BACKEND_RATE_LIMIT));
780        assert_eq!(classify("exceeded your quota"), Some(BACKEND_RATE_LIMIT));
781        assert_eq!(classify("the model is overloaded"), Some(BACKEND_RATE_LIMIT));
782        assert_eq!(classify("HTTP 401 Unauthorized: bad API key"), Some(BACKEND_AUTH));
783        assert_eq!(classify("PERMISSION_DENIED"), Some(BACKEND_AUTH));
784        assert_eq!(classify("402 Payment Required: no $LH"), Some(BACKEND_CREDITS));
785        assert_eq!(classify("the request timed out"), Some(BACKEND_TIMEOUT));
786        assert_eq!(classify("empty response from model"), Some(BACKEND_EMPTY));
787        assert_eq!(classify("model output truncated at max_tokens"), Some(BACKEND_EMPTY));
788        assert_eq!(classify("the connection was truncated mid-stream"), Some(BACKEND_NETWORK));
789        assert_eq!(classify("HTTP 503 internal server error"), Some(BACKEND_SERVER));
790        assert_eq!(classify("failed to fetch: network down"), Some(BACKEND_NETWORK));
791        assert_eq!(classify("stale or future timestamp"), Some(BACKEND_STALE_AUTH));
792        assert_eq!(classify("a perfectly ordinary message"), None);
793    }
794
795    /// Telemetry #41: reqwest's bare transport failure ("error sending
796    /// request" — a rejected fetch() on wasm/mobile) must classify as the
797    /// retryable send class, NOT fall through to CORE_OTHER (which made the
798    /// stream-open retry fail fast and surfaced a hard turn error).
799    #[test]
800    fn classify_maps_bare_send_failure_to_backend_send() {
801        assert_eq!(classify("gemini POST: error sending request"), Some(BACKEND_SEND));
802        assert_eq!(classify("anthropic POST: error sending request"), Some(BACKEND_SEND));
803        assert_eq!(classify("openai POST: error sending request"), Some(BACKEND_SEND));
804        // A named cause wins over the bare wording: still LH3007 (network).
805        assert_eq!(
806            classify("error sending request: tcp connect error: Connection refused"),
807            Some(BACKEND_NETWORK)
808        );
809        assert_eq!(classify("error sending request: dns error"), Some(BACKEND_NETWORK));
810        // A send timeout stays a timeout.
811        assert_eq!(classify("error sending request: operation timed out"), Some(BACKEND_TIMEOUT));
812    }
813
814    #[test]
815    fn classify_prefers_rate_limit_over_credits() {
816        // A provider 429 / spend-cap must NOT be classified as out-of-credits
817        // (the historic conflation that showed a "redeem" card for a quota error).
818        assert_eq!(
819            classify("429 RESOURCE_EXHAUSTED: project exceeded its monthly spending cap"),
820            Some(BACKEND_RATE_LIMIT)
821        );
822    }
823
824    #[test]
825    fn classify_status_reads_the_real_number() {
826        assert_eq!(classify_status(429), Some(BACKEND_RATE_LIMIT));
827        assert_eq!(classify_status(401), Some(BACKEND_AUTH));
828        assert_eq!(classify_status(403), Some(BACKEND_AUTH));
829        assert_eq!(classify_status(402), Some(BACKEND_CREDITS));
830        assert_eq!(classify_status(408), Some(BACKEND_TIMEOUT));
831        for s in [500, 502, 503, 504, 529] {
832            assert_eq!(classify_status(s), Some(BACKEND_SERVER), "status {s}");
833        }
834        // Statuses with no backend meaning of their own stay unclassified.
835        assert_eq!(classify_status(400), None);
836        assert_eq!(classify_status(404), None);
837        assert_eq!(classify_status(200), None);
838    }
839
840    #[test]
841    fn classify_http_status_first_with_overrides_and_fallback() {
842        // Structured: the status decides even with an opaque body.
843        assert_eq!(classify_http(429, "<opaque provider body>"), Some(BACKEND_RATE_LIMIT));
844        assert_eq!(classify_http(503, "x"), Some(BACKEND_SERVER));
845        // Stale device clock overrides the 401 it arrives under.
846        assert_eq!(classify_http(401, "stale or future timestamp"), Some(BACKEND_STALE_AUTH));
847        // Unmapped status falls back to the body string.
848        assert_eq!(classify_http(400, "API key not valid"), Some(BACKEND_AUTH));
849        assert_eq!(classify_http(400, "exceeded your quota"), Some(BACKEND_RATE_LIMIT));
850        assert_eq!(classify_http(418, "a perfectly ordinary message"), None);
851    }
852
853    #[test]
854    fn lh3002_copy_never_blames_a_platform_user_for_a_key_they_dont_own() {
855        // Telemetry #90: a dead PLATFORM key told the user to "check your
856        // Gemini key" and popped the BYOK modal, so their only reading was
857        // "am i out of credits?". The platform variant must do neither.
858        let platform = auth_failure_copy(false);
859        assert!(!platform.prompt_for_key, "platform users have no key to fix");
860        assert!(!platform.show_raw, "the raw body is a server-side blob");
861        assert!(!platform.line.contains("your Gemini key"));
862        assert!(!platform.status.contains("your Gemini key"));
863        // It must actively clear the two wrong conclusions: their money, their key.
864        assert!(platform.line.contains("$LH"));
865        assert!(platform.line.contains("server-side"));
866
867        // BYOK is unchanged: the user owns the key, so name it and prompt.
868        let byok = auth_failure_copy(true);
869        assert!(byok.prompt_for_key);
870        assert!(byok.show_raw);
871        assert!(byok.line.contains("Gemini key"));
872    }
873
874    #[test]
875    fn classify_narrows_bare_insufficient() {
876        // Bare "insufficient" (e.g. a provider "insufficient storage") must NOT
877        // be treated as out-of-credits — that showed a spurious redeem card.
878        assert_ne!(classify("insufficient storage"), Some(BACKEND_CREDITS));
879        // Money-shaped "insufficient" still maps to credits.
880        assert_eq!(classify("insufficient credit balance"), Some(BACKEND_CREDITS));
881        assert_eq!(classify("402 payment required"), Some(BACKEND_CREDITS));
882        // "insufficient quota" is caught earlier as rate-limit, not credits.
883        assert_eq!(classify("insufficient quota"), Some(BACKEND_RATE_LIMIT));
884    }
885}