openlatch-client 0.5.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
// `openlatch` is a CONSOLE-subsystem binary on every platform and in every
// profile. There is no `windows_subsystem = "windows"` attribute here, and
// `ci/check-portability.py` fails the build if one comes back.
//
// Windows release builds carried that attribute between 2026-04-24 and this
// commit, to stop Task Scheduler allocating a console window at logon. It cost
// far more than it bought, because the subsystem byte in the PE header is read
// by the LAUNCHER, before the process exists:
//
//   * `cmd` and PowerShell do not WAIT for a GUI-subsystem child. `& openlatch
//     init` returned instantly, `$LASTEXITCODE` was whatever came before it,
//     and `install.ps1` reported a successful install over enrolment that had
//     not started. Every exit code this CLI documents — `doctor`'s 7 above all
//     — was unobservable from any Windows script or CI job.
//   * A GUI-subsystem process is not ATTACHED to the parent console, so no
//     keystroke and no `CTRL_C_EVENT` ever reached it. Every interactive
//     prompt (telemetry consent, the proxy prompts in `cli::prompt`,
//     `uninstall`'s confirmation) rendered and could never be answered, and
//     the only way out was to kill the process from another shell.
//   * Writing still worked, because writes go to inherited handles and need no
//     attachment — so the failure looked like a hang rather than a wiring bug,
//     and `IsTerminal` answered TRUE on the very handle that could not be read.
//
// `AttachConsole(ATTACH_PARENT_PROCESS)` used to sit in `main` to paper over
// the second point. It cannot fix the first by construction, and it did not
// fix the second either: by the time it runs, the launcher has already moved
// on. The logon window is kept away in two layers instead. On Windows 11 24H2
// and Server 2025 it never exists: `openlatch.exe.manifest`, embedded by
// build.rs, sets the `detached` console allocation policy, so Windows creates no
// console for a launch that has none to inherit, while a shell launch still
// inherits the shell's. Older builds ignore the manifest and allocate one, and
// `release_unattended_console` below closes it — after the fact, where a
// decision about THIS process can be made from what it can observe about
// itself. That leaves a brief flash at logon on those builds, accepted.
use clap::Parser;

use openlatch_client::cli;
use openlatch_client::cli::Commands;
use openlatch_client::error::OlError;
use openlatch_client::telemetry::{self, Event};
use openlatch_client::update;

/// Outcome of the supervisor-restart-loop rollback path. Captured at
/// the very top of `main()` BEFORE telemetry / logging init, then
/// flushed out as a `update_completed` event once telemetry is alive.
/// `eprintln!` and stdlib I/O are the only outputs allowed during the
/// rollback window — `tracing::warn!` would silently no-op (no
/// subscriber yet) and `telemetry::capture_global` would no-op (handle
/// not installed yet).
enum RollbackEvent {
    /// `.bak` siblings were renamed back; sentinel cleared. Carries the
    /// `from`/`to` versions read out of the sentinel before it was
    /// cleared so the deferred telemetry event lands with real strings
    /// instead of `"(unknown)"` placeholders.
    Success { from: String, to: String },
    /// The rollback failed mid-flight. The daemon continues startup
    /// with whatever binary is on disk — typically the crash-looping
    /// new binary, which the supervisor will restart again. The reason
    /// has already been printed to stderr; nothing further to do.
    Failed,
}

/// On Windows, close a console this process was handed and nobody is driving.
///
/// Task Scheduler launches the supervised daemon at logon. On Windows before
/// 24H2, which ignores the `detached` policy in `openlatch.exe.manifest`, a
/// console-subsystem binary started that way gets a console allocated FOR it —
/// `<Hidden>true</Hidden>` in the task XML hides the task from the scheduler's
/// list, never the window — so without this the user would keep a console window
/// for as long as the daemon runs. Avoiding that window is the whole reason the
/// `windows` subsystem was adopted, and this is the same outcome bought without
/// breaking every shell that runs the CLI. On 24H2 and later there is no console
/// here to close.
///
/// Two conditions, and both are load-bearing:
///
/// * **The daemon foreground entry only.** Every other invocation is a CLI
///   command whose output is the point. `update::should_rollback`'s gate reads
///   argv the same way, for the same reason — the supervisor's child is the one
///   process on the machine with nobody watching it.
/// * **This process is the console's only member.** A shell that launched us is
///   attached to the same console, so a count above one means the console was
///   INHERITED and closing it would take the user's terminal with it. Exactly
///   one means the OS allocated it for us alone. A zero is a failed query or a
///   `CREATE_NO_WINDOW` child that has no console at all: both leave it alone.
///
/// Freeing the console invalidates the std handles without clearing them, and a
/// write to a stale handle is an `Err` that `eprintln!` turns into a panic — so
/// the handles that pointed at it are nulled straight after. Rust's Windows
/// stdio treats a null handle as a black hole and reports the write as
/// complete, which is exactly how this path behaved under the `windows`
/// subsystem, where there was no console and no handles to begin with.
///
/// Only the CONSOLE handles are nulled, established before the console goes
/// away because afterwards a dangling handle and a file handle are
/// indistinguishable. Today the supervisor redirects nothing, so all three are
/// console handles and the distinction is academic — but a task action that
/// grows a redirect must not have the daemon's log silently dropped into a
/// nulled handle by this function.
#[cfg(windows)]
fn release_unattended_console() {
    use winapi::um::consoleapi::GetConsoleMode;
    use winapi::um::processenv::{GetStdHandle, SetStdHandle};
    use winapi::um::winbase::{STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE};
    use winapi::um::wincon::{FreeConsole, GetConsoleProcessList};

    if !std::env::args().any(|a| a == "--foreground") {
        return;
    }

    // Safety: FFI call with a caller-owned buffer and its true length. The
    // return is the number of processes attached to this console; it may
    // exceed the buffer, in which case the buffer contents are undefined and
    // the count is still valid — and a count that large is a `> 1` answer.
    let mut attached_pids: [u32; 2] = [0; 2];
    let attached =
        unsafe { GetConsoleProcessList(attached_pids.as_mut_ptr(), attached_pids.len() as u32) };
    if attached != 1 {
        return;
    }

    const STD_IDS: [u32; 3] = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE];
    // Safety: FFI calls taking a std-handle id and, for `GetConsoleMode`, a
    // caller-owned `DWORD`. `GetConsoleMode` succeeding is the definition of
    // "this handle is a console handle"; it fails for a file, a pipe and a
    // null alike, all of which survive `FreeConsole` untouched.
    let is_console: [bool; 3] = STD_IDS.map(|id| unsafe {
        let handle = GetStdHandle(id);
        let mut mode = 0;
        GetConsoleMode(handle, &mut mode) != 0
    });

    // Safety: FFI calls with no arguments beyond well-known constants. A
    // `FreeConsole` failure leaves the console open, which is the behaviour
    // this function exists to improve on rather than to depend on — and leaves
    // the handles valid, so nothing may be nulled after it.
    unsafe {
        if FreeConsole() == 0 {
            return;
        }
        for (id, was_console) in STD_IDS.iter().zip(is_console) {
            if was_console {
                let _ = SetStdHandle(*id, std::ptr::null_mut());
            }
        }
    }
}

#[cfg(not(windows))]
fn release_unattended_console() {}

fn main() {
    release_unattended_console();

    // `openlatch system evaluate` must produce a verdict on a box holding
    // nothing but the binary — no enrolment, no credential, no daemon, no
    // configuration file (spec §3, and `ci/check-engine-purity.py` scans the
    // command module for it). Everything below this point breaks that before
    // argv is even parsed: `init_crash_report` and `init_telemetry` resolve the
    // data directory and read config, and a keychain prompt on a headless box
    // is a hang, not an error. So the subcommand is answered HERE, above all of
    // it — and so is the root `evaluate` alias, the same command for one more
    // release.
    //
    // `try_parse` rather than an argv sniff, so `openlatch --json system
    // evaluate` is the same command as `openlatch system evaluate` and
    // `openlatch logs -f evaluate` is not. A parse that fails — `--help`, a bad
    // flag — falls through and is reported by the normal path below, which
    // re-parses.
    if let Ok(cli::Cli {
        command:
            Some(
                Commands::System {
                    cmd: cli::SystemCommands::Evaluate(args),
                }
                | Commands::Evaluate(args),
            ),
        ..
    }) = <cli::Cli as clap::Parser>::try_parse()
    {
        std::process::exit(openlatch_client::cli::commands::evaluate::run(&args));
    }

    // Install the process-default rustls CryptoProvider before anything can build an
    // HTTP client. `install_default()` wins at most once per process, so it has to run
    // from a single deterministic point rather than lazily from whichever supervised
    // task happens to construct its client first.
    openlatch_client::egress::init_crypto();

    // Supervisor-restart-loop rollback. Runs BEFORE logging /
    // telemetry init — no subscriber, no PostHog handle yet — so output
    // goes to stderr only. The sentinel is read first so the deferred
    // telemetry event below carries real `from`/`to` versions even
    // though `rollback_from_bak` clears the sentinel as part of its
    // cleanup.
    //
    // Gated on the daemon-foreground argv. Every supervisor template
    // (launchd / systemd / Task Scheduler) and every internal spawn
    // path invokes `daemon start --foreground`; user-facing CLI
    // commands never do. Without this gate, three quick CLI calls
    // (`update`, `status`, `--version`) inside the post-swap window
    // where sentinel + `<exe>.bak` coexist would trip the threshold
    // and silently downgrade the on-disk binary.
    let is_daemon_entry = std::env::args().any(|a| a == "--foreground");
    let rollback_event = if is_daemon_entry && update::should_rollback() {
        let (from, to) = update::read_sentinel()
            .map(|s| (s.from, s.to))
            .unwrap_or_else(|| ("(unknown)".into(), "(unknown)".into()));
        match update::rollback_from_bak() {
            Ok(()) => {
                eprintln!("[openlatch] rolled back update due to supervisor restart loop");
                Some(RollbackEvent::Success { from, to })
            }
            Err(e) => {
                eprintln!("[openlatch] rollback failed: {e}");
                Some(RollbackEvent::Failed)
            }
        }
    } else {
        None
    };

    // CLI-09: Set SIGINT handler to ensure exit code 130.
    //
    // There is no crash-report flush here any more, and its absence is deliberate: the
    // panic path POSTs synchronously before it returns, so by the time any exit runs
    // there is nothing buffered to lose. The client this replaced batched in the
    // background, which is what made a flush-before-exit necessary at all.
    let _ = ctrlc::set_handler(|| {
        std::process::exit(130);
    });

    // Crash reporting first — captures panics in everything below, including
    // runtime construction and the detached daemon child process which
    // re-enters this main() via `daemon start --foreground`. There is no guard to
    // hold: the panic path sends synchronously, so nothing outlives the hook. No-op
    // when the `crash-report` feature is disabled or consent resolves to Disabled.
    #[cfg(feature = "crash-report")]
    let _ = init_crash_report();

    // Telemetry: initialise the process-global handle and install a panic hook.
    // Both are no-ops when consent is disabled or no key is baked. Phase A's
    // init never spawns a network task; Task 3 will activate the POST path.
    // Note: `init_crash_report` above already installed the crash-report hook; this
    // one chains through `prev(info)` so both fire. The ordering constraint is
    // unchanged — the crash hook must be installed first so it sits closest to the
    // default hook that prints the message.
    init_telemetry();
    install_panic_hook();

    // Deferred rollback telemetry. Now that the global handle is
    // installed, surface the rollback outcome captured above. The
    // failed-rollback path is silent — the daemon will likely keep
    // crash-looping and the operator-visible signal lands via crash-report
    // panics rather than a successful update_completed event.
    if let Some(RollbackEvent::Success { from, to }) = rollback_event {
        telemetry::capture_global(Event::update_completed(
            &from,
            &to,
            "normal",
            "in_process",
            /* success */ false,
            /* duration_ms */ None,
            /* rolled_back */ true,
        ));
    }

    let cli_args = cli::Cli::parse();
    let output = cli::build_output_config(&cli_args);

    // No subcommand → print banner + help and exit 0. Matches `--help` UX but
    // routes through our color-aware output path so `--no-color`/non-TTY gets
    // the plain banner while TTY gets the ANSI version.
    let Some(command) = cli_args.command.as_ref() else {
        use clap::CommandFactory;
        cli::header::print_full_banner(&output);
        let _ = cli::Cli::command().print_help();
        println!();
        return;
    };

    let started_at = std::time::Instant::now();
    let (command_label, subcommand_label) = command_labels(command);

    // Tag the current process as a CLI invocation. `run_daemon_foreground`
    // overwrites `process_type` to "daemon" before any daemon work runs,
    // when this main() is reached via `daemon start --foreground`.
    #[cfg(feature = "crash-report")]
    telemetry::crash::set_cli_scope(command_label);

    let result = dispatch(command, &output);

    // The error decides its own status (`OlError::exit_code`) instead of every
    // failure collapsing into 1. Supervisors read this number: exit 5 is how a
    // supervised `daemon start --foreground` tells systemd "another daemon
    // already holds this machine, do not restart me", and telemetry now records
    // the status the process actually returns rather than a stand-in for it.
    // A command can succeed at running and still need to report that the
    // machine is degraded — `doctor` finding the model relay switched off is
    // not an `OlError`, but it must not exit 0 either, or `openlatch doctor &&
    // deploy` ships with enforcement disabled. Diagnostics record that verdict
    // through `cli::report`; errors still decide their own status.
    let exit_code = result
        .as_ref()
        .err()
        .map_or_else(cli::report::pending_exit_code, OlError::exit_code);
    let duration_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
    telemetry::capture_global(Event::command_invoked(
        command_label,
        subcommand_label,
        exit_code,
        duration_ms,
    ));

    if let Err(e) = result {
        output.print_error(&e);
        std::process::exit(exit_code);
    }
    // Succeeded, but a diagnostic asked for a non-zero status. No `print_error`
    // here on purpose: the rendering already said what is wrong, in the shape
    // the operator asked for, and an "Error:" line on top of a warning report
    // would misdescribe it.
    if exit_code != 0 {
        std::process::exit(exit_code);
    }
}

/// Install the crash-report panic hook. Returns `false` when disabled by any consent
/// rule, in which case no hook is installed and nothing is ever sent.
///
/// Must run before any tokio runtime is built and before clap parses arguments —
/// otherwise a panic during runtime construction or arg parsing is not captured.
#[cfg(feature = "crash-report")]
fn init_crash_report() -> bool {
    let dir = openlatch_client::config::openlatch_dir();
    // The crash path takes the same proxy route, CA bundle and TLS posture as every
    // other outbound request, and the same ingest host as product telemetry.
    let (egress, api_url) = telemetry_transport();
    let host = telemetry::network::resolve_host(&api_url);
    match telemetry::crash::CrashConfig::resolve(&dir, egress, host) {
        Some(cfg) => telemetry::crash::install_panic_hook(cfg),
        None => false,
    }
}

/// The egress route and platform origin both telemetry paths send through.
///
/// A config we cannot read is not worth failing a command over. The route falls back
/// to direct, exactly as it behaved before there was a route. The origin falls back to
/// `OPENLATCH_API_URL`, and `resolve_host` turns a blank or unset one into the
/// compiled-in default. Never a PostHog host: telemetry has one destination, the
/// platform's `/ingest`, whether or not the config loaded.
fn telemetry_transport() -> (openlatch_client::egress::EgressConfig, String) {
    match openlatch_client::config::Config::load(None, None, false) {
        Ok(c) => (c.egress, c.cloud.api_url),
        Err(_) => (
            openlatch_client::egress::EgressConfig::direct(),
            std::env::var("OPENLATCH_API_URL").unwrap_or_default(),
        ),
    }
}

/// Initialise the process-global telemetry handle. Reads the openlatch dir
/// for the consent file and agent id. Best-effort — errors are swallowed
/// because telemetry must never fail user commands (invariant I10-adjacent).
fn init_telemetry() {
    let dir = openlatch_client::config::openlatch_dir();
    // Pre-init builds have no agent_id yet; "agt_unknown" is the documented
    // placeholder for the very first command before `openlatch init` runs.
    let agent_id =
        openlatch_client::config::sniff_agent_id(&dir).unwrap_or_else(|| "agt_unknown".to_string());

    // Task 3: probe for a non-empty PostHog key (build-time baked or runtime
    // override). Empty key → no-op handle, preserves I1.
    let baked_key_present = openlatch_client::telemetry::network::key_is_present();
    // Telemetry POSTs leave the host like everything else, so they take the configured
    // proxy route, to the platform's `/ingest`.
    let (egress, api_url) = telemetry_transport();
    let host = telemetry::network::resolve_host(&api_url);
    let handle = telemetry::init(&dir, agent_id, false, baked_key_present, egress, host);
    let _ = telemetry::install_global(handle);
}

/// Convert a panic hook into a `daemon_crashed` event. Captures only the
/// `file:line` of the panic location — never the message or interpolated
/// values (§5.3 of the brainstorm).
fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let location = info
            .location()
            .map(|l| format!("{}:{}", l.file(), l.line()))
            .unwrap_or_else(|| "unknown".to_string());
        telemetry::capture_global(Event::daemon_crashed(&location, 0));
        prev(info);
    }));
}

/// Map a parsed CLI command to a stable `(command, subcommand)` pair for the
/// `command_invoked` event. Names are taken from the clap subcommand grammar
/// — never from user-supplied flag values.
/// Telemetry labels for `openlatch system <subsystem> <verb>`.
///
/// Split out of [`command_labels`] so the top-level match stays one line per
/// command, the shape every other arm has. The label is the LEAF command, not
/// `system <leaf>`: telemetry answers "which subsystem did they drive", and
/// grouping the commands under `system` did not change that answer.
/// Relabelling would split every existing series in half at the release
/// boundary for nothing.
fn system_command_labels(cmd: &cli::SystemCommands) -> (&'static str, Option<&'static str>) {
    match cmd {
        cli::SystemCommands::Hooks { cmd } => (
            "hooks",
            Some(match cmd {
                cli::HooksCommands::Install(_) => "install",
                cli::HooksCommands::Uninstall(_) => "uninstall",
                cli::HooksCommands::Status => "status",
            }),
        ),
        cli::SystemCommands::Auth { cmd } => (
            "auth",
            Some(match cmd {
                cli::AuthCommands::Login(_) => "login",
                cli::AuthCommands::Logout => "logout",
                cli::AuthCommands::Status => "status",
            }),
        ),
        cli::SystemCommands::Telemetry { cmd } => (
            "telemetry",
            Some(match cmd {
                cli::TelemetryCommands::Status => "status",
                cli::TelemetryCommands::Enable => "enable",
                cli::TelemetryCommands::Disable => "disable",
                cli::TelemetryCommands::Purge => "purge",
                cli::TelemetryCommands::Debug => "debug",
            }),
        ),
        cli::SystemCommands::Supervision { cmd } => (
            "supervision",
            Some(match cmd {
                cli::SupervisionCommands::Install => "install",
                cli::SupervisionCommands::Uninstall => "uninstall",
                cli::SupervisionCommands::Status => "status",
                cli::SupervisionCommands::Enable => "enable",
                cli::SupervisionCommands::Disable => "disable",
            }),
        ),
        cli::SystemCommands::Inventory { cmd } => (
            "inventory",
            Some(match cmd {
                cli::InventoryCommands::List(_) => "list",
                cli::InventoryCommands::Log(_) => "log",
                cli::InventoryCommands::Rescan(_) => "rescan",
                cli::InventoryCommands::Status(_) => "status",
                cli::InventoryCommands::Inspect(_) => "inspect",
                cli::InventoryCommands::Projects(_) => "projects",
                cli::InventoryCommands::Ack(_) => "ack",
            }),
        ),
        cli::SystemCommands::ModelRelay { cmd } => (
            "model-relay",
            Some(match cmd {
                cli::ModelRelayCommands::Status => "status",
                cli::ModelRelayCommands::Enable(_) => "enable",
                cli::ModelRelayCommands::Disable(_) => "disable",
                cli::ModelRelayCommands::Explain { .. } => "explain",
            }),
        ),
        cli::SystemCommands::Proxy { cmd } => (
            "proxy",
            Some(match cmd {
                cli::ProxyCommands::Status => "status",
                cli::ProxyCommands::Discover(_) => "discover",
                cli::ProxyCommands::Set(_) => "set",
                cli::ProxyCommands::Clear => "clear",
                cli::ProxyCommands::Test(_) => "test",
            }),
        ),
        // Unreachable: `main` answers `system evaluate` above every init,
        // telemetry included. Present because the match is exhaustive. Same
        // label as the root alias — one command, one series.
        cli::SystemCommands::Evaluate(_) => ("evaluate", None),
    }
}

/// Run one `openlatch system <subsystem> <verb>`.
///
/// Split out of [`dispatch`] for the same reason [`system_command_labels`] is:
/// seven subsystems, several of them with their own verb match, do not belong
/// inline in a match arm. The two tables stay separate on purpose — one is
/// telemetry naming and one is real dispatch, and enumerating both is what
/// makes a new subsystem a compile error in each.
fn dispatch_system(
    cmd: &cli::SystemCommands,
    output: &openlatch_client::cli::output::OutputConfig,
) -> Result<(), openlatch_client::error::OlError> {
    match cmd {
        cli::SystemCommands::Auth { cmd } => match cmd {
            cli::AuthCommands::Login(args) => {
                openlatch_client::cli::commands::auth::run_login(args, output)
            }
            cli::AuthCommands::Logout => openlatch_client::cli::commands::auth::run_logout(output),
            cli::AuthCommands::Status => openlatch_client::cli::commands::auth::run_status(output),
        },
        cli::SystemCommands::Hooks { cmd } => match cmd {
            cli::HooksCommands::Install(args) => {
                openlatch_client::cli::commands::init::run_init(args, output)
            }
            cli::HooksCommands::Uninstall(args) => {
                openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
            }
            // Not an alias of `status`. It was one, so `openlatch system
            // hooks status` printed the whole dashboard and not one
            // hook-specific fact — the twelve entries, the binary each
            // resolves to, the token, the port.
            cli::HooksCommands::Status => openlatch_client::cli::commands::doctor::run_section(
                openlatch_client::cli::report::Section::Hooks,
                output,
            ),
        },
        cli::SystemCommands::ModelRelay { cmd } => {
            openlatch_client::cli::commands::model_relay::run(cmd, output)
        }
        cli::SystemCommands::Proxy { cmd } => {
            openlatch_client::cli::commands::proxy::run(cmd, output)
        }
        cli::SystemCommands::Supervision { cmd } => {
            openlatch_client::cli::commands::supervision::run(cmd, output)
        }
        cli::SystemCommands::Telemetry { cmd } => {
            openlatch_client::cli::commands::telemetry::run(cmd, output)
        }
        cli::SystemCommands::Inventory { cmd } => {
            openlatch_client::cli::commands::inventory::run_inventory(cmd, output)
        }
        // Unreachable in practice — `main` answers this one before any of the
        // process init that dispatch runs below. Kept so the command is
        // complete from the CLI tree alone, and it exits directly for the same
        // reason `update` does: 0, 1 and 2 are a wire contract, not an
        // `OlError`.
        cli::SystemCommands::Evaluate(args) => {
            std::process::exit(openlatch_client::cli::commands::evaluate::run(args));
        }
    }
}

fn command_labels(cmd: &Commands) -> (&'static str, Option<&'static str>) {
    match cmd {
        Commands::Init(_) => ("init", None),
        Commands::Status => ("status", None),
        Commands::Start(_) => ("start", None),
        Commands::Stop => ("stop", None),
        Commands::Restart => ("restart", None),
        Commands::Logs(_) => ("logs", None),
        Commands::Doctor(_) => ("doctor", None),
        Commands::Uninstall(_) => ("uninstall", None),
        Commands::Docs => ("docs", None),
        Commands::System { cmd } => system_command_labels(cmd),
        // The label stays the LEAF command, not `system <leaf>`: telemetry
        // answers "which subsystem did they drive", and grouping the commands
        // under `system` did not change that answer. Relabelling would split
        // every existing series in half at the release boundary for nothing.
        Commands::Daemon { cmd } => (
            "daemon",
            Some(match cmd {
                cli::DaemonCommands::Start(_) => "start",
                cli::DaemonCommands::Stop => "stop",
                cli::DaemonCommands::Restart => "restart",
            }),
        ),
        Commands::Update(_) => ("update", None),
        // Unreachable: `main` answers `evaluate` (the root alias of `system
        // evaluate`) above every init, telemetry included. Present because the
        // match is exhaustive, and because a stateless command is one that
        // reports nothing about itself anywhere.
        Commands::Evaluate(_) => ("evaluate", None),
        // Hidden Phase 1 spike — emit a telemetry label so we can spot
        // accidental invocations in aggregate even though the subcommand
        // is not user-facing.
        Commands::SpikeUpdate(_) => ("__spike-update", None),
        Commands::Bench { cmd } => (
            "bench",
            Some(match cmd {
                cli::BenchCommands::PanicIsolation(_) => "panic-isolation",
                cli::BenchCommands::PortStability => "port-stability",
                cli::BenchCommands::ExportFixtures => "export-fixtures",
                cli::BenchCommands::TransformEgress => "transform-egress",
                cli::BenchCommands::Replay(_) => "replay",
                cli::BenchCommands::CacheBaseline(_) => "cache-baseline",
                cli::BenchCommands::Compare(_) => "compare",
                cli::BenchCommands::Attribution(_) => "attribution",
            }),
        ),
    }
}

/// Dispatch CLI commands to their handlers.
///
/// Returns `Err(OlError)` on failure; main() prints the error and exits 1.
fn dispatch(
    command: &Commands,
    output: &openlatch_client::cli::output::OutputConfig,
) -> Result<(), openlatch_client::error::OlError> {
    match command {
        Commands::Init(args) => openlatch_client::cli::commands::init::run_init(args, output),
        Commands::Status => openlatch_client::cli::commands::status::run_status(output),
        Commands::Start(args) => {
            openlatch_client::cli::commands::lifecycle::run_start(args, output)
        }
        Commands::Stop => openlatch_client::cli::commands::lifecycle::run_stop(output),
        Commands::Restart => openlatch_client::cli::commands::lifecycle::run_restart(output),
        Commands::Logs(args) => openlatch_client::cli::commands::logs::run_logs(args, output),
        Commands::Doctor(args) => openlatch_client::cli::commands::doctor::run_doctor(args, output),
        Commands::Uninstall(args) => {
            openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
        }
        Commands::Docs => openlatch_client::cli::commands::docs::run_docs(output),
        Commands::System { cmd } => dispatch_system(cmd, output),
        Commands::Daemon { cmd } => match cmd {
            cli::DaemonCommands::Start(args) => {
                openlatch_client::cli::commands::lifecycle::run_start(args, output)
            }
            cli::DaemonCommands::Stop => {
                openlatch_client::cli::commands::lifecycle::run_stop(output)
            }
            cli::DaemonCommands::Restart => {
                openlatch_client::cli::commands::lifecycle::run_restart(output)
            }
        },
        // The update command surfaces multi-valued exit codes (5 for
        // cargo-install refusal, 6 for daemon-unreachable, 1 for
        // failure, 0 for success/idempotent) that the OL-XXXX dispatch
        // pipeline cannot express, so it `process::exit`s directly.
        Commands::Update(args) => {
            let code = openlatch_client::cli::commands::update::run(args, output);
            // A successful swap that left a stale daemon serving is not a clean
            // 0: the binary moved, the running process did not, and the fix the
            // user just installed is not the code enforcing anything.
            let code = if code == 0 {
                openlatch_client::cli::report::pending_exit_code()
            } else {
                code
            };
            std::process::exit(code);
        }
        // Hidden Phase 1 spike — bypasses the OL-XXXX error pipeline since
        // it is maintainer-only and has its own stderr diagnostics. The
        // process::exit means dispatch never returns past this branch.
        Commands::SpikeUpdate(args) => {
            let success = openlatch_client::cli::commands::spike_update::run(args);
            std::process::exit(if success { 0 } else { 1 });
        }
        // The root alias of `system evaluate`, one release from removal.
        // Unreachable in practice — `main` answers it before any of the process
        // init that dispatch runs below. Kept so the command is complete from
        // the CLI tree alone, and it exits directly for the same reason
        // `update` does: 0, 1 and 2 are a wire contract, not an `OlError`.
        Commands::Evaluate(args) => {
            std::process::exit(openlatch_client::cli::commands::evaluate::run(args));
        }
        // Model relay verification benches build their OWN tokio runtime (this
        // dispatch is synchronous — mirrors lifecycle.rs).
        Commands::Bench { cmd } => match cmd {
            cli::BenchCommands::PanicIsolation(args) => {
                openlatch_client::model_relay::bench::run_panic_isolation(&args.inject)
            }
            cli::BenchCommands::PortStability => {
                openlatch_client::model_relay::bench::run_port_stability()
            }
            cli::BenchCommands::ExportFixtures => {
                openlatch_client::model_relay::bench::run_export_fixtures()
            }
            cli::BenchCommands::TransformEgress => {
                openlatch_client::model_relay::bench::run_transform_egress()
            }
            cli::BenchCommands::Replay(args) => {
                openlatch_client::model_relay::bench::run_replay(&args.rule, args.runs)
            }
            cli::BenchCommands::CacheBaseline(args) => {
                let out = args.out.clone().or_else(|| args.out_positional.clone());
                openlatch_client::model_relay::bench::run_cache_baseline(
                    args.turns,
                    args.with_mcp,
                    args.direct,
                    args.uncached_prefix,
                    out.as_deref(),
                )
            }
            cli::BenchCommands::Compare(args) => {
                openlatch_client::model_relay::bench::run_compare(&args.a, &args.b)
            }
            cli::BenchCommands::Attribution(args) => {
                openlatch_client::model_relay::bench::run_attribution(args.sessions, args.turns)
            }
        },
    }
}