codewhale-telemetry 0.9.8

Anonymous, user-disableable product usage counting for Codewhale
Documentation
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
//! The wire schema. This module is the whole of what may leave the machine.
//!
//! Every field below is an integer, a boolean, or a **closed enum string**,
//! except exactly three bounded strings: `app_version`, `git_sha`, and
//! `panic_site`. Each of those three has a written rule and a test pinning the
//! rule. There is no free-form string type here and no open-keyed map, which is
//! what makes "aggregates and events, never content" a property the compiler
//! and the test suite can enforce rather than a promise.
//!
//! Bounded is not the same as *built bounded*. These types are also a
//! **deserialization target**: `flush` reads `buffer.jsonl` back off disk and
//! hands the lines to `serde`, which will fill `site`, `previous_version`, and
//! `providers` with any string the file contains. Anything running as the user
//! can append to that file, `$CODEWHALE_HOME` is a predictable path, and this
//! product executes model-authored shell commands. [`Event::is_bounded`] is
//! therefore checked on the drain path, and it is the reason the guarantee
//! above survives contact with the filesystem.
//!
//! Three standing rules for anyone extending this file:
//!
//! 1. **Never `#[derive(Serialize)]` over an existing state type.**
//!    `codewhale_state::Thread` carries `git_sha`, `git_branch`,
//!    `git_origin_url`, `cwd`, and `path`. A payload builder that accepts one
//!    and derives breaches the red lines in a single line. Every struct here is
//!    built from scratch with explicit fields.
//! 2. **Bump [`SCHEMA_VERSION`] on any field add, remove, or retype**, and
//!    never reuse a number. The golden snapshot test fails until you do.
//! 3. **A new string field needs a clause in [`Event::is_bounded`]**, not just
//!    a doc comment naming its rule. A rule only the constructor honours is a
//!    rule the drain path does not have.

use serde::{Deserialize, Serialize};

/// Wire schema version. Bumped on any field add, remove, or retype; never
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
pub const SCHEMA_VERSION: u32 = 1;

/// Which product surface produced a batch.
///
/// Deliberately **not** derived from the executable: `codewhale-tui` serves at
/// least five surfaces, and app-server runs in-process inside `codewhale`, so
/// `current_exe()` would report every app-server session as CLI. Each
/// subcommand dispatch names its own surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Surface {
    /// The interactive terminal UI.
    Tui,
    /// `codewhale exec` — one non-interactive run.
    Exec,
    /// Terminal `config` / `auth` / `update` subcommands.
    Cli,
    /// The app-server protocol surface (in-process inside `codewhale`).
    AppServer,
    /// The MCP server surface.
    McpServer,
    /// `codewhale serve`.
    Serve,
}

impl Surface {
    /// Every surface, for exhaustive iteration in tests and schema checks.
    pub const ALL: &'static [Self] = &[
        Self::Tui,
        Self::Exec,
        Self::Cli,
        Self::AppServer,
        Self::McpServer,
        Self::Serve,
    ];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Tui => "tui",
            Self::Exec => "exec",
            Self::Cli => "cli",
            Self::AppServer => "app-server",
            Self::McpServer => "mcp-server",
            Self::Serve => "serve",
        }
    }
}

/// Operating system family. A closed whitelist, so an unrecognised
/// `std::env::consts::OS` reports `other` rather than shipping a novel string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Os {
    /// Linux.
    Linux,
    /// macOS.
    Macos,
    /// Windows.
    Windows,
    /// FreeBSD.
    Freebsd,
    /// Android.
    Android,
    /// Anything else.
    Other,
}

impl Os {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[
        Self::Linux,
        Self::Macos,
        Self::Windows,
        Self::Freebsd,
        Self::Android,
        Self::Other,
    ];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Linux => "linux",
            Self::Macos => "macos",
            Self::Windows => "windows",
            Self::Freebsd => "freebsd",
            Self::Android => "android",
            Self::Other => "other",
        }
    }
}

/// CPU family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Arch {
    /// 64-bit x86.
    X86_64,
    /// 64-bit ARM.
    Aarch64,
    /// Anything else.
    Other,
}

impl Arch {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::X86_64, Self::Aarch64, Self::Other];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::X86_64 => "x86_64",
            Self::Aarch64 => "aarch64",
            Self::Other => "other",
        }
    }
}

/// C runtime the binary was **compiled** against.
///
/// Compile-time (`cfg!(target_env)`), never runtime-detected: the only way to
/// read this at runtime is `/etc/os-release` or shelling to `ldd`, both of which
/// surface corporate golden-image vendor strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Libc {
    /// glibc.
    Gnu,
    /// musl.
    Musl,
    /// Not a libc target (macOS, Windows, and anything else).
    None,
}

impl Libc {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::Gnu, Self::Musl, Self::None];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Gnu => "gnu",
            Self::Musl => "musl",
            Self::None => "none",
        }
    }
}

/// Whether this binary is newly installed, upgraded, or downgraded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallKind {
    /// No prior version record on this machine.
    Install,
    /// The recorded version is older than this one.
    Upgrade,
    /// The recorded version is newer than this one.
    Downgrade,
}

impl InstallKind {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::Install, Self::Upgrade, Self::Downgrade];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::Upgrade => "upgrade",
            Self::Downgrade => "downgrade",
        }
    }
}

/// How a session was started.
///
/// Mirrors `codewhale_state::SessionSource` by value, deliberately re-declared
/// here rather than imported: this crate must not depend on the thread store,
/// which is the one crate whose types carry paths and git identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionSource {
    /// A user opened a session directly.
    Interactive,
    /// Resumed from a persisted session.
    Resume,
    /// Forked from an existing conversation.
    Fork,
    /// Started programmatically.
    Api,
    /// Not stated.
    Unknown,
}

impl SessionSource {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[
        Self::Interactive,
        Self::Resume,
        Self::Fork,
        Self::Api,
        Self::Unknown,
    ];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Interactive => "interactive",
            Self::Resume => "resume",
            Self::Fork => "fork",
            Self::Api => "api",
            Self::Unknown => "unknown",
        }
    }
}

/// How long a session lasted, bucketed. Half-open intervals, in seconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DurationBucket {
    /// `d < 60`
    #[serde(rename = "lt_1m")]
    Lt1m,
    /// `60 <= d < 600`
    #[serde(rename = "1m_10m")]
    OneToTen,
    /// `600 <= d < 3600`
    #[serde(rename = "10m_60m")]
    TenToSixty,
    /// `d >= 3600`
    #[serde(rename = "gt_60m")]
    Gt60m,
}

impl DurationBucket {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::Lt1m, Self::OneToTen, Self::TenToSixty, Self::Gt60m];

    /// Bucket a session duration in whole seconds.
    #[must_use]
    pub fn from_secs(secs: u64) -> Self {
        match secs {
            0..60 => Self::Lt1m,
            60..600 => Self::OneToTen,
            600..3600 => Self::TenToSixty,
            _ => Self::Gt60m,
        }
    }

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Lt1m => "lt_1m",
            Self::OneToTen => "1m_10m",
            Self::TenToSixty => "10m_60m",
            Self::Gt60m => "gt_60m",
        }
    }
}

/// How the process ended.
///
/// Derived from an explicit atomic set by the panic hook, the signal task, and
/// the clean path — **never from an exit code**. `RunTerminationReason::Canceled`
/// maps to exit 130, the same value the SIGINT path uses, so a code-based
/// derivation would report every Esc-cancelled turn as a signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitClass {
    /// Ordinary successful exit.
    Clean,
    /// Terminated by a signal.
    Signal,
    /// Terminated by a panic.
    Panic,
    /// Exited non-successfully without a signal or a panic.
    Error,
}

impl ExitClass {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Clean => "clean",
            Self::Signal => "signal",
            Self::Panic => "panic",
            Self::Error => "error",
        }
    }

    /// Stable numeric encoding for the process-wide `AtomicU8`.
    #[must_use]
    pub fn as_u8(self) -> u8 {
        match self {
            Self::Clean => 0,
            Self::Signal => 1,
            Self::Panic => 2,
            Self::Error => 3,
        }
    }

    /// Inverse of [`Self::as_u8`]; anything unrecognised reads as `Clean`.
    #[must_use]
    pub fn from_u8(value: u8) -> Self {
        match value {
            1 => Self::Signal,
            2 => Self::Panic,
            3 => Self::Error,
            _ => Self::Clean,
        }
    }
}

/// Cold-start time, bucketed, in milliseconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColdStartBucket {
    /// `ms < 250`
    #[serde(rename = "lt_250")]
    Lt250,
    /// `250 <= ms < 1000`
    #[serde(rename = "250_1000")]
    Mid,
    /// `1000 <= ms < 3000`
    #[serde(rename = "1000_3000")]
    Slow,
    /// `ms >= 3000`
    #[serde(rename = "gte_3000")]
    Gte3000,
}

impl ColdStartBucket {
    /// Every value, for exhaustive iteration.
    pub const ALL: &'static [Self] = &[Self::Lt250, Self::Mid, Self::Slow, Self::Gte3000];

    /// Bucket a cold-start measurement in milliseconds.
    #[must_use]
    pub fn from_millis(ms: u64) -> Self {
        match ms {
            0..250 => Self::Lt250,
            250..1000 => Self::Mid,
            1000..3000 => Self::Slow,
            _ => Self::Gte3000,
        }
    }

    /// The wire spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Lt250 => "lt_250",
            Self::Mid => "250_1000",
            Self::Slow => "1000_3000",
            Self::Gte3000 => "gte_3000",
        }
    }
}

/// Feature-use counts for one session.
///
/// A struct of named `u32`s rather than a map, deliberately: a
/// `BTreeMap<&'static str, u32>` is an open key set the compiler cannot police,
/// so the doc-match test would be asserting that the doc matches a fixture
/// rather than the binary. Adding a counter now requires editing this file,
/// which is where that test lives. Every field serializes, including zeros.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Counters {
    /// Model turns completed.
    pub turns: u32,
    /// Tool calls executed, across every surface.
    pub tool_calls: u32,
    /// Fleet dispatches started.
    pub fleet_dispatch: u32,
    /// `workflow_run` invocations, keyed off the parsed action discriminant.
    pub workflow_run: u32,
    /// Sub-agents spawned.
    pub subagent_spawn: u32,
    /// MCP servers that reached `connected`.
    pub mcp_server_connected: u32,
    /// Native-memory searches.
    pub memory_search: u32,
    /// Approval modals shown.
    pub approval_modal_shown: u32,
    /// Approvals granted by an auto-allow rule.
    pub approval_auto_allowed: u32,
    /// Command-palette opens.
    pub command_palette_open: u32,
}

impl Counters {
    /// Field names in declaration order, for the doc-match test.
    pub const FIELDS: &'static [&'static str] = &[
        "turns",
        "tool_calls",
        "fleet_dispatch",
        "workflow_run",
        "subagent_spawn",
        "mcp_server_connected",
        "memory_search",
        "approval_modal_shown",
        "approval_auto_allowed",
        "command_palette_open",
    ];
}

/// Error counts for one session.
///
/// Every value is a count of a **variant discriminant**, never of an
/// `err.to_string()`. `ToolError::PathEscape`'s `Display` *is* an absolute path;
/// the secret store's *is* the store's absolute path; every `LlmError` variant
/// carries the raw provider HTTP body verbatim, and a 400 from a content filter
/// routinely echoes the prompt.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Errors {
    /// Credential preflight rejected the route.
    pub auth_preflight_failed: u32,
    /// Provider responded 4xx.
    pub provider_http_4xx: u32,
    /// Provider responded 5xx.
    pub provider_http_5xx: u32,
    /// A tool call was denied by policy.
    pub tool_denied_by_policy: u32,
    /// A tool call timed out.
    pub tool_timeout: u32,
    /// A request failed below HTTP — DNS, connect, TLS, or timeout.
    pub network_error: u32,
}

impl Errors {
    /// Field names in declaration order, for the doc-match test.
    pub const FIELDS: &'static [&'static str] = &[
        "auth_preflight_failed",
        "provider_http_4xx",
        "provider_http_5xx",
        "tool_denied_by_policy",
        "tool_timeout",
        "network_error",
    ];
}

/// Per-session histogram of turn wall-clock time.
///
/// A histogram, never a per-turn series: a timestamped stream of turn durations
/// reconstructs a session's working rhythm, which is the same objection that
/// rules out per-tool-call phone-home.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnWall {
    /// Turns under 5 seconds.
    pub lt_5s: u32,
    /// Turns in `[5s, 30s)`.
    #[serde(rename = "5_30s")]
    pub five_to_thirty: u32,
    /// Turns in `[30s, 120s)`.
    #[serde(rename = "30_120s")]
    pub thirty_to_onetwenty: u32,
    /// Turns at or over 120 seconds.
    pub gte_120s: u32,
}

impl TurnWall {
    /// Field names in wire spelling, in declaration order.
    pub const FIELDS: &'static [&'static str] = &["lt_5s", "5_30s", "30_120s", "gte_120s"];

    /// Record one turn of `secs` wall-clock seconds.
    pub fn observe_secs(&mut self, secs: u64) {
        match secs {
            0..5 => self.lt_5s = self.lt_5s.saturating_add(1),
            5..30 => self.five_to_thirty = self.five_to_thirty.saturating_add(1),
            30..120 => self.thirty_to_onetwenty = self.thirty_to_onetwenty.saturating_add(1),
            _ => self.gte_120s = self.gte_120s.saturating_add(1),
        }
    }
}

/// One telemetry event.
///
/// The tag is the `event` key, so the wire form is flat and the variant set is
/// closed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
    /// This binary's version differs from the one last recorded on this machine.
    InstallOrUpgrade {
        /// Install, upgrade, or downgrade.
        kind: InstallKind,
        /// The previously recorded version, read from the telemetry state file
        /// **only** — never from session history or config mtimes, which have a
        /// different privacy contract.
        previous_version: Option<String>,
    },
    /// A session began.
    SessionStart {
        /// How it was started.
        source: SessionSource,
    },
    /// A session ended. Everything the session accumulated ships here, once.
    SessionEnd {
        /// Bucketed wall-clock session length.
        duration_bucket: DurationBucket,
        /// How the process ended.
        exit_class: ExitClass,
        /// Bucketed cold start. `null` on surfaces that do not measure it.
        cold_start_bucket: Option<ColdStartBucket>,
        /// Sorted, deduplicated `ProviderKind` names. A custom provider yields
        /// the literal `"custom"`, never the customer's `[providers.<name>]`
        /// table key, and no model id is sent for any provider.
        providers: Vec<String>,
        /// Feature-use counts.
        counters: Counters,
        /// Error counts.
        errors: Errors,
        /// Turn wall-clock histogram.
        turn_wall: TurnWall,
    },
    /// The process panicked.
    Panic {
        /// Source location, reduced by the `crates/` allowlist. Never the panic
        /// *message*: a slicing panic embeds the entire string being sliced, and
        /// this tree slices user and model text in dozens of places.
        site: String,
    },
}

impl Event {
    /// The `event` discriminant, for the doc-match test.
    #[must_use]
    pub fn name(&self) -> &'static str {
        match self {
            Self::InstallOrUpgrade { .. } => "install_or_upgrade",
            Self::SessionStart { .. } => "session_start",
            Self::SessionEnd { .. } => "session_end",
            Self::Panic { .. } => "panic",
        }
    }

    /// Whether every string this event carries is inside its declared bound.
    ///
    /// The bounds above are enforced by the *constructors* — `Counters` is a
    /// struct of `u32`s, `providers` comes from `ProviderKind::as_str()`, and
    /// `site` comes from [`crate::reduce_panic_site`]. That holds only for an
    /// event this process built. Events are also **read back from
    /// `buffer.jsonl` and deserialized** before a batch is assembled, and
    /// `serde` will happily fill `site`, `previous_version`, and `providers`
    /// with any string the file contains. Anything running as this user can
    /// append a line to that file — including a `Bash` tool call this session
    /// made on the model's behalf — so the drain path must re-establish the
    /// bound rather than inherit it.
    ///
    /// Failing this check drops the event. It is never *sanitized*: a payload
    /// that the schema cannot account for is not made safe by editing it.
    #[must_use]
    pub fn is_bounded(&self) -> bool {
        match self {
            Self::SessionStart { .. } => true,
            Self::InstallOrUpgrade {
                previous_version, ..
            } => previous_version
                .as_deref()
                .is_none_or(is_release_version_string),
            Self::SessionEnd { providers, .. } => {
                providers.iter().all(|name| is_known_provider_id(name))
            }
            Self::Panic { site } => is_reduced_panic_site(site),
        }
    }
}

/// Whether `value` is a release version this schema may carry.
///
/// `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$` — the rule already written on
/// [`Batch::app_version`], applied to `previous_version` as well because that
/// field is read back from `state.json` rather than built in this process.
#[must_use]
pub fn is_release_version_string(value: &str) -> bool {
    let (core, pre) = match value.split_once('-') {
        Some((core, pre)) => (core, Some(pre)),
        None => (value, None),
    };
    let parts: Vec<&str> = core.split('.').collect();
    if parts.len() != 3
        || !parts
            .iter()
            .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
    {
        return false;
    }
    match pre {
        None => true,
        Some(pre) => !pre.is_empty() && pre.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.'),
    }
}

/// Whether `value` is in the output space of [`crate::reduce_panic_site`]:
/// the literal `<dep>`, or `crates/…​.rs:<line>:<column>` over the allowlist
/// charset.
#[must_use]
pub fn is_reduced_panic_site(value: &str) -> bool {
    if value == "<dep>" {
        return true;
    }
    let Some((file, rest)) = value.split_once(".rs:") else {
        return false;
    };
    let Some((line, column)) = rest.split_once(':') else {
        return false;
    };
    file.starts_with("crates/")
        && file
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'/' | b'.' | b'-'))
        && !line.is_empty()
        && line.bytes().all(|b| b.is_ascii_digit())
        && !column.is_empty()
        && column.bytes().all(|b| b.is_ascii_digit())
}

/// Whether `value` is a provider id this build knows.
///
/// Checked against the **full** provider registry, not
/// `ProviderKind::all()`: that constant is the 36-row *catalog* subset, and
/// `ApiProvider::kind()` legitimately yields dialect kinds
/// (`deepseek-anthropic`, the Model Studio plan variants) that are absent from
/// it. Narrowing to the catalog would silently drop a real user's route.
#[must_use]
pub fn is_known_provider_id(value: &str) -> bool {
    codewhale_config::provider::all_providers()
        .iter()
        .any(|provider| provider.id() == value)
}

/// The POST body. One per flush.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Batch {
    /// [`SCHEMA_VERSION`].
    pub schema_version: u32,
    /// RFC3339 UTC, second precision. Per-**batch** only — events carry no
    /// timestamps at all.
    pub sent_at: String,
    /// Random v4 UUID stored on this machine, rotated every 90 days.
    pub install_id: String,
    /// `CARGO_PKG_VERSION`. Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`.
    pub app_version: String,
    /// First 12 hex chars of the release-CI build sha, or `null` for every
    /// locally built binary.
    pub git_sha: Option<String>,
    /// Which surface produced this batch.
    pub surface: Surface,
    /// OS family.
    pub os: Os,
    /// CPU family.
    pub arch: Arch,
    /// Compile-time libc.
    pub libc: Libc,
    /// Whether both stdin and stdout were terminals.
    pub tty: bool,
    /// The events.
    pub events: Vec<Event>,
}

impl Batch {
    /// Envelope field names in declaration order, for the doc-match test.
    pub const FIELDS: &'static [&'static str] = &[
        "schema_version",
        "sent_at",
        "install_id",
        "app_version",
        "git_sha",
        "surface",
        "os",
        "arch",
        "libc",
        "tty",
        "events",
    ];
}