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
//! Typed event constructors for the v1 PostHog event catalog.
//!
//! Each variant maps to a snake_case, object_action PostHog event name.
//! Phase A defines the shape and constructors only — actual wiring to call
//! sites lands in Phase B (Task 2). Super-properties are attached by the
//! client wrapper, not duplicated here.
//!
//! See `.brainstorms/2026-04-13-posthog-client-telemetry.md §5` for the full
//! catalog.

use serde_json::{json, Map, Value};

/// A PostHog event ready to be wrapped with super-properties and enqueued.
#[derive(Debug, Clone)]
pub struct Event {
    /// PostHog event name (e.g. `"cli_initialized"`).
    pub name: String,
    /// Event-specific properties. Super-properties are merged in by the client.
    pub properties: Map<String, Value>,
}

impl Event {
    fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            properties: Map::new(),
        }
    }

    fn with(mut self, key: &str, value: Value) -> Self {
        self.properties.insert(key.to_string(), value);
        self
    }

    // ---- Lifecycle ----

    pub fn cli_initialized(
        agent_detected: &str,
        hooks_installed_count: usize,
        first_run: bool,
    ) -> Self {
        Self::new("cli_initialized")
            .with("agent_detected", json!(agent_detected))
            .with("hooks_installed_count", json!(hooks_installed_count))
            .with("first_run", json!(first_run))
    }

    pub fn auth_completed(auth_method: &str, duration_ms: u64) -> Self {
        Self::new("auth_completed")
            .with("auth_method", json!(auth_method))
            .with("duration_ms", json!(duration_ms))
    }

    pub fn auth_failed(error_code: &str, stage: &str) -> Self {
        Self::new("auth_failed")
            .with("error_code", json!(error_code))
            .with("stage", json!(stage))
    }

    pub fn uninstalled(agents_removed_count: usize) -> Self {
        Self::new("uninstalled").with("agents_removed_count", json!(agents_removed_count))
    }

    /// Recorded after `openlatch init` and `openlatch supervision install` decide
    /// the daemon's persistence posture. All fields are low-cardinality enums or
    /// a short reason string — never a path or credential.
    ///
    /// - `backend`: `launchd` | `systemd` | `task_scheduler` | `none`
    /// - `mode`: `active` | `deferred` | `disabled`
    /// - `deferred_reason`: populated only when `mode != active`
    ///   (`user_opt_out`, `foreground_session`, `no_start`, `unsupported_os`,
    ///   or the `OL-XXXX ...` message when the OS install failed).
    pub fn supervision_installed(backend: &str, mode: &str, deferred_reason: Option<&str>) -> Self {
        let mut e = Self::new("supervision_installed")
            .with("backend", json!(backend))
            .with("mode", json!(mode));
        if let Some(r) = deferred_reason {
            e = e.with("deferred_reason", json!(r));
        }
        e
    }

    // ---- Wire-format unknown-variant signals ----
    //
    // The CloudEvents envelope (v1.0.2) uses open strings for `source` and
    // `type`. When the client observes a string it does not yet have a named
    // Rust variant for, emit one of these events so we can see which new
    // agents / hook events to promote to named variants in the next release.
    // Payload is the raw wire string only — anonymised, no PII.

    pub fn hook_source_unknown(source: &str) -> Self {
        Self::new("hook_source_unknown").with("source", json!(source))
    }

    pub fn hook_type_unknown(type_str: &str) -> Self {
        Self::new("hook_type_unknown").with("type", json!(type_str))
    }

    // ---- Daemon runtime ----

    pub fn daemon_started(port: u16, startup_ms: u64, cloud_enabled: bool) -> Self {
        Self::new("daemon_started")
            .with("port", json!(port))
            .with("startup_ms", json!(startup_ms))
            .with("cloud_enabled", json!(cloud_enabled))
    }

    pub fn daemon_stopped(uptime_seconds: u64, events_processed_total: u64) -> Self {
        Self::new("daemon_stopped")
            .with("uptime_seconds", json!(uptime_seconds))
            .with("events_processed_total", json!(events_processed_total))
    }

    /// `panic_location` must be `file:line` only — never the panic message or
    /// backtrace, which can leak interpolated user values (§5.3).
    pub fn daemon_crashed(panic_location: &str, uptime_seconds: u64) -> Self {
        Self::new("daemon_crashed")
            .with("panic_location", json!(panic_location))
            .with("uptime_seconds", json!(uptime_seconds))
    }

    // ---- Command usage ----

    /// Constructs a `command_invoked` event. Callers are responsible for
    /// skipping `--help` / `--version` invocations upstream (per plan).
    /// Flag values and argument text must never be passed in.
    pub fn command_invoked(
        command: &str,
        subcommand: Option<&str>,
        exit_code: i32,
        duration_ms: u64,
    ) -> Self {
        let mut e = Self::new("command_invoked")
            .with("command", json!(command))
            .with("exit_code", json!(exit_code))
            .with("duration_ms", json!(duration_ms));
        if let Some(s) = subcommand {
            e = e.with("subcommand", json!(s));
        }
        e
    }

    // ---- Hook activity (aggregated) ----

    pub fn hooks_processed(rollup: Value, window_seconds: u64) -> Self {
        Self::new("hooks_processed")
            .with("rollup", rollup)
            .with("window_seconds", json!(window_seconds))
    }

    pub fn cloud_error(
        error_code: &str,
        http_status: Option<u16>,
        retry_count: u32,
        latency_ms: u64,
    ) -> Self {
        let mut e = Self::new("cloud_error")
            .with("error_code", json!(error_code))
            .with("retry_count", json!(retry_count))
            .with("latency_ms", json!(latency_ms));
        if let Some(s) = http_status {
            e = e.with("http_status", json!(s));
        }
        e
    }

    // ---- Tamper detection (aggregates only) ----
    //
    // Emitted by the reconciler when a tracked hook entry drifts from its
    // install-time HMAC, and again when the self-heal attempt resolves.
    // Payload is strictly aggregate context — no `entry_id`, no paths, no
    // field names. The OCSF `TamperEvent` shipped through the cloud worker
    // carries the full forensic detail; PostHog only sees counts by shape.

    pub fn tamper_detected(detection_method: &str, agent_type: &str) -> Self {
        Self::new("tamper_detected")
            .with("detection_method", json!(detection_method))
            .with("agent_type", json!(agent_type))
    }

    pub fn tamper_healed(
        detection_method: &str,
        agent_type: &str,
        outcome: &str,
        attempt: u32,
    ) -> Self {
        Self::new("tamper_healed")
            .with("detection_method", json!(detection_method))
            .with("agent_type", json!(agent_type))
            .with("outcome", json!(outcome))
            .with("attempt", json!(attempt))
    }

    // ---- Durable outbox / fallback replay (aggregates only) ----
    //
    // Emitted by the cloud worker when events fall back to the on-disk
    // outbox because live delivery failed, and by the fallback-replay task
    // when it clears hook-written entries. Payload is strictly aggregate:
    // counts and the failure category. No envelope content, no paths.

    /// An event was written to `~/.openlatch/outbox.jsonl` because the
    /// cloud retry failed. `reason` ∈ `network` / `server_error` /
    /// `rate_limit` / `auth_error` / `no_credential`.
    pub fn cloud_event_spooled(reason: &str) -> Self {
        Self::new("cloud_event_spooled").with("reason", json!(reason))
    }

    /// The outbox drain task completed one pass. Reports how many entries
    /// were successfully replayed, how many remain (failed), how many were
    /// discarded as corrupt, and how many were quarantined after repeated
    /// failures (entry-level poison-pill protection).
    pub fn cloud_outbox_drained(drained: u64, failed: u64, corrupt: u64, quarantined: u64) -> Self {
        Self::new("cloud_outbox_drained")
            .with("drained", json!(drained))
            .with("failed", json!(failed))
            .with("corrupt", json!(corrupt))
            .with("quarantined", json!(quarantined))
    }

    /// The outbox hit its size cap and dropped the oldest entries to stay
    /// under `cloud.outbox_max_bytes`.
    pub fn cloud_outbox_overflow(dropped: u64, size_before: u64, max_bytes: u64) -> Self {
        Self::new("cloud_outbox_overflow")
            .with("dropped", json!(dropped))
            .with("size_before", json!(size_before))
            .with("max_bytes", json!(max_bytes))
    }

    /// The fallback-replay task started a pass. `pending` is the count of
    /// entries visible in `fallback.jsonl` at the start of the pass.
    pub fn fallback_replay_started(pending: u64) -> Self {
        Self::new("fallback_replay_started").with("pending", json!(pending))
    }

    /// The fallback-replay task completed a pass.
    pub fn fallback_replay_completed(replayed: u64, corrupt: u64, remaining: u64) -> Self {
        Self::new("fallback_replay_completed")
            .with("replayed", json!(replayed))
            .with("corrupt", json!(corrupt))
            .with("remaining", json!(remaining))
    }

    /// `fallback.jsonl` hit its size cap and dropped the oldest entries
    /// (via offset advancement) to stay under `cloud.fallback_max_bytes`.
    /// Mirrors `cloud_outbox_overflow` byte-for-byte so dashboards can
    /// chart the two side-by-side.
    pub fn fallback_overflow(dropped: u64, size_before: u64, max_bytes: u64) -> Self {
        Self::new("fallback_overflow")
            .with("dropped", json!(dropped))
            .with("size_before", json!(size_before))
            .with("max_bytes", json!(max_bytes))
    }

    /// The cloud forwarder transitioned into or out of emergency drop mode
    /// due to sustained live-event channel saturation. `action` ∈
    /// `enter` / `exit`. `trigger` ∈ `live_drops` / `channel_high_water`
    /// distinguishes the two engage paths (hard drops vs. sustained
    /// channel pressure detected before drops). Aggregate context only —
    /// drops_in_window, window_duration_ms, channel_depth, channel_size —
    /// never envelope content, paths, or session identifiers.
    pub fn cloud_channel_overflow_emergency(
        action: &str,
        trigger: &str,
        drops_in_window: u64,
        window_duration_ms: u64,
        channel_depth: usize,
        channel_size: usize,
    ) -> Self {
        Self::new("cloud_channel_overflow_emergency")
            .with("action", json!(action))
            .with("trigger", json!(trigger))
            .with("drops_in_window", json!(drops_in_window))
            .with("window_duration_ms", json!(window_duration_ms))
            .with("channel_depth", json!(channel_depth))
            .with("channel_size", json!(channel_size))
    }

    // ---- Doctor (--fix / --restore / --rescue) ----
    //
    // Aggregates only — never paths, file contents, or matched credential
    // text. agent_id is already a super-property and must not be re-attached.

    /// Recorded once per `openlatch doctor --fix` run.
    #[allow(clippy::too_many_arguments)] // every field is intentional telemetry context
    pub fn doctor_fix_run(
        categories_selected: Vec<&str>,
        checks_before_pass: usize,
        checks_before_fail: usize,
        checks_after_pass: usize,
        checks_after_fail: usize,
        unfixable_codes: Vec<&str>,
        duration_ms: u64,
        auto_rollback_triggered: bool,
    ) -> Self {
        Self::new("doctor_fix_run")
            .with("categories_selected", json!(categories_selected))
            .with(
                "checks_before",
                json!({ "pass": checks_before_pass, "fail": checks_before_fail }),
            )
            .with(
                "checks_after",
                json!({ "pass": checks_after_pass, "fail": checks_after_fail }),
            )
            .with("unfixable_codes", json!(unfixable_codes))
            .with("duration_ms", json!(duration_ms))
            .with("auto_rollback_triggered", json!(auto_rollback_triggered))
    }

    /// Recorded once per user-initiated `openlatch doctor --restore`.
    pub fn doctor_restore_run(
        actions_reversed: usize,
        actions_skipped: usize,
        daemon_restart_required: bool,
        duration_ms: u64,
    ) -> Self {
        Self::new("doctor_restore_run")
            .with("actions_reversed", json!(actions_reversed))
            .with("actions_skipped", json!(actions_skipped))
            .with("daemon_restart_required", json!(daemon_restart_required))
            .with("duration_ms", json!(duration_ms))
    }

    // ---- Auto-update (P2 manual) ----
    //
    // Aggregates only — version strings are public and low-cardinality.
    // No tarball URL, no path, no SRI string. The 5 events span the full
    // P2 surface so we can detect (a) registry health, (b) successful
    // applies, (c) minisign verify failures (security signal), and
    // (d) the long-tail of clients stuck below min_supported_client.

    /// Recorded once per `update::check` call. `outcome` ∈
    /// `up_to_date` | `available` | `failed`. `latest` and `severity` are
    /// populated only on `available`.
    pub fn update_check(outcome: &str, latest: Option<&str>, severity: Option<&str>) -> Self {
        let mut e = Self::new("update_check").with("outcome", json!(outcome));
        if let Some(l) = latest {
            e = e.with("latest", json!(l));
        }
        if let Some(s) = severity {
            e = e.with("severity", json!(s));
        }
        e
    }

    /// Apply pipeline begun. `mode` ∈ `rpc` (CLI → daemon) | `in_process`
    /// (CLI → swap directly because no daemon was running).
    pub fn update_started(from: &str, to: &str, severity: &str, mode: &str) -> Self {
        Self::new("update_started")
            .with("from_version", json!(from))
            .with("to_version", json!(to))
            .with("severity", json!(severity))
            .with("mode", json!(mode))
    }

    /// Apply pipeline reached terminal state. `success = false` covers
    /// both clean failures (download / verify / sanity / swap stage
    /// errored) and the rollback path. `rolled_back = true` is set
    /// only by the supervisor-restart-loop rollback in `main()` —
    /// anywhere else `success` already conveys the outcome.
    ///
    /// `duration_ms` is `None` from the rollback path: the new daemon's
    /// `main()` runs before logging is initialised and has no record of
    /// when the bad apply began. Every other call site still passes a
    /// real measurement.
    pub fn update_completed(
        from: &str,
        to: &str,
        severity: &str,
        mode: &str,
        success: bool,
        duration_ms: Option<u64>,
        rolled_back: bool,
    ) -> Self {
        let mut e = Self::new("update_completed")
            .with("from_version", json!(from))
            .with("to_version", json!(to))
            .with("severity", json!(severity))
            .with("mode", json!(mode))
            .with("success", json!(success))
            .with("rolled_back", json!(rolled_back));
        if let Some(ms) = duration_ms {
            e = e.with("duration_ms", json!(ms));
        }
        e
    }

    /// Minisign verification failed for either the daemon or hook
    /// binary. Distinct event from `update_check_failed` because this is
    /// a security signal — the bytes were downloaded but the signature
    /// did not match a baked trusted key.
    pub fn update_signature_failed(from: &str, to: &str, binary: &str) -> Self {
        Self::new("update_signature_failed")
            .with("from_version", json!(from))
            .with("to_version", json!(to))
            .with("binary", json!(binary))
    }

    /// `min_supported_client` declared by the new release is greater
    /// than the running client — apply was refused. Surfaces the
    /// long-tail of clients that need a manual `npm install -g`.
    pub fn update_blocked_by_min_supported(from: &str, latest: &str, min_supported: &str) -> Self {
        Self::new("update_blocked_by_min_supported")
            .with("from_version", json!(from))
            .with("latest", json!(latest))
            .with("min_supported", json!(min_supported))
    }

    // ---- Configuration plane monitoring (aggregates only) ----
    //
    // Emitted by src/daemon/config_monitor/. Payload is strictly aggregate
    // — never includes file content, paths, source identifiers, agent_id,
    // or session_id. Used to size the watcher footprint and to spot
    // anomalies in change-detection or alert-delivery health.

    /// A manifest-tracked config file changed (FS watcher or native hook).
    /// `change_type` ∈ `added` | `modified` | `removed`.
    pub fn config_change_detected(
        kind: &str,
        severity: &str,
        agent_type: &str,
        change_type: &str,
    ) -> Self {
        Self::new("config_change_detected")
            .with("kind", json!(kind))
            .with("severity", json!(severity))
            .with("agent_type", json!(agent_type))
            .with("change_type", json!(change_type))
    }

    /// Initial inventory walk started (one per agent, on daemon startup).
    pub fn config_initial_scan_started(agent_type: &str) -> Self {
        Self::new("config_initial_scan_started").with("agent_type", json!(agent_type))
    }

    /// Initial inventory walk completed.
    pub fn config_initial_scan_completed(
        agent_type: &str,
        items_emitted: usize,
        duration_ms: u64,
    ) -> Self {
        Self::new("config_initial_scan_completed")
            .with("agent_type", json!(agent_type))
            .with("items_emitted", json!(items_emitted))
            .with("duration_ms", json!(duration_ms))
    }

    /// Periodic 12h rescan (or drain_notify recovery rescan) completed.
    pub fn config_periodic_rescan_completed(
        agent_type: &str,
        items_observed: usize,
        items_changed: usize,
        duration_ms: u64,
    ) -> Self {
        Self::new("config_periodic_rescan_completed")
            .with("agent_type", json!(agent_type))
            .with("items_observed", json!(items_observed))
            .with("items_changed", json!(items_changed))
            .with("duration_ms", json!(duration_ms))
    }

    /// Filesystem watcher init failed for a path. `os_error_class` ∈
    /// `enospc` | `not_found` | `io` | `other`.
    pub fn config_watcher_init_failed(os_error_class: &str, path_kind: Option<&str>) -> Self {
        let mut e =
            Self::new("config_watcher_init_failed").with("os_error_class", json!(os_error_class));
        if let Some(k) = path_kind {
            e = e.with("path_kind", json!(k));
        }
        e
    }

    /// SessionStart event was enriched with `openlatch.declared_*` fields
    /// (P2 surface — the constructor lands here in P1 to keep the catalog
    /// in one place; the call site is wired by P2).
    /// `declared_count_bucket` ∈ `1-5` | `6-20` | `21-50` | `50+`.
    pub fn config_session_start_enriched(agent_type: &str, declared_count_bucket: &str) -> Self {
        Self::new("config_session_start_enriched")
            .with("agent_type", json!(agent_type))
            .with("declared_count_buckets", json!(declared_count_bucket))
    }

    /// A pending alert was fetched from the cloud's long-poll endpoint
    /// (P2 surface — see SessionStart enriched above).
    pub fn config_pending_alert_received(severity: &str, agent_type: &str) -> Self {
        Self::new("config_pending_alert_received")
            .with("severity", json!(severity))
            .with("agent_type", json!(agent_type))
    }

    /// A pending alert was surfaced via the agent's hook output translator
    /// (P2 surface). `surface` ∈ `session_start_context` | `pretooluse_reason`.
    pub fn config_alert_surfaced_in_session(severity: &str, surface: &str) -> Self {
        Self::new("config_alert_surfaced_in_session")
            .with("severity", json!(severity))
            .with("surface", json!(surface))
    }

    /// Recorded once per `openlatch doctor --rescue` run. `agents_found`
    /// carries kebab-case agent identifiers only (no file paths). Hit
    /// counts are aggregate across all redactor patterns; per-pattern
    /// breakdown lives in the bundled MANIFEST, not in telemetry.
    #[allow(clippy::too_many_arguments)] // every field is intentional telemetry context
    pub fn doctor_rescue_run(
        archive_size_bytes: u64,
        files_collected_count: usize,
        daemon_reachable: bool,
        agents_found: Vec<&str>,
        redactor_hits_total: u64,
        duration_ms: u64,
        fix_applied_after: bool,
    ) -> Self {
        Self::new("doctor_rescue_run")
            .with("archive_size_bytes", json!(archive_size_bytes))
            .with("files_collected_count", json!(files_collected_count))
            .with("daemon_reachable", json!(daemon_reachable))
            .with("agents_found", json!(agents_found))
            .with("redactor_hits_total", json!(redactor_hits_total))
            .with("duration_ms", json!(duration_ms))
            .with("fix_applied_after", json!(fix_applied_after))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cli_initialized_has_required_props() {
        let e = Event::cli_initialized("claude-code", 3, true);
        assert_eq!(e.name, "cli_initialized");
        assert_eq!(e.properties["agent_detected"], "claude-code");
        assert_eq!(e.properties["hooks_installed_count"], 3);
        assert_eq!(e.properties["first_run"], true);
    }

    #[test]
    fn test_command_invoked_omits_subcommand_when_none() {
        let e = Event::command_invoked("status", None, 0, 12);
        assert!(!e.properties.contains_key("subcommand"));
    }

    #[test]
    fn test_command_invoked_includes_subcommand_when_some() {
        let e = Event::command_invoked("auth", Some("login"), 0, 42);
        assert_eq!(e.properties["subcommand"], "login");
    }

    #[test]
    fn test_doctor_fix_run_includes_aggregate_counts_only() {
        let e = Event::doctor_fix_run(vec!["state", "hooks"], 3, 3, 6, 0, vec![], 1234, false);
        assert_eq!(e.name, "doctor_fix_run");
        assert_eq!(e.properties["checks_before"]["pass"], 3);
        assert_eq!(e.properties["checks_after"]["fail"], 0);
        assert_eq!(e.properties["auto_rollback_triggered"], false);
        assert_eq!(e.properties["duration_ms"], 1234);
    }

    #[test]
    fn test_doctor_rescue_run_carries_aggregate_redactor_hits_only() {
        let e = Event::doctor_rescue_run(1_234_567, 12, true, vec!["claude-code"], 42, 3000, false);
        assert_eq!(e.name, "doctor_rescue_run");
        assert_eq!(e.properties["archive_size_bytes"], 1_234_567);
        assert_eq!(e.properties["redactor_hits_total"], 42);
        // No per-pattern breakdown leaks into telemetry.
        assert!(!e.properties.contains_key("redactor_hits_by_pattern"));
    }

    #[test]
    fn test_supervision_installed_omits_reason_when_active() {
        let e = Event::supervision_installed("launchd", "active", None);
        assert_eq!(e.name, "supervision_installed");
        assert_eq!(e.properties["backend"], "launchd");
        assert_eq!(e.properties["mode"], "active");
        assert!(!e.properties.contains_key("deferred_reason"));
    }

    #[test]
    fn test_supervision_installed_includes_reason_when_deferred() {
        let e = Event::supervision_installed("task_scheduler", "deferred", Some("user_opt_out"));
        assert_eq!(e.properties["mode"], "deferred");
        assert_eq!(e.properties["deferred_reason"], "user_opt_out");
    }

    #[test]
    fn test_daemon_crashed_accepts_file_line_only() {
        // The constructor can't validate format, but we assert the contract:
        // callers pass file:line.
        let e = Event::daemon_crashed("src/daemon/mod.rs:214", 3600);
        let loc = e.properties["panic_location"].as_str().unwrap();
        assert!(loc.contains(':'));
        assert!(!loc.contains("panic at"));
    }

    #[test]
    fn test_tamper_detected_carries_aggregates_only() {
        let e = Event::tamper_detected("hmac_mismatch", "claude-code");
        assert_eq!(e.name, "tamper_detected");
        assert_eq!(e.properties["detection_method"], "hmac_mismatch");
        assert_eq!(e.properties["agent_type"], "claude-code");
        // No entry IDs, paths, or forensic detail in the telemetry event —
        // the OCSF CloudEvent carries that to the platform instead.
        assert!(!e.properties.contains_key("entry_id"));
        assert!(!e.properties.contains_key("settings_path_hash"));
        assert!(!e.properties.contains_key("field_deltas"));
    }

    #[test]
    fn test_fallback_overflow_mirrors_outbox_overflow_shape() {
        let e = Event::fallback_overflow(42, 60_000_000, 50_000_000);
        assert_eq!(e.name, "fallback_overflow");
        assert_eq!(e.properties["dropped"], 42);
        assert_eq!(e.properties["size_before"], 60_000_000);
        assert_eq!(e.properties["max_bytes"], 50_000_000);
    }

    #[test]
    fn test_cloud_channel_overflow_emergency_carries_aggregate_context() {
        let e =
            Event::cloud_channel_overflow_emergency("enter", "live_drops", 120, 11_500, 1000, 1000);
        assert_eq!(e.name, "cloud_channel_overflow_emergency");
        assert_eq!(e.properties["action"], "enter");
        assert_eq!(e.properties["trigger"], "live_drops");
        assert_eq!(e.properties["drops_in_window"], 120);
        assert_eq!(e.properties["window_duration_ms"], 11_500);
        assert_eq!(e.properties["channel_depth"], 1000);
        assert_eq!(e.properties["channel_size"], 1000);
    }

    #[test]
    fn test_tamper_healed_includes_outcome_and_attempt() {
        let e = Event::tamper_healed("hmac_mismatch", "claude-code", "succeeded", 2);
        assert_eq!(e.name, "tamper_healed");
        assert_eq!(e.properties["outcome"], "succeeded");
        assert_eq!(e.properties["attempt"], 2);
        assert_eq!(e.properties["detection_method"], "hmac_mismatch");
    }
}