openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
/// 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");
    }
}