src/errcode.rs
//! # `errcode` — nornir's FROZEN, append-only application error codes
//!
//! Every non-UI error nornir emits carries **two channels**:
//!
//! 1. a **visible human string** (what went wrong, in words, with the operand), and
//! 2. a **stable machine code** `nornir-<subsystem>-<n>`.
//!
//! Both ride in the SAME message, so a code survives every transport nornir has —
//! a `tonic::Status` message, a `state_json` dump, a log line, a CLI stderr line —
//! without needing a side channel. On gRPC the code ALSO rides as its own
//! `x-nornir-code` metadata entry (see [`status`]), so a thin client can branch on
//! the code without parsing prose.
//!
//! ## The law
//!
//! - **FROZEN.** A code, once published, never changes meaning. Fix the wording of
//! a [`ErrCode::summary`] freely; never re-point a code at a different failure.
//! - **APPEND-ONLY.** Retire a code by leaving it in [`ALL`] and ceasing to emit it.
//! NEVER reuse the number — a retired code in an old log must stay unambiguous.
//! - **UNIQUE** within its subsystem *and* globally across the app. Enforced by
//! [`tests::codes_are_unique`], which FAILS on a repeat.
//! - Consumers (facett panes, korp) may **prepend a mount prefix**; [`parse`] finds
//! the bracketed code anywhere in the string, so a prefixed message still parses.
//!
//! ## Suite context
//!
//! Sibling namespaces: `facet-<component>-<n>` (facett UI), `korp-<subsystem>-<n>`,
//! `dwarves-<subsystem>-<n>`, `holger-<subsystem>-<n>`. This module owns
//! `nornir-<subsystem>-<n>` only. The registry doc is
//! `.nornir/nornir-error-codes.md`.
//!
//! ## Using it
//!
//! ```ignore
//! use crate::errcode::{self, WAREHOUSE_OPEN};
//! // anyhow / CLI / log:
//! anyhow::bail!("{}", WAREHOUSE_OPEN.tag(format_args!("{}: {e:#}", root.display())));
//! // gRPC handler:
//! return Err(errcode::status(&errcode::RPC_WORKSPACE_UNKNOWN, tonic::Code::NotFound, name));
//! ```
//!
//! A test asserts the **code**, never a substring of the prose:
//! `assert_eq!(errcode::parse(&msg), Some(&errcode::WAREHOUSE_OPEN));`
use std::fmt::Display;
/// One frozen entry in nornir's error-code registry.
///
/// `code` is the wire identity (`nornir-<subsystem>-<n>`); `summary` is the human
/// half of the dual channel — a short, operand-free description of the failure
/// CLASS (the operand goes in the per-site detail passed to [`ErrCode::tag`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrCode {
/// The frozen wire code, e.g. `"nornir-warehouse-1"`.
pub code: &'static str,
/// The subsystem half of the code, e.g. `"warehouse"`.
pub subsystem: &'static str,
/// The ordinal within the subsystem. Unique per subsystem, never reused.
pub n: u16,
/// The human channel: what this failure MEANS, without the operand.
pub summary: &'static str,
}
impl ErrCode {
/// Render both channels into one message: `[code] summary: detail`.
///
/// This is the ONE formatter — every emit site goes through it, so the shape a
/// log/Status/state-dump carries is identical everywhere and [`parse`] can
/// always recover the code.
pub fn tag(&self, detail: impl Display) -> String {
format!("[{}] {}: {}", self.code, self.summary, detail)
}
/// The dual-channel message with no per-site operand (`[code] summary`).
pub fn bare(&self) -> String {
format!("[{}] {}", self.code, self.summary)
}
}
impl Display for ErrCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code)
}
}
/// Declare a frozen code AND enrol it in [`ALL`] in one place, so a code can never
/// be added to the app without also being added to the uniqueness gate.
macro_rules! codes {
($( $(#[$m:meta])* $ident:ident = ($sub:literal, $n:literal, $summary:literal); )*) => {
$(
$(#[$m])*
pub const $ident: ErrCode = ErrCode {
code: concat!("nornir-", $sub, "-", stringify!($n)),
subsystem: $sub,
n: $n,
summary: $summary,
};
)*
/// EVERY code nornir can emit. Append-only: add at the end of a subsystem's
/// block, never renumber, never delete.
pub const ALL: &[ErrCode] = &[ $($ident),* ];
};
}
codes! {
// ── rpc — the nornir.v1 gRPC handler surface (src/server_grpc.rs) ─────────
/// The bearer/mTLS identity on the call is missing or unreadable.
RPC_UNAUTHENTICATED = ("rpc", 1, "request carried no usable credential");
/// Authenticated, but the policy denies this identity the resource.
RPC_PERMISSION_DENIED = ("rpc", 2, "identity is not permitted this resource");
/// The named workspace is not registered on this server.
RPC_WORKSPACE_UNKNOWN = ("rpc", 3, "no such workspace on this server");
/// The named repo is not a member of the workspace.
RPC_REPO_UNKNOWN = ("rpc", 4, "no such repo in this workspace");
/// A request field was absent, empty or malformed.
RPC_INVALID_ARGUMENT = ("rpc", 5, "invalid request argument");
/// The descriptor exists but its shape is wrong (e.g. `repo` is not a table).
RPC_DESCRIPTOR_SHAPE = ("rpc", 6, "workspace descriptor has the wrong shape");
/// The handler failed for an internal reason (the detail carries the cause).
RPC_INTERNAL = ("rpc", 7, "handler failed internally");
/// The verb exists in the proto but this build does not implement it.
RPC_UNIMPLEMENTED = ("rpc", 8, "verb not implemented in this build");
/// A precondition the caller must satisfy first is unmet.
RPC_FAILED_PRECONDITION = ("rpc", 9, "precondition for this call is unmet");
/// A dependency the handler needs (warehouse, checkout, index) is unavailable.
RPC_UNAVAILABLE = ("rpc", 10, "a backing resource is unavailable");
// ── warehouse — Iceberg/Arrow/redb store (src/warehouse/) ────────────────
/// The Iceberg warehouse root could not be opened.
WAREHOUSE_OPEN = ("warehouse", 1, "cannot open the Iceberg warehouse");
/// An append (one Iceberg snapshot) failed; rows were NOT persisted.
WAREHOUSE_APPEND = ("warehouse", 2, "warehouse append failed — rows not persisted");
/// A read/scan over a warehouse table failed.
WAREHOUSE_READ = ("warehouse", 3, "warehouse read failed");
/// The table does not exist in the catalog (never populated).
WAREHOUSE_TABLE_MISSING = ("warehouse", 4, "warehouse table does not exist");
/// An Arrow batch did not match its declared schema (column count/type drift).
WAREHOUSE_SCHEMA_DRIFT = ("warehouse", 5, "Arrow batch does not match the table schema");
/// The redb sidecar (cache/index) could not be opened or written.
WAREHOUSE_REDB = ("warehouse", 6, "redb sidecar store failed");
/// SQL submitted to the warehouse query engine was rejected.
WAREHOUSE_QUERY = ("warehouse", 7, "warehouse query was rejected");
/// The warehouse writer lock could not be taken (another writer holds it).
WAREHOUSE_LOCKED = ("warehouse", 8, "warehouse writer lock is held");
// ── knowledge — syn symbol/call-edge/git-heat scans (src/knowledge/) ─────
/// The repo could not be scanned (path wrong, unreadable, not a checkout).
KNOWLEDGE_SCAN = ("knowledge", 1, "knowledge scan failed");
/// The scan produced ZERO files/symbols — almost always a wrong repo path.
KNOWLEDGE_EMPTY_SCAN = ("knowledge", 2, "knowledge scan resolved to nothing — check the repo path");
/// The file watcher backing incremental scans failed.
KNOWLEDGE_WATCH = ("knowledge", 3, "knowledge file-watch failed");
// ── dwarf — binary/DWARF introspection (src/introspect/) ─────────────────
/// The binary could not be read as an object file.
DWARF_READ_BINARY = ("dwarf", 1, "cannot read the binary");
/// The binary carries no debug info (stripped) — nothing to extract.
DWARF_NO_DEBUG_INFO = ("dwarf", 2, "binary has no debug info");
/// Persisting the extracted DWARF facts into the warehouse failed.
DWARF_PERSIST = ("dwarf", 3, "cannot persist DWARF facts");
// ── vector — embeddings + the search index (src/vector/, src/index/) ─────
/// The embedding model could not be loaded.
VECTOR_MODEL_LOAD = ("vector", 1, "cannot load the embedding model");
/// Embedding a batch failed.
VECTOR_EMBED = ("vector", 2, "embedding failed");
/// The vector store could not be opened or written.
VECTOR_STORE = ("vector", 3, "vector store failed");
/// A similarity query mixed incomparable model profiles (dim/model mismatch).
VECTOR_PROFILE_MISMATCH = ("vector", 4, "vectors come from different model profiles — not comparable");
/// The text index (tantivy) could not be opened, written or queried.
INDEX_STORE = ("index", 1, "text index failed");
// ── docs — the doc generator / renderer (src/docs/) ──────────────────────
/// A `.nornir` doc source is missing.
DOCS_SOURCE_MISSING = ("docs", 1, "doc source is missing");
/// Rendering (typst/markdown → PDF/HTML/SVG) failed.
DOCS_RENDER = ("docs", 2, "doc render failed");
/// Export/publish of a rendered artefact failed.
DOCS_EXPORT = ("docs", 3, "doc export failed");
/// A render needs sibling path-deps that are not resolvable.
DOCS_UNRESOLVED_PATH_DEP = ("docs", 4, "doc render needs unresolved sibling path-deps");
/// The docs freshness check found the rendered book behind its sources.
DOCS_STALE = ("docs", 5, "rendered docs are behind their sources");
// ── guard — the forbidden-path guard (crates/nornir-guard) ───────────────
/// A path the guard declares forbidden is writable.
GUARD_WRITABLE = ("guard", 1, "a guarded path is writable");
/// The guard manifest is missing or unreadable.
GUARD_MANIFEST = ("guard", 2, "guard manifest is unreadable");
/// A guarded path drifted from the recorded manifest.
GUARD_DRIFT = ("guard", 3, "a guarded path drifted from the manifest");
// ── release — the release pipeline + gates (src/release/) ────────────────
/// The release refuses to run because a repo tree is dirty.
RELEASE_DIRTY_TREE = ("release", 1, "release refused — uncommitted changes");
/// A release gate refused (test/bench/guard/coverage/docs).
RELEASE_GATE_REFUSED = ("release", 2, "a release gate refused");
/// Writing the release_lineage rows failed.
RELEASE_LINEAGE = ("release", 3, "cannot persist release lineage");
/// A regression was detected against the prior released artefact.
RELEASE_REGRESSION = ("release", 4, "regression against the prior release");
/// Publishing to the registry failed.
RELEASE_PUBLISH = ("release", 5, "publish failed");
// ── funnel — the idea→plan→node DAG store (src/funnel/) ──────────────────
/// The funnel store could not be opened or written.
FUNNEL_STORE = ("funnel", 1, "funnel store failed");
/// The referenced funnel node/plan does not exist.
FUNNEL_NODE_UNKNOWN = ("funnel", 2, "no such funnel node");
/// The requested link would create a cycle in the DAG.
FUNNEL_CYCLE = ("funnel", 3, "link would create a cycle");
// ── security — SBOM / vulnerability / licence scanning (src/security/) ───
/// The SBOM for a repo could not be produced.
SECURITY_SBOM = ("security", 1, "cannot produce the SBOM");
/// The vulnerability feed could not be consulted.
SECURITY_VULN_FEED = ("security", 2, "vulnerability feed unavailable");
/// The scan found a REACHABLE vulnerability (a refusal, not a transport error).
SECURITY_REACHABLE_VULN = ("security", 3, "a reachable vulnerability was found");
// ── mcp — the MCP verb surface (src/bin/nornir-mcp.rs) ───────────────────
/// An MCP verb argument was absent or malformed.
MCP_INVALID_ARGS = ("mcp", 1, "invalid MCP verb arguments");
/// The MCP verb failed while doing its work.
MCP_VERB_FAILED = ("mcp", 2, "MCP verb failed");
/// A path argument handed to an MCP verb does not exist.
MCP_PATH_MISSING = ("mcp", 3, "path argument does not exist");
// ── workspace — descriptor, members, populate/monitor (src/workspace/) ───
/// The workspace descriptor could not be read or parsed.
WORKSPACE_DESCRIPTOR = ("workspace", 1, "cannot read the workspace descriptor");
/// A member's source path does not resolve.
WORKSPACE_MEMBER_PATH = ("workspace", 2, "member source path does not resolve");
/// Populate produced no materialised checkouts.
WORKSPACE_NO_CHECKOUTS = ("workspace", 3, "populate materialised no checkouts");
/// A git clone/fetch for a member failed.
WORKSPACE_FETCH = ("workspace", 4, "member clone/fetch failed");
/// The member has no resolvable git HEAD — its checkout never materialised, so
/// every scan keyed on the `"unknown"` SHA sentinel is meaningless.
WORKSPACE_MEMBER_UNRESOLVED = ("workspace", 5, "member checkout has no resolvable git HEAD");
}
/// Recover the [`ErrCode`] from any message [`ErrCode::tag`] produced — a log line,
/// a `tonic::Status` message, a `state_json` field, or one of those with a mount
/// prefix prepended by a consumer.
///
/// Matching is on the **bracketed** form `[nornir-sub-n]`, so `nornir-rpc-1` can
/// never be mistaken for `nornir-rpc-11`.
///
/// This is what tests assert on — never a substring of the human prose, which is
/// free to be reworded.
pub fn parse(msg: &str) -> Option<&'static ErrCode> {
ALL.iter().find(|c| msg.contains(&format!("[{}]", c.code)))
}
/// Look a code up by its exact wire string (`"nornir-warehouse-1"`).
pub fn by_code(code: &str) -> Option<&'static ErrCode> {
ALL.iter().find(|c| c.code == code)
}
/// The gRPC metadata key the code rides in, in ADDITION to being inside the
/// message — so a thin client can branch on the code without parsing prose.
pub const GRPC_CODE_KEY: &str = "x-nornir-code";
/// Build a `tonic::Status` carrying BOTH channels: the dual-channel message and
/// the code as `x-nornir-code` metadata.
///
/// This is the ONE constructor the gRPC handlers use, so no handler can emit a
/// codeless Status.
#[cfg(feature = "server")]
pub fn status(ec: &'static ErrCode, code: tonic::Code, detail: impl Display) -> tonic::Status {
let mut st = tonic::Status::new(code, ec.tag(detail));
if let Ok(v) = ec.code.parse::<tonic::metadata::MetadataValue<_>>() {
st.metadata_mut().insert(GRPC_CODE_KEY, v);
}
st
}
/// Read the code back off a `tonic::Status` — the metadata entry first (the
/// prose-free channel), falling back to [`parse`] over the message so a Status
/// that crossed a proxy which dropped metadata still yields its code.
#[cfg(feature = "server")]
pub fn from_status(st: &tonic::Status) -> Option<&'static ErrCode> {
st.metadata()
.get(GRPC_CODE_KEY)
.and_then(|v| v.to_str().ok())
.and_then(by_code)
.or_else(|| parse(st.message()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// THE gate. Every code must be unique GLOBALLY, and `(subsystem, n)` unique
/// within its subsystem. A copy-pasted duplicate — the one mistake that breaks
/// a frozen registry — FAILS here, naming the offender.
#[test]
fn codes_are_unique() {
let mut seen_code: HashSet<&str> = HashSet::new();
let mut seen_pair: HashSet<(&str, u16)> = HashSet::new();
for c in ALL {
assert!(
seen_code.insert(c.code),
"DUPLICATE error code `{}` — codes are FROZEN and append-only; \
pick the next free n in subsystem `{}`",
c.code,
c.subsystem,
);
assert!(
seen_pair.insert((c.subsystem, c.n)),
"DUPLICATE (subsystem, n) = ({}, {}) for code `{}`",
c.subsystem,
c.n,
c.code,
);
}
assert!(!ALL.is_empty(), "the registry must not be empty");
}
/// The code string must be exactly `nornir-<subsystem>-<n>` — the shape every
/// suite consumer (facett/korp) parses. Guards a hand-edited const.
#[test]
fn code_strings_match_their_parts() {
for c in ALL {
assert_eq!(
c.code,
format!("nornir-{}-{}", c.subsystem, c.n),
"code string drifted from its (subsystem, n)"
);
assert!(!c.summary.is_empty(), "{} has no human summary", c.code);
assert!(
c.summary.len() < 100,
"{} summary is a paragraph, not a summary",
c.code
);
assert!(c.n > 0, "{} — ordinals start at 1", c.code);
}
}
/// The DUAL CHANNEL contract: the rendered message carries the human string
/// AND the code, and the code survives a round-trip through [`parse`].
#[test]
fn tag_carries_both_channels_and_round_trips() {
let msg = WAREHOUSE_OPEN.tag("/srv/wh: permission denied (os error 13)");
// human channel
assert!(msg.contains("cannot open the Iceberg warehouse"), "{msg}");
// operand survives
assert!(msg.contains("os error 13"), "{msg}");
// machine channel — asserted as the CODE, never a prose substring
assert_eq!(parse(&msg), Some(&WAREHOUSE_OPEN));
assert_eq!(parse(&WAREHOUSE_OPEN.bare()), Some(&WAREHOUSE_OPEN));
}
/// A consumer may PREPEND a mount prefix; the code must still parse out.
#[test]
fn parse_survives_a_consumer_mount_prefix() {
let msg = format!("korp/nornir-pane: {}", RPC_WORKSPACE_UNKNOWN.tag("nordisk"));
assert_eq!(parse(&msg), Some(&RPC_WORKSPACE_UNKNOWN));
}
/// `nornir-rpc-1` must never be recovered from a `nornir-rpc-10` message —
/// the bracketed form is what makes the registry safely extensible past 9.
#[test]
fn parse_does_not_confuse_prefix_codes() {
let msg = RPC_UNAVAILABLE.tag("warehouse down"); // nornir-rpc-10
assert_eq!(parse(&msg), Some(&RPC_UNAVAILABLE));
assert_ne!(parse(&msg), Some(&RPC_UNAUTHENTICATED)); // nornir-rpc-1
}
/// An uncoded message yields None — so a test asserting "this path is coded"
/// genuinely fails when the code is dropped.
#[test]
fn uncoded_messages_do_not_parse() {
assert_eq!(parse("permission denied (os error 13)"), None);
assert_eq!(by_code("nornir-warehouse-999"), None);
}
}