openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
/// 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";

/// The policy plane is switched off in config, so no rule is evaluated and
/// every action is allowed.
///
/// Not a failure — an operator is entitled to run without local policy — but
/// never a green state either: `[policy] enabled = false` disarms enforcement
/// on this host, and a diagnostic that reports that as healthy is how a
/// disarmed client goes unnoticed for weeks.
pub const ERR_POLICY_DISABLED: &str = "OL-1215";

// --- Proxy / egress errors (OL-1220–1229) -----------------------------------
//
// Same Cloud forwarding decade as OL-1200–1215 — egress to the platform is
// still a cloud-forwarding concern — but a distinct block for proxy-aware
// egress (Initiative I-1, Proxy Support). OL-1216–1219 are left vacant so
// the groups never blur together, following the OL-1210 precedent above.

/// The platform could not be reached at all: no proxy is configured, and the
/// direct connection to the platform failed at the TCP/DNS level. Distinct
/// from `OL-1221` below — that code fires when a *proxy* is in the path and
/// unreachable, whereas this one means egress never had a proxy hop to blame.
/// Check outbound DNS resolution and firewall rules for the host running the
/// daemon.
pub const ERR_EGRESS_UNREACHABLE: &str = "OL-1220";
/// A proxy is configured, but the proxy itself did not answer: the TCP
/// connect to the proxy host failed, or an HTTPS `CONNECT` through it timed
/// out or was refused. The destination is never reached because the client
/// cannot even establish the tunnel to it. Verify the proxy host and port are
/// correct and reachable from this machine before suspecting the destination.
pub const ERR_PROXY_UNREACHABLE: &str = "OL-1221";
/// The proxy answered `407 Proxy Authentication Required` and either the
/// credentials the client offered were rejected, or it had none to offer.
/// Distinct from `OL-1201`/`ERR_CLOUD_AUTH_FAILED` — that is the *platform*
/// rejecting our API key; this is the proxy's own gate, tripped before the
/// request ever reaches the platform. A credential that was valid yesterday
/// and a corporate proxy that rotated its password look identical on the
/// wire, so the operator needs to reconfirm the proxy credential specifically,
/// not the platform API key.
pub const ERR_PROXY_AUTH_FAILED: &str = "OL-1222";
/// The configured proxy URL names a scheme this build cannot speak: anything
/// outside `http`/`https`/`socks5`/`socks5h`, or `negotiate` requested
/// against a build compiled without the Kerberos feature. Fails fast at
/// proxy resolution rather than silently falling through to a plain connect
/// — a silent downgrade would route traffic around the very proxy the
/// operator configured. Fix the scheme in the proxy URL, or rebuild with the
/// feature that scheme requires.
pub const ERR_PROXY_SCHEME_UNSUPPORTED: &str = "OL-1223";
/// The TLS handshake to the destination failed certificate verification. The
/// most common cause on an enterprise network is a TLS-inspecting proxy or
/// firewall re-signing outbound traffic with an interception CA that is not
/// in the OS trust store and was not supplied via `ca_bundle` — from the
/// client's point of view that looks identical to a real machine-in-the-middle,
/// which is exactly why it refuses the handshake instead of guessing. The fix
/// is almost always to point `ca_bundle` at the interception root, never to
/// disable verification.
pub const ERR_EGRESS_TLS_FAILED: &str = "OL-1224";
/// A PAC (Proxy Auto-Config) or WPAD source is configured, but the OS could
/// not evaluate it: the PAC URL did not resolve or serve a script, or the
/// platform's PAC engine is unavailable on this OS. Egress falls back to
/// whatever static proxy or direct behaviour the config declares beneath the
/// PAC entry, so this code is informational unless that fallback also fails.
pub const ERR_PAC_UNAVAILABLE: &str = "OL-1225";
/// The proxy configuration itself is malformed: an unparsable proxy URL, a
/// scheme missing a credential field it requires, or `allow_direct` and the
/// proxy settings contradicting each other. Unlike every other code in this
/// block — each describing a runtime network condition that never blocks
/// startup — this one DOES fail startup: a config the client cannot even
/// parse is an authoring bug, not a transient network state, and letting the
/// daemon start with it would only move the failure to the first request.
pub const ERR_PROXY_CONFIG_INVALID: &str = "OL-1226";
/// `allow_direct = false` and no usable proxy could be found for this
/// destination. Egress refuses the request rather than silently falling back
/// to a direct connection — on a network where policy requires all egress to
/// transit a proxy, a silent direct fallback is an exfiltration path, not a
/// convenience. Fix the proxy configuration, or set `allow_direct = true` if
/// direct egress from this host is actually acceptable.
pub const ERR_DIRECT_FORBIDDEN: &str = "OL-1227";
/// The proxy buffers a streaming response instead of passing bytes through as
/// they arrive: the `CONNECT` tunnel itself works, but a Server-Sent Events
/// stream piles up behind the proxy's internal buffer and lands in one burst
/// instead of streaming. This is a proxy-behaviour diagnosis, not a
/// connectivity failure — the request still succeeds — so it is surfaced as a
/// warning rather than failing the call. Look for a response-buffering or
/// content-inspection setting on the proxy for the streaming endpoint.
pub const ERR_STREAM_BUFFERED: &str = "OL-1228";
/// `negotiate` (Kerberos/SPNEGO) was selected for proxy auth, but no usable
/// ticket could be minted: no Kerberos credential cache, an expired TGT, or
/// the proxy's SPN not resolving from this host. Distinct from
/// `OL-1222`/`ERR_PROXY_AUTH_FAILED` — the proxy was never even offered a
/// token to accept or reject. The fix is host-side: run `kinit`, or confirm
/// the machine is domain-joined so Windows can obtain a ticket automatically.
pub const ERR_NEGOTIATE_NO_TICKET: &str = "OL-1229";

// --- `openlatch evaluate` frame protocol (OL-1240–1244) -------------------
//
// The codes the batch evaluator writes on its own stdout, not through
// `OlError`: the wire is NDJSON and every one of these rides inside a frame
// (`docs/evaluate-protocol.md`). They retain the contract's `OL-12xx`
// prefix; OL-1230–1239 stay vacant so the proxy block
// above and this one never blur together, following the OL-1210 precedent.
//
// The consumer never switches exhaustively on one and never parses the message
// — the code is an open string and the message is for humans.

/// A `row` frame that did not deserialise into a request row: an `event` that
/// is not an object, a `now_ms` that is not an integer, a `state_in` that is
/// not a register file. **One row, never the stream** — the frame is answered
/// with an error response and the run continues, so a replay reports "all but
/// four events evaluated" instead of dying midway.
pub const ERR_EVAL_ROW_MALFORMED: &str = "OL-1240";
/// Stage 1 of the bundle parse failed, so **nothing loaded**: the envelope did
/// not deserialise, or its `client_floor` is above what this engine implements.
/// Written as a `bundle_error` frame, which is a different outcome from a
/// `bundle_ack` carrying a non-empty `skipped` — that one means the bundle IS
/// loaded and one artifact is not.
pub const ERR_EVAL_BUNDLE_REJECTED: &str = "OL-1241";
/// A `row` arrived with no bundle to evaluate it against: no header frame has
/// been accepted and the row carries no inline `bundle` of its own. Answered
/// per row, because the next header frame may well fix it.
pub const ERR_EVAL_NO_BUNDLE: &str = "OL-1242";
/// The row's `state_in` disagrees with the bundle's `state_layout`. **Malformed,
/// never padded** — two implementations cannot agree about which counter is
/// which if one of them silently invents a slot.
pub const ERR_EVAL_STATE_MISMATCH: &str = "OL-1243";
/// The engine panicked while evaluating one row.
///
/// A bug — the client must never panic on malformed input — but a bug that must
/// cost ONE row, not the run: a batch replay that dies at event 40 000 of 90 000
/// reports nothing about the 50 000 events after it. The row is answered with
/// this code and the loop continues.
pub const ERR_EVAL_PANICKED: &str = "OL-1244";

// ---------------------------------------------------------------------------
// 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";
/// The file on disk has changed since the running daemon read it, so the
/// process is still serving the previous values. Nothing is wrong with the
/// file — `openlatch proxy set` and its siblings write it correctly and say so
/// — and nothing is wrong with the daemon. The two are simply out of step, and
/// only a restart closes the gap.
///
/// Reported as `Degraded`, never `Ok`: a diagnostic that reads the file and
/// calls it the live configuration would tell an operator a proxy is carrying
/// their traffic before a single byte has gone through it.
pub const ERR_CONFIG_NOT_APPLIED: &str = "OL-1302";

// ---------------------------------------------------------------------------
// 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";
/// The agent's hooks are installed and correct on disk, and the agent will
/// nonetheless never run them — so nothing is enforced. Three ways to reach it,
/// one code and three remedies: our hook binary does not resolve; the agent has
/// not been told to trust the hook (Codex marks a new or changed hook command
/// for review and skips it until the developer runs `/hooks`); or an
/// administrative setting suppresses non-managed hooks wholesale.
///
/// The distinguishing property is that there is **no runtime symptom**: nothing
/// calls us, nothing errors, and the file on disk looks perfect. Reported by
/// the diagnostic that proves arming rather than assuming it, and the remedy
/// names which of the three it observed.
pub const ERR_HOOK_NOT_ARMED: &str = "OL-1405";
/// An agent config file that is TOML — Codex CLI's `config.toml` — is present
/// and is not valid TOML, so the model-boundary provider table can be neither
/// written nor reversed without destroying what is already in it.
///
/// The TOML sibling of [`ERR_HOOK_MALFORMED_JSONC`], and a separate code
/// because the remedy names a different file and a different syntax. Never
/// fatal to the daemon: the agent is left unwired and talks to its provider
/// directly.
pub const ERR_HOOK_MALFORMED_TOML: &str = "OL-1406";

// ---------------------------------------------------------------------------
// 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";
/// Hook entries were modified outside OpenLatch and the reconciler healed them.
///
/// The heal is the happy path — nothing is broken once it lands. It is still
/// worth surfacing: repeated tamper events mean something on the machine keeps
/// rewriting `settings.json`, and the next write may land somewhere the
/// reconciler does not watch.
pub const ERR_TAMPER_DETECTED: &str = "OL-1906";

// ---------------------------------------------------------------------------
// 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";
/// The configuration-plane monitor is switched off in config, so config drift
/// on this host is neither observed nor reported.
pub const ERR_INVENTORY_DISABLED: &str = "OL-2008";

// ---------------------------------------------------------------------------
// 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";
/// A provider table under the name OpenLatch writes already exists in the
/// agent's config and does **not** point at our loopback listener — it is
/// somebody else's. Install refuses rather than overwriting it, because
/// uninstall could not then round-trip the file: the reversal would delete a
/// table that was never ours and restore a pointer we never recorded.
///
/// Distinct from [`ERR_BOUNDARY_PORT_FOREIGN`], which is a fact about the
/// *port* — a foreign process holding the listener — not about a config file.
/// Remedy: rename or remove the conflicting `[model_providers.<name>]` table,
/// or leave OpenLatch's model boundary disabled for that agent.
pub const ERR_BOUNDARY_FOREIGN_PROVIDER: &str = "OL-BND-PROVIDER";

// ---------------------------------------------------------------------------
// 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_CONFIG_NOT_APPLIED, "OL-1302");
        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");
        assert_eq!(ERR_POLICY_DISABLED, "OL-1215");
        // Proxy / egress errors (OL-1216–1219 deliberately vacant)
        assert_eq!(ERR_EGRESS_UNREACHABLE, "OL-1220");
        assert_eq!(ERR_PROXY_UNREACHABLE, "OL-1221");
        assert_eq!(ERR_PROXY_AUTH_FAILED, "OL-1222");
        assert_eq!(ERR_PROXY_SCHEME_UNSUPPORTED, "OL-1223");
        assert_eq!(ERR_EGRESS_TLS_FAILED, "OL-1224");
        assert_eq!(ERR_PAC_UNAVAILABLE, "OL-1225");
        assert_eq!(ERR_PROXY_CONFIG_INVALID, "OL-1226");
        assert_eq!(ERR_DIRECT_FORBIDDEN, "OL-1227");
        assert_eq!(ERR_STREAM_BUFFERED, "OL-1228");
        assert_eq!(ERR_NEGOTIATE_NO_TICKET, "OL-1229");
        // 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");
        assert_eq!(ERR_TAMPER_DETECTED, "OL-1906");
        // Configuration plane
        assert_eq!(ERR_INVENTORY_DISABLED, "OL-2008");
    }

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