/// Error framework for OpenLatch client using the OL-XXXX code format.
///
/// All user-facing errors carry a structured code, message, optional suggestion,
/// and optional docs URL. The Display format follows the D-06/D-07 convention:
///
/// ```text
/// Error: {message} (OL-XXXX)
///
/// Suggestion: {actionable text}
/// Docs: {url}
/// ```
///
/// Suggestion and Docs lines are omitted when the respective field is `None`.
use std::fmt;
/// A structured, user-facing error with an OL-XXXX code.
///
/// # Display format (D-06/D-07)
///
/// ```text
/// Error: {message} (OL-XXXX)
///
/// Suggestion: {actionable text}
/// Docs: {url}
/// ```
#[derive(Debug, Clone)]
pub struct OlError {
/// The OL-XXXX error code (e.g. "OL-1002").
pub code: &'static str,
/// Human-readable, actionable error description.
pub message: String,
/// Optional suggestion for how to fix the error.
pub suggestion: Option<String>,
/// Optional link to documentation for this error code.
pub docs_url: Option<String>,
}
impl OlError {
/// Create a new error with the given code and message.
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
suggestion: None,
docs_url: None,
}
}
/// Attach a suggestion to this error.
pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
self.suggestion = Some(s.into());
self
}
/// Attach a docs URL to this error.
pub fn with_docs(mut self, url: impl Into<String>) -> Self {
self.docs_url = Some(url.into());
self
}
/// Build a loud "pinned boundary port is occupied" error (D-25).
///
/// The boundary listener NEVER re-probes onto a different port: the port is
/// written into the agent's config at `init`, so a silent move would break
/// the agent. Occupied-at-startup is therefore a user-resolvable failure,
/// not a fallback trigger.
#[cfg(feature = "boundary")]
pub fn port_occupied(port: u16, source: std::io::Error) -> Self {
Self {
code: ERR_BOUNDARY_PORT_IN_USE,
message: format!(
"boundary listener could not bind pinned loopback port {port}: {source}"
),
suggestion: Some(format!(
"Another process is holding 127.0.0.1:{port}. Free it (lsof -i :{port}) and \
start the daemon again, or set `[boundary] enabled = false` to run without \
the boundary. The port is never silently re-probed and the daemon refuses to \
start without it — the agent config is written only once this bind succeeds, \
so it can never point at a listener that is not there."
)),
docs_url: Some("https://docs.openlatch.ai/errors/OL-BND-PORT".into()),
}
}
/// The process exit status this error should produce.
///
/// Exit codes are public API (`.claude/rules/error-handling.md`), and the
/// default for an `Err` is and stays **1**. This function exists for the
/// codes where 1 is not merely imprecise but actively wrong.
///
/// # `OL-1501` → 5
///
/// "A daemon is already running" is the one failure a supervisor must be
/// able to tell apart from a crash. systemd's `RestartPreventExitStatus=5`
/// keys off this exact number: exit 5 ends the job, every other code keeps
/// `Restart=always` restarting forever.
///
/// ⚠️ **Nothing else may map to 5.** `OL-1502` in particular — a daemon
/// that died from a serve error — must stay 1, because that *is* the crash
/// supervision exists to recover from, and a 5 there would silently disable
/// the restart it needs. This is why the already-running branch could not
/// simply return `Err` alongside a blanket `RestartPreventExitStatus=1`.
///
/// 5 = Conflict is the documented meaning of the code and already what
/// `openlatch update` returns for its cargo-install refusal, so this adds a
/// second instance of one meaning rather than a second meaning.
pub fn exit_code(&self) -> i32 {
match self.code {
ERR_ALREADY_RUNNING => 5,
_ => 1,
}
}
/// Build a "bug report" error pre-filled with a GitHub issue URL.
///
/// Use this for unexpected internal errors that indicate a bug in openlatch.
pub fn bug_report(message: impl Into<String>) -> Self {
let msg = message.into();
let url = format!(
"https://github.com/OpenLatch/openlatch-client/issues/new?title={}&body={}",
percent_encode(&msg),
percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
);
Self {
code: "OL-9999",
message: msg,
suggestion: Some("This is a bug. Please report it.".into()),
docs_url: Some(url),
}
}
}
impl fmt::Display for OlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Error: {} ({})", self.message, self.code)?;
if self.suggestion.is_some() || self.docs_url.is_some() {
writeln!(f)?;
writeln!(f)?;
if let Some(ref s) = self.suggestion {
writeln!(f, " Suggestion: {s}")?;
}
if let Some(ref url) = self.docs_url {
write!(f, " Docs: {url}")?;
}
}
Ok(())
}
}
impl std::error::Error for OlError {}
/// Map any `std::io::Error` into a generic boundary I/O `OlError`.
///
/// Gated to `boundary` so it never changes `?`-inference for the boundary-free
/// build (existing code maps `io::Error` explicitly everywhere). Lets the
/// boundary listener use `TcpListener::bind(..)?` into a `Result<_, OlError>`
/// without a bespoke map at every call site; the pinned-port bind still routes
/// through [`OlError::port_occupied`] for the loud D-25 message.
#[cfg(feature = "boundary")]
impl From<std::io::Error> for OlError {
fn from(e: std::io::Error) -> Self {
OlError::new(ERR_BOUNDARY_IO, format!("boundary I/O error: {e}"))
}
}
/// Minimal percent-encoding for URL query parameters.
///
/// Only encodes characters that break URL structure: space, newline, carriage return,
/// ampersand, equals sign, and the hash character. This avoids pulling in a
/// full URL-encoding dependency for a single use in bug_report().
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
' ' => out.push_str("%20"),
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
'&' => out.push_str("%26"),
'=' => out.push_str("%3D"),
'#' => out.push_str("%23"),
other => out.push(other),
}
}
out
}
// ---------------------------------------------------------------------------
// Envelope errors (OL-1000–1099)
// ---------------------------------------------------------------------------
// OL-1001 (ERR_UNKNOWN_AGENT) was retired with the CloudEvents v1.0.2
// migration — `source` and `type` are open strings surfaced through the
// tagged-enum lens (src/core/envelope/known_types.rs), so unknown values
// are a valid runtime path, not an error. The code is intentionally left
// vacant so it is never reused with different semantics. See
// .brainstorms/2026-04-16-forward-compatible-wire-enums.md §5.7.
/// Event body exceeds the 1 MB size limit.
pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
/// Event was deduplicated within the TTL window (informational).
pub const ERR_EVENT_DEDUPED: &str = "OL-1003";
// ---------------------------------------------------------------------------
// Privacy filter errors (OL-1100–1199)
// ---------------------------------------------------------------------------
/// A custom regex pattern in config.toml failed to compile.
pub const ERR_INVALID_REGEX: &str = "OL-1100";
// ---------------------------------------------------------------------------
// Cloud forwarding errors (OL-1200–1299)
// ---------------------------------------------------------------------------
/// Cloud API endpoint is unreachable (network error or DNS failure).
pub const ERR_CLOUD_UNREACHABLE: &str = "OL-1200";
/// Cloud returned 401/403 — API key is invalid or revoked.
pub const ERR_CLOUD_AUTH_FAILED: &str = "OL-1201";
/// Cloud returned 429 — rate limit exceeded; respect Retry-After header.
pub const ERR_CLOUD_RATE_LIMITED: &str = "OL-1202";
/// Durable outbox write failure (disk full, permission denied, I/O error).
/// The daemon continues serving events — the outbox is a recovery aid, not
/// a correctness mechanism — but the event is lost for cloud replay.
pub const ERR_OUTBOX_WRITE_FAILED: &str = "OL-1204";
/// Outbox drain completed with some entries still pending (cloud transient
/// failure mid-replay, or I/O error reading the spool). The remaining
/// entries survive on disk and are retried on the next drain signal.
pub const ERR_OUTBOX_DRAIN_PARTIAL: &str = "OL-1205";
/// Cloud forwarding channel is under sustained congestion — emergency mode
/// engaged. Live hook events were being dropped faster than the worker
/// could drain; replay is paused and the fallback queue may be evicted to
/// restore headroom. Cleared automatically when drops cease for two
/// consecutive health ticks.
pub const ERR_CLOUD_CHANNEL_EMERGENCY: &str = "OL-1206";
/// An outbox entry failed repeatedly within the current daemon lifetime and
/// was quarantined so the queue can drain past it. The entry is dropped
/// from the outbox without ever reaching the cloud — only emitted after
/// the per-id attempt count crosses `OUTBOX_MAX_ATTEMPTS`.
pub const ERR_OUTBOX_QUARANTINED: &str = "OL-1207";
// --- Policy bundle sync (OL-1210–1214) -------------------------------------
//
// Same Forwarding decade as OL-1200–1207 — the policy bundle rides the same
// cloud rail — but a distinct, previously unassigned block. OL-1208/1209 are
// left vacant so the two groups never blur together.
//
// Every one of these is logged with the structured `code =` field, matching
// the existing `tracing::warn!(code = "OL-1200", …)` idiom.
/// A poll for the policy bundle failed (network error, 5xx, or 401/403).
/// The previously activated bundle is retained and keeps enforcing —
/// fail-static, never fail-open. Warn level for transient failures, error
/// level for auth failures.
pub const ERR_BUNDLE_FETCH_FAILED: &str = "OL-1210";
/// A fetched or on-disk bundle failed verification and was NOT activated:
/// digest mismatch against the ETag, an `organization_id` that is not ours,
/// a `200` carrying no `ETag` header (the digest is not inside the body, so
/// an unverifiable body must never activate), or a cached `bundle.json`
/// whose bytes no longer match `bundle.meta.json`. The previous bundle stays
/// resident; the stored validator is deliberately NOT advanced, so the next
/// poll re-requests and re-attempts activation.
pub const ERR_BUNDLE_REJECTED: &str = "OL-1211";
/// A bundle was structurally unusable: malformed JSON, a `schema_version`
/// this client does not know, or a non-null `signature` this client cannot
/// verify (refusing rather than ignoring the field is what stops an old
/// client from silently downgrading signing once it is enabled).
pub const ERR_BUNDLE_INVALID: &str = "OL-1212";
/// No successful poll within `stale_warn_after_secs`. Warning only — the
/// resident bundle keeps enforcing. Measured from the last successful poll
/// (connectivity), not from `built_at` (policy age), and a `304` counts as
/// a successful poll.
pub const ERR_BUNDLE_STALE: &str = "OL-1213";
/// One rule inside an otherwise-good bundle was acted on individually at
/// projection time — dropped, or held in a weaker mode — and the rest of the
/// bundle activated normally. Deliberately NOT `OL-1212`, which means *the
/// bundle was rejected and the previous one retained* — reusing it would invert
/// its meaning for anyone alerting on it.
///
/// ⚠️ **Alerting on this code alone counts more than lost rules.** Four of the
/// five reasons below mean a rule was dropped; `enforce_coerced` means a rule
/// was kept. An alert tracking lost enforcement coverage must filter on
/// `reason`, not on the code.
///
/// Logged at `warn` with `rule_id` and a `reason` from a closed set:
///
/// | `reason` | Cause |
/// | -------- | ----- |
/// | `unknown_kind` | A `kind` this client version does not implement |
/// | `unknown_action` | An `action` this client version cannot carry out |
/// | `unrecognized_field` | Anything in a rule that this build's generated types refuse: a key from a newer schema, **or** a value outside one of the closed enums those types carry — `conditions[]`'s `field`, `op` and `value` (`AgentFunction`) are the ones that exist today. Both arrive through the same per-rule serde channel (`parse_bundle_tolerant`), which is why they share a reason. The rows above are the *open* string vocabularies, recognised and skipped by name; this row is everything the deserializer itself rejects, and it exists so that gap costs one rule rather than the whole bundle (`OL-1212`). Pinned by `a_rule_with_an_unknown_condition_field_is_dropped_not_fatal` |
/// | `gate_violation` | The rule failed the per-kind/per-action schema gate |
/// | `enforce_coerced` | Not a skip: a `kind: request` rule authored `enforce` was held as `observe`, because nothing acts on the request plane in Phase 1 (D-26). Named per rule so the coercion is never silent |
pub const ERR_RULE_SKIPPED: &str = "OL-1214";
// ---------------------------------------------------------------------------
// Config errors (OL-1300–1399)
// ---------------------------------------------------------------------------
/// The configuration file contains an invalid value.
pub const ERR_INVALID_CONFIG: &str = "OL-1300";
/// A required configuration field is absent and has no default.
pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";
// ---------------------------------------------------------------------------
// Hooks errors (OL-1400–OL-1499)
// ---------------------------------------------------------------------------
/// No supported AI agent was detected on this machine.
pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
/// Cannot read or write the agent's settings.json (permissions, I/O error).
pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
/// The settings.json file contains malformed JSONC that cannot be parsed.
pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
/// Existing non-OpenLatch hooks detected in settings.json (warning, non-blocking).
pub const ERR_HOOK_CONFLICT: &str = "OL-1403";
/// No `openlatch-hook` binary could be staged into `<ol_dir>/bin/`, so any hook
/// command written now would resolve to a bare name and die with exit 127 on
/// every agent tool call. Fatal for `init` — an install that cannot resolve its
/// own hook binary is not a successful install — and reported (not applied) by
/// `doctor --fix`.
pub const ERR_HOOK_BINARY_UNRESOLVABLE: &str = "OL-1404";
// ---------------------------------------------------------------------------
// Daemon errors (OL-1500–1599)
// ---------------------------------------------------------------------------
/// The selected port is already in use by another process.
pub const ERR_PORT_IN_USE: &str = "OL-1500";
/// A daemon instance is already running on this machine.
pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
/// Daemon process started but health check failed within timeout.
pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
/// A newer version of openlatch is available (warning, non-blocking).
pub const ERR_VERSION_OUTDATED: &str = "OL-1503";
/// Auto-update apply failed at verify / integrity / sanity / extraction
/// time. The downloaded artefact did not match the trust root, was
/// truncated, hit a hardening check, or failed to invoke `--version`.
/// Always surfaces the staging directory was discarded — no partial
/// state lands on disk.
pub const ERR_UPDATE_VERIFY_FAILED: &str = "OL-1504";
/// `cargo install` users cannot use the auto-update path. Surfaces the
/// `cargo install --force --locked openlatch-client` recovery line.
pub const ERR_UPDATE_REFUSED_CARGO_INSTALL: &str = "OL-1505";
/// CLI expected to talk to a running daemon's `/admin/update` endpoint
/// but could not reach it. Distinct from `OL-1500` (the *user's* daemon
/// failed to bind a port at startup). The CLI falls back to the
/// in-process apply when this happens.
pub const ERR_UPDATE_DAEMON_UNREACHABLE: &str = "OL-1506";
/// `openlatch stop`/`restart` could not terminate the daemon: it survived
/// graceful `/shutdown`, SIGTERM, AND SIGKILL (effectively never — a process
/// that survives SIGKILL is stuck in an uninterruptible kernel wait). The user
/// must clean up manually. Distinct from `OL-1300` (a *config* value error),
/// which this path previously misused.
pub const ERR_DAEMON_STOP_FAILED: &str = "OL-1507";
// --- Subsystem supervision (OL-1510–1513) ----------------------------------
//
// Same Daemon decade as OL-1500–1507 — these are all "the daemon is not
// serving what it claims to serve" conditions — but a distinct block.
// OL-1508/1509 are left vacant so the two groups never blur together,
// following the OL-1210 precedent in the Forwarding decade.
/// A supervised in-process task panicked. The supervisor caught it via the
/// task's `JoinHandle` and is restarting the subsystem after a backoff; the
/// rest of the daemon keeps serving. Logged at ERROR on the first occurrence
/// and at DEBUG for each consecutive one, so a subsystem panicking every
/// backoff window cannot flood `daemon.log`.
pub const ERR_TASK_PANICKED: &str = "OL-1510";
/// A supervised task has failed on every restart for
/// [`crate::supervision::task::RESTART_LIMIT_WARN_AT`] consecutive attempts.
/// The supervisor keeps retrying — it never gives up — but the streak is
/// surfaced once so a permanently broken subsystem is visible without
/// tailing DEBUG logs.
pub const ERR_TASK_RESTART_LIMIT: &str = "OL-1511";
/// `/health` reports at least one `RestartPolicy::Always` subsystem that is
/// not `running`. The daemon answers requests but is not doing everything it
/// advertises — `status` becomes `degraded` rather than `ok`.
pub const ERR_SUBSYSTEM_DEGRADED: &str = "OL-1512";
/// No OS supervisor is installed and nobody declined one, so nothing will
/// restart the daemon if it dies or the machine reboots.
///
/// Warning only, and reported ONLY for an absence the user did not ask for
/// (`unsupported_os`, a deferred install, an uninitialised config) — see
/// `supervision::absence_is_deliberate`. `--no-persistence`, `--foreground` and
/// `--no-start` are choices, not findings, and `--foreground` in particular must
/// never be hijacked into an automatic install.
pub const ERR_NO_SUPERVISOR: &str = "OL-1513";
// ---------------------------------------------------------------------------
// Auth / credential errors (OL-1600–1699)
// ---------------------------------------------------------------------------
/// No credentials found in keychain, env var, or encrypted file.
pub const ERR_NO_CREDENTIALS: &str = "OL-1600";
/// API key / token has expired or been revoked.
pub const ERR_TOKEN_EXPIRED: &str = "OL-1601";
/// OS keychain service is unavailable (e.g., no Secret Service on headless Linux).
pub const ERR_KEYCHAIN_UNAVAILABLE: &str = "OL-1602";
/// OS keychain denied access (permission error).
pub const ERR_KEYCHAIN_PERMISSION: &str = "OL-1603";
/// Encrypted file fallback failed (decrypt error, missing file, or corrupt data).
pub const ERR_FILE_FALLBACK_ERROR: &str = "OL-1604";
/// Auth login flow timed out after 5 minutes waiting for browser callback.
pub const ERR_AUTH_TIMEOUT: &str = "OL-1605";
/// Auth login flow failed (callback server error, truncated request, browser launch error).
pub const ERR_AUTH_FLOW_FAILED: &str = "OL-1606";
/// Server-side API key revocation failed during logout (warning only — local cleanup continues).
pub const ERR_AUTH_REVOCATION_FAILED: &str = "OL-1607";
/// Returns a platform-specific suggestion for keychain errors (D-23).
///
/// Provides actionable guidance tailored to the current operating system,
/// including an `OPENLATCH_API_KEY` env var fallback in all cases.
pub fn keychain_suggestion() -> String {
if cfg!(target_os = "linux") {
"Install and start gnome-keyring or KWallet, or set OPENLATCH_API_KEY env var as fallback."
.into()
} else if cfg!(target_os = "windows") {
"Check Windows Credential Manager in Control Panel, or set OPENLATCH_API_KEY env var."
.into()
} else if cfg!(target_os = "macos") {
"Check Keychain Access.app permissions, or set OPENLATCH_API_KEY env var.".into()
} else {
"Set OPENLATCH_API_KEY env var as an alternative to OS keychain.".into()
}
}
// ---------------------------------------------------------------------------
// Telemetry errors (OL-1700–1799)
// ---------------------------------------------------------------------------
/// The telemetry.json consent file contains invalid JSON and cannot be parsed.
pub const ERR_TELEMETRY_CONFIG_CORRUPT: &str = "OL-1700";
/// Failed to write the telemetry.json consent file (permissions or I/O error).
pub const ERR_TELEMETRY_WRITE_FAILED: &str = "OL-1701";
/// A telemetry batch POST failed (informational — events dropped, no retry).
pub const ERR_TELEMETRY_POST_FAILED: &str = "OL-1702";
/// Telemetry subsystem failed to initialise (baked key missing, channel error).
pub const ERR_TELEMETRY_INIT_FAILED: &str = "OL-1703";
// ---------------------------------------------------------------------------
// Doctor (--fix / --restore / --rescue) errors (OL-1800–1899)
// ---------------------------------------------------------------------------
/// `doctor --fix` journal file is present but cannot be parsed (--restore can't drive).
pub const ERR_DOCTOR_JOURNAL_CORRUPT: &str = "OL-1800";
/// `doctor --restore` was invoked with no prior `--fix` journal on disk.
pub const ERR_DOCTOR_RESTORE_NO_JOURNAL: &str = "OL-1801";
/// `doctor --rescue` could not write the archive (disk full, permissions, etc.).
pub const ERR_DOCTOR_RESCUE_WRITE_FAILED: &str = "OL-1802";
/// `doctor --rescue` produced an archive but one or more collectors errored.
pub const ERR_DOCTOR_RESCUE_PARTIAL: &str = "OL-1803";
// ---------------------------------------------------------------------------
// Tamper-evidence errors (OL-1900–1999)
// ---------------------------------------------------------------------------
/// HMAC key unavailable — both OS keyring and file fallback failed.
pub const ERR_HMAC_KEY_UNAVAILABLE: &str = "OL-1900";
/// The hook-state.json file exists but cannot be parsed (invalid JSON or unknown schema_version).
pub const ERR_STATE_FILE_CORRUPT: &str = "OL-1901";
/// Atomic write to hook-state.json failed after settings.json was written.
pub const ERR_STATE_FILE_WRITE_FAILED: &str = "OL-1902";
/// The `_openlatch.v` field carries a version we don't understand.
pub const ERR_MARKER_SCHEMA_UNSUPPORTED: &str = "OL-1903";
/// JCS canonicalization rejected the hook entry (internal error; should not happen).
pub const ERR_CANONICALIZATION_FAILED: &str = "OL-1904";
/// A legacy `_openlatch: true` boolean marker was found and will be upgraded (info-level).
pub const ERR_LEGACY_MARKER_DETECTED: &str = "OL-1905";
// ---------------------------------------------------------------------------
// Configuration plane monitoring errors (OL-2000–2099)
// ---------------------------------------------------------------------------
/// Config monitor init failed (manifest parse error or watcher setup error).
pub const ERR_INVENTORY_INIT_FAILED: &str = "OL-2000";
/// Manifest file not found at configured path.
pub const ERR_INVENTORY_MANIFEST_NOT_FOUND: &str = "OL-2001";
/// Manifest schema validation failed.
pub const ERR_INVENTORY_MANIFEST_PARSE: &str = "OL-2002";
/// Path expansion failed (unknown variable, missing env var, OS-call failure).
pub const ERR_INVENTORY_PATH_EXPANSION: &str = "OL-2003";
/// Watcher init failed for a specific path (continues running for other paths).
pub const ERR_INVENTORY_WATCHER_FAILED: &str = "OL-2004";
/// JCS canonicalization or NFC normalization failed (fail-open: log + continue).
pub const ERR_INVENTORY_HASH_FAILED: &str = "OL-2005";
/// Periodic rescan partial — some paths inaccessible; rescan completes for the rest.
pub const ERR_INVENTORY_RESCAN_PARTIAL: &str = "OL-2006";
/// Session-start enrichment failed (logs warning, continues with un-enriched event).
pub const ERR_INVENTORY_ENRICH_FAILED: &str = "OL-2007";
// ---------------------------------------------------------------------------
// Model-boundary listener errors (OL-BND-*) — feature = "boundary"
// ---------------------------------------------------------------------------
/// The pinned boundary loopback port is already in use. Loud, never a silent
/// re-probe (D-25) — the agent config points at this exact port.
pub const ERR_BOUNDARY_PORT_IN_USE: &str = "OL-BND-PORT";
/// The boundary axum server exited with an I/O error while serving.
pub const ERR_BOUNDARY_SERVE: &str = "OL-BND-SERVE";
/// Generic boundary I/O error (used by the `From<std::io::Error>` bridge).
pub const ERR_BOUNDARY_IO: &str = "OL-BND-IO";
/// The pinned boundary port is held by a process that is NOT the OpenLatch
/// boundary (wrong/missing status signature). Reported by `openlatch doctor`:
/// an agent wired at a foreign loopback listener is sending its provider
/// credential to that process.
pub const ERR_BOUNDARY_PORT_FOREIGN: &str = "OL-BND-FOREIGN";
/// Nothing is answering the boundary status endpoint on the pinned port
/// (connection refused).
pub const ERR_BOUNDARY_NOT_RUNNING: &str = "OL-BND-DOWN";
/// `boundary explain <finding_id>` found no matching churn block in the bounded
/// local retention store (never produced here, or aged/pruned out).
pub const ERR_BOUNDARY_FINDING_NOT_FOUND: &str = "OL-BND-NOFIND";
/// The boundary bound its port but a synthetic request could not complete the
/// round trip to the provider through it, so the agent was deliberately left
/// unwired (`boundary::preflight`). Reported by `init` — which exits non-zero
/// while leaving hooks, daemon and supervision fully installed — and by
/// `doctor`. NOT a daemon-fatal condition: the daemon keeps serving and re-wires
/// itself as soon as the probe passes.
pub const ERR_BOUNDARY_PREFLIGHT_FAILED: &str = "OL-BND-PREFLIGHT";
// ---------------------------------------------------------------------------
// Bug report sentinel
// ---------------------------------------------------------------------------
/// Code assigned to all internal/unexpected errors routed through bug_report().
pub const ERR_BUG: &str = "OL-9999";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ol_error_display_full_format() {
// OlError Display output matches D-06/D-07 format. Uses a stable
// error code unaffected by the CloudEvents migration (OL-1002 is
// retained; OL-1001 was retired when the wire format opened up).
let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
.with_suggestion("Split the payload into smaller events")
.with_docs("https://docs.openlatch.ai/errors/OL-1002");
let output = format!("{err}");
assert!(
output.starts_with("Error: Event body exceeds 1 MB limit (OL-1002)"),
"Expected error header, got: {output}"
);
assert!(
output.contains("Suggestion: Split the payload"),
"Missing suggestion"
);
assert!(
output.contains("Docs: https://docs.openlatch.ai"),
"Missing docs URL"
);
}
#[test]
fn test_ol_error_display_no_suggestion() {
// Test 2: OlError without suggestion omits the suggestion line
let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
.with_docs("https://docs.openlatch.ai/errors/OL-1002");
let output = format!("{err}");
assert!(
!output.contains("Suggestion:"),
"Should not contain suggestion line: {output}"
);
assert!(
output.contains("Docs:"),
"Should still contain docs line: {output}"
);
}
#[test]
fn test_ol_error_display_no_docs_url() {
// Test 3: OlError without docs_url omits the docs line
let err = OlError::new(ERR_INVALID_REGEX, "Invalid regex pattern")
.with_suggestion("Fix the regex in your config");
let output = format!("{err}");
assert!(
!output.contains("Docs:"),
"Should not contain docs line: {output}"
);
assert!(
output.contains("Suggestion:"),
"Should still contain suggestion: {output}"
);
}
#[test]
fn test_ol_error_display_no_optional_fields() {
// Test 3 (extended): OlError with neither suggestion nor docs
let err = OlError::new(ERR_PORT_IN_USE, "Port 7443 is already in use");
let output = format!("{err}");
assert_eq!(output, "Error: Port 7443 is already in use (OL-1500)");
}
#[test]
fn test_error_code_constants_exist() {
// Error code constants exist for each subsystem range. OL-1001
// (ERR_UNKNOWN_AGENT) was retired with the CloudEvents migration.
assert_eq!(ERR_EVENT_TOO_LARGE, "OL-1002");
assert_eq!(ERR_INVALID_REGEX, "OL-1100");
assert_eq!(ERR_INVALID_CONFIG, "OL-1300");
assert_eq!(ERR_MISSING_CONFIG_FIELD, "OL-1301");
assert_eq!(ERR_EVENT_DEDUPED, "OL-1003");
assert_eq!(ERR_HOOK_CONFLICT, "OL-1403");
assert_eq!(ERR_PORT_IN_USE, "OL-1500");
assert_eq!(ERR_ALREADY_RUNNING, "OL-1501");
assert_eq!(ERR_DAEMON_START_FAILED, "OL-1502");
assert_eq!(ERR_VERSION_OUTDATED, "OL-1503");
assert_eq!(ERR_DAEMON_STOP_FAILED, "OL-1507");
// Subsystem supervision (OL-1508/1509 deliberately vacant)
assert_eq!(ERR_TASK_PANICKED, "OL-1510");
assert_eq!(ERR_TASK_RESTART_LIMIT, "OL-1511");
assert_eq!(ERR_SUBSYSTEM_DEGRADED, "OL-1512");
assert_eq!(ERR_NO_SUPERVISOR, "OL-1513");
// Cloud forwarding errors
assert_eq!(ERR_CLOUD_UNREACHABLE, "OL-1200");
assert_eq!(ERR_CLOUD_AUTH_FAILED, "OL-1201");
assert_eq!(ERR_CLOUD_RATE_LIMITED, "OL-1202");
assert_eq!(ERR_CLOUD_CHANNEL_EMERGENCY, "OL-1206");
assert_eq!(ERR_OUTBOX_QUARANTINED, "OL-1207");
// Policy bundle sync
assert_eq!(ERR_BUNDLE_FETCH_FAILED, "OL-1210");
assert_eq!(ERR_BUNDLE_REJECTED, "OL-1211");
assert_eq!(ERR_BUNDLE_INVALID, "OL-1212");
assert_eq!(ERR_BUNDLE_STALE, "OL-1213");
assert_eq!(ERR_RULE_SKIPPED, "OL-1214");
// Auth / credential errors
assert_eq!(ERR_NO_CREDENTIALS, "OL-1600");
assert_eq!(ERR_TOKEN_EXPIRED, "OL-1601");
assert_eq!(ERR_KEYCHAIN_UNAVAILABLE, "OL-1602");
assert_eq!(ERR_KEYCHAIN_PERMISSION, "OL-1603");
assert_eq!(ERR_FILE_FALLBACK_ERROR, "OL-1604");
// Tamper-evidence errors
assert_eq!(ERR_HMAC_KEY_UNAVAILABLE, "OL-1900");
assert_eq!(ERR_STATE_FILE_CORRUPT, "OL-1901");
assert_eq!(ERR_STATE_FILE_WRITE_FAILED, "OL-1902");
assert_eq!(ERR_MARKER_SCHEMA_UNSUPPORTED, "OL-1903");
assert_eq!(ERR_CANONICALIZATION_FAILED, "OL-1904");
assert_eq!(ERR_LEGACY_MARKER_DETECTED, "OL-1905");
}
/// The pairing that makes `RestartPreventExitStatus=5` safe: the
/// already-running refusal is the ONLY code that reaches 5. If OL-1502
/// ever joined it, a supervised daemon that crashed on a serve error would
/// stop being restarted — the exact failure supervision exists to cover.
#[test]
fn only_already_running_exits_five() {
assert_eq!(OlError::new(ERR_ALREADY_RUNNING, "x").exit_code(), 5);
for code in [
ERR_DAEMON_START_FAILED,
ERR_PORT_IN_USE,
ERR_DAEMON_STOP_FAILED,
ERR_INVALID_CONFIG,
ERR_NO_CREDENTIALS,
ERR_BUG,
] {
assert_eq!(
OlError::new(code, "x").exit_code(),
1,
"{code} must exit 1 — only OL-1501 may exit 5, or supervision \
stops restarting genuine crashes"
);
}
}
#[test]
fn test_keychain_suggestion_returns_non_empty_string() {
// keychain_suggestion() must return a non-empty, platform-specific string
let suggestion = keychain_suggestion();
assert!(
!suggestion.is_empty(),
"keychain_suggestion must not be empty"
);
// On Windows it should mention Credential Manager or env var
#[cfg(target_os = "windows")]
assert!(
suggestion.contains("Credential Manager") || suggestion.contains("OPENLATCH_API_KEY"),
"Windows suggestion must mention Credential Manager or env var: {suggestion}"
);
// On macOS it should mention Keychain Access
#[cfg(target_os = "macos")]
assert!(
suggestion.contains("Keychain") || suggestion.contains("OPENLATCH_API_KEY"),
"macOS suggestion must mention Keychain: {suggestion}"
);
// On Linux it should mention gnome-keyring or Secret Service
#[cfg(target_os = "linux")]
assert!(
suggestion.contains("gnome-keyring") || suggestion.contains("OPENLATCH_API_KEY"),
"Linux suggestion must mention gnome-keyring or env var: {suggestion}"
);
}
#[test]
fn test_ol_error_implements_std_error() {
// Test 5: OlError implements std::error::Error trait
let err = OlError::new(ERR_EVENT_TOO_LARGE, "test");
// Verify the trait bound by using it as &dyn std::error::Error
let _boxed: Box<dyn std::error::Error> = Box::new(err);
}
#[test]
fn test_bug_report_sets_code_ol_9999() {
let err = OlError::bug_report("Unexpected panic in envelope module");
assert_eq!(err.code, "OL-9999");
assert!(err.suggestion.is_some());
assert!(err.docs_url.is_some());
let url = err.docs_url.unwrap();
assert!(url.contains("github.com/OpenLatch/openlatch-client/issues/new"));
}
#[test]
fn test_percent_encode_spaces_and_newlines() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("line1\nline2"), "line1%0Aline2");
}
}