Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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);
    }
}