//! 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 four bounded strings: `app_version`, `git_sha`, //! `panic_site`, and `model_usage.model`, which is closed against the bundled //! model catalog. Each of those four has a written 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 = 4; /// Default-on policy disclosure version. This is not human consent. pub const NOTICE_VERSION: u32 = 6; /// 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, /// Public website product interactions. Website, /// Browser application interactions. WebApp, /// Native desktop application interactions. Desktop, /// Control-plane application interactions. ControlPlane, /// A VS Code-family extension client of this schema. /// /// Deliberately **generic**, the way `web-app` and `desktop` are: the /// client is not one publisher's extension, and a surface naming a vendor /// would narrow the whitelist for every later extension built on the same /// client. The editor's own name and version are **not** collected — this /// value names the kind of client that emitted the batch, never the host /// application it happened to run inside. VscodeExtension, } 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, Self::Website, Self::WebApp, Self::Desktop, Self::ControlPlane, Self::VscodeExtension, ]; /// 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", Self::Website => "website", Self::WebApp => "web-app", Self::Desktop => "desktop", Self::ControlPlane => "control-plane", Self::VscodeExtension => "vscode-extension", } } /// The inverse of [`Self::as_str`], or `None` for anything else. /// /// The round trip is pinned by a test, so a value that can be spelled can /// also be parsed and vice versa. This is the only place a surface name is /// turned back into a variant, which is what lets an *embedder* declare one /// without a second copy of the closed list. #[must_use] pub fn parse(value: &str) -> Option { Self::ALL .iter() .copied() .find(|surface| surface.as_str() == value) } } /// 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, /// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub /// Action, so automation is never counted as a person. Ci, } impl SessionSource { /// Every value, for exhaustive iteration. pub const ALL: &'static [Self] = &[ Self::Interactive, Self::Resume, Self::Fork, Self::Api, Self::Unknown, Self::Ci, ]; /// 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", Self::Ci => "ci", } } } /// 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", ]; } /// Closed aggregate product counters shared with browser and app clients. /// Runtime sessions keep their existing counters and add no second collector. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProductCounters { /// Aggregate page view count. pub page_view: u32, /// Aggregate docs view count. pub docs_view: u32, /// Aggregate install copy count. pub install_copy: u32, /// Aggregate download count. pub download: u32, /// Aggregate signup count. pub signup: u32, /// Aggregate login count. pub login: u32, /// Aggregate session create count. pub session_create: u32, /// Aggregate session resume count. pub session_resume: u32, /// Aggregate turn submit count. pub turn_submit: u32, /// Aggregate turn complete count. pub turn_complete: u32, /// Aggregate settings open count. pub settings_open: u32, /// Aggregate integration connect count. pub integration_connect: u32, /// Aggregate error shown count. pub error_shown: u32, } impl ProductCounters { /// Closed wire field names. pub const FIELDS: &'static [&'static str] = &[ "page_view", "docs_view", "install_copy", "download", "signup", "login", "session_create", "session_resume", "turn_submit", "turn_complete", "settings_open", "integration_connect", "error_shown", ]; } /// 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, }, /// 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, /// Sorted, deduplicated `ProviderKind` names. A custom provider yields /// the literal `"custom"`, never the customer's `[providers.]` /// table key, and no model id is sent for any provider. providers: Vec, /// 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, }, /// Anonymous service health aggregates under explicit operator consent. OperationsSummary { /// Aggregate requests. requests: u32, /// Aggregate errors. errors: u32, /// Aggregate duration ms total. duration_ms_total: u32, /// Aggregate duration ms max. duration_ms_max: u32, /// Aggregate probes. probes: u32, /// Aggregate probes failed. probes_failed: u32, }, /// Aggregate browser or application interactions without a timeline. ProductUsage { /// Closed counts; never a URL, user identity, or work content. counters: ProductCounters, }, /// One session's model turns and token counts for one provider and model. ModelUsage { /// A known provider id ([`is_known_provider_id`]). provider: String, /// A bundled-catalog model id, or `other` ([`is_reported_model_id`]). model: String, /// Turn outcomes and token totals. counters: ModelCounters, /// Turn wall-clock buckets for these turns. turn_wall: TurnWall, }, /// One session's calls of one tool. ToolUsage { /// A first-party tool name, `mcp` or `other` ([`is_reported_tool_name`]). tool: String, /// Calls made. calls: u32, /// Calls that returned an error. errors: u32, }, } /// Turn outcomes and token totals for one provider and model in a session. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelCounters { /// Turns that completed. pub turns_ok: u32, /// Turns that failed. pub turns_failed: u32, /// Turns the user or the runtime interrupted. pub turns_interrupted: u32, /// Input tokens, including cache reads. pub input_tokens: u64, /// Output tokens, including reasoning. pub output_tokens: u64, /// Input tokens served from the provider's prompt cache. pub cache_read_tokens: u64, /// Input tokens written to the provider's prompt cache. pub cache_write_tokens: u64, } impl ModelCounters { /// Wire keys, in order. pub const FIELDS: &'static [&'static str] = &[ "turns_ok", "turns_failed", "turns_interrupted", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", ]; } /// First-party tool names reported by `tool_usage`. Any other name is /// reported as `mcp` or `other`, so a plugin's or server's tool name never /// leaves the machine. pub const TOOL_NAMES: &[&str] = &[ "agent", "apply_patch", "authenticate", "bash", "code_execution", "context_archive_get", "context_archive_search", "create_goal", "diagnostics", "edit", "edit_file", "exec_shell", "execute_tools", "fetch_url", "file_search", "fim_edit", "finance", "get_goal", "git_blame", "git_commit_plan", "git_diff", "git_fetch", "git_log", "git_merge_tree", "git_show", "git_status", "grep_files", "handle_read", "image_ocr", "js_execution", "list_dir", "load_skill", "lsp", "memory_get", "memory_search", "note", "notify", "pandoc_convert", "project_map", "read", "read_file", "read_media", "remember", "request_plugin_install", "request_user_input", "retrieve_tool_result", "revert_turn", "review", "rlm", "run_tests", "run_verifiers", "session_get", "session_search", "start_mcp_server", "task_create", "task_shell_wait", "todo_write", "tool_search", "update_goal", "update_plan", "validate_data", "verify", "wait_for_dev_server", "web_search", "workflow", "write", "write_file", ]; /// The `tool` value for a tool name: itself when first-party, `mcp` for an /// MCP pool tool, otherwise `other`. #[must_use] pub fn reported_tool_name(name: &str, is_mcp: bool) -> &'static str { match TOOL_NAMES.iter().find(|known| **known == name) { Some(known) => known, None if is_mcp => "mcp", None => "other", } } /// Whether `value` may appear as `tool_usage.tool`. #[must_use] pub fn is_reported_tool_name(value: &str) -> bool { value == "mcp" || value == "other" || TOOL_NAMES.contains(&value) } /// The `model` value for a model id: the lowercased id when the bundled model /// catalog lists it, otherwise `other`. Custom, local and fine-tuned model /// names therefore never leave the machine. #[must_use] pub fn reported_model_id(model: &str) -> String { let id = model.trim().to_ascii_lowercase(); if is_catalogued_model(&id) { id } else { "other".to_string() } } /// Whether `value` may appear as `model_usage.model`. #[must_use] pub fn is_reported_model_id(value: &str) -> bool { value == "other" || (value == value.to_ascii_lowercase() && is_catalogued_model(value)) } fn is_catalogued_model(id: &str) -> bool { if id.is_empty() || id.len() > 128 { return false; } let catalog = codewhale_config::catalog::bundled_models_dev_catalog(); catalog.reviewed.intrinsic.contains_key(id) || catalog .models .keys() .any(|key| key.eq_ignore_ascii_case(id)) || catalog.providers.values().any(|provider| { provider .models .keys() .any(|key| key.eq_ignore_ascii_case(id)) }) } 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", Self::ProductUsage { .. } => "product_usage", Self::ModelUsage { .. } => "model_usage", Self::ToolUsage { .. } => "tool_usage", Self::OperationsSummary { .. } => "operations_summary", } } /// 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 { .. } | Self::ProductUsage { .. } | Self::OperationsSummary { .. } => 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), Self::ModelUsage { provider, model, .. } => is_known_provider_id(provider) && is_reported_model_id(model), Self::ToolUsage { tool, .. } => is_reported_tool_name(tool), } } } /// 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 ``, or `crates/…​.rs::` over the allowlist /// charset. #[must_use] pub fn is_reduced_panic_site(value: &str) -> bool { if value == "" { 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 list is the selectable catalog subset, while /// captured intrinsic kinds legitimately include 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, /// [`NOTICE_VERSION`], policy metadata rather than proof of acceptance. pub notice_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, /// 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, } impl Batch { /// Envelope field names in declaration order, for the doc-match test. pub const FIELDS: &'static [&'static str] = &[ "schema_version", "notice_version", "sent_at", "install_id", "app_version", "git_sha", "surface", "os", "arch", "libc", "tty", "events", ]; }