Operationalize the owner report around the honest metric: observed active installs = distinct rotating anonymous install ids with a session_start ingested on a UTC day. The canonical report:active-installs command prints the daily series, a complete-days 7-vs-7 trend, event freshness, and an always-printed coverage-caveat block; report:dau remains as a pure re-export compat alias. Exclusion guarantees are now pinned by tests (index1 only inside count(DISTINCT), no content/identity/network columns anywhere in the report path), and the output is forbidden from claiming DAU/unique users. Known floor semantics recorded in docs: day attribution is ingest-day (events carry no per-event timestamps), distinct counts cannot be sampling-corrected, and id rotation can double-count across a trend window — all framed as the lower-bound caveats they are. Verified: telemetry-ingest npm test (109/109), npm run typecheck, CLI smoke against fixtures. Implemented with Claude Code agent assistance.
28 KiB
Codewhale product telemetry
Status for 0.9.6: anonymous usage counting is on by default and can be
disabled immediately. The first interactive launch summarizes what is counted,
links the exact field-by-field schema, and preselects "Keep on" in a native
startup modal. Arrow keys or Tab choose, Enter confirms, and Y/N are direct
shortcuts. Telemetry remains unarmed until that choice. Headless surfaces follow
the same documented default without pretending an interactive notice was shown.
Every decline recorded by the former 0.9.4 opt-in notice remains off after
upgrade.
Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, or credentials. It sends no per-turn or per-tool timeline. It does send the closed, aggregate schema below: version and platform classes, session duration/outcome, feature/error counters, and a random install id that rotates every 90 days.
There is now a real endpoint. An enabled session sends its batches to the
first-party ingest service at https://telemetry.codewhale.net/v1/telemetry,
which is the shipped default for telemetry_endpoint. What that service is,
what it stores, and what it structurally cannot store is spelled out in "What
the endpoint does" below.
To send nothing anywhere, keep telemetry off (see "Turning it off"). To stay
enabled but contact nobody, set telemetry_endpoint = "": batches are then
appended to $CODEWHALE_HOME/telemetry/dryrun.jsonl on your own machine, byte
for byte what the server would have received, and no HTTP client is ever
constructed. That file is how you audit this document against reality.
This document is the schema. It is not a summary of the schema: a test in
crates/telemetry parses the field names out of this file and asserts set
equality against the structs the serializer actually uses, so a field that is
here and not in the code — or in the code and not here — fails the build.
Turning it off
There are two off switches and they do different things. Both stop collection completely; only one of them erases anything.
codewhale config set telemetry false # opt out: stops collection and erases state
CODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing
codewhale --telemetry false # the same kill switch, for one command
telemetry = false in the config file is the opt-out. It is a floor:
--telemetry true and CODEWHALE_TELEMETRY=1 both lose to it, because a
setting you can undo by accident from a wrapper script is not a setting. It
deletes the random install id, truncates every buffered event and every dry-run
record, and writes a tombstone. Appends, identity/state writes, and delivery all
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
POST remains in flight. If any part of that wipe fails, the tombstone is still
there and the buffer is undrainable — a failed wipe fails closed. Every later
run re-asserts the same tombstone for as long as the setting stands, so it
survives; turning telemetry back on means writing telemetry = true in the same
place, and that is also what clears it. Nothing buffered before that point is
ever sent.
The environment variable and the flag are kill switches, not opt-outs.
Telemetry is off for the run, nothing is written, nothing is sent — and
nothing on disk is touched or deleted. That is deliberate: a harness or agent
that sets CODEWHALE_TELEMETRY=0 for one command must not silently discard the
install id and the dry-run records of the person who owns the machine. If you
want the erasing kind, use the config file.
CODEWHALE_TELEMETRY (and its DEEPSEEK_TELEMETRY alias) accepts
0, 1, true, false, yes, no, on, off, enabled, disabled.
A value this list cannot read also resolves to off — a typo in a kill switch
must never resolve to "on".
The first-run notice is not shown at all when either switch is already set:
it never asks a question this environment would override, and answering it
never rewrites a telemetry = false you put there yourself.
A repo-local .codewhale/config.toml can set neither telemetry nor
telemetry_endpoint, and a workspace .env can set neither. Someone else's
repository cannot turn your telemetry on or aim it at a host of their choosing.
Where it lives, and how much of your disk it uses
Everything is under $CODEWHALE_HOME/telemetry/ (0700), every file 0600:
| file | role |
|---|---|
buffer.jsonl |
pending events, one JSON object per line |
buffer.jsonl.lock |
the sibling ordering lock shared by writes, delivery, arming, and wipe |
dryrun.jsonl |
where batches go when the endpoint is configured empty |
state.json |
the last app version seen and the last flush attempt |
install_id.json |
the random install id and when it was minted |
disabled |
the tombstone; present means nothing is appended or sent |
Both buffer.jsonl and dryrun.jsonl are rings capped at 512 records or
256 KiB, whichever comes first, with the oldest dropped. The documented
footprint ceiling for the whole directory is therefore 512 KiB plus a few
hundred bytes of metadata.
The install id is a random v4 UUID. It is never derived from your hostname,
MAC address, machine-id, home directory, username, or executable path — a
derived id is a device fingerprint that survives reinstall and re-identifies
you across your own opt-out. It is regenerated whenever
$CODEWHALE_HOME/telemetry/ is cleared, which opting out does automatically,
and in any case every 90 days.
There is no factory-reset command in Codewhale, so this document does not claim one.
When anything is sent, and where
Nothing is sent when the persistent opt-out or a run-scoped kill switch is in
force. Otherwise there is exactly one flush point: an attempt during shutdown,
bounded at three seconds. There is no
startup flush, mid-session flush, per-turn flush, or per-tool-call flush. The
shutdown flush re-resolves your setting from disk immediately beforehand, so
codewhale config set telemetry false written from another terminal stops the
flush of a session that is already running.
A flush is one POST to the resolved endpoint — by default
https://telemetry.codewhale.net/v1/telemetry. The request carries a
content-type: application/json header, a
user-agent: codewhale-telemetry/<app_version>, and the batch body. That is
all: no cookies (the HTTP client is built without a cookie jar to disable), no
redirects (refused outright), no Authorization header, no custom headers, and
no query string. The response body is discarded unread; only the status class is
looked at.
https:// is required. Plain http:// is accepted only for a loopback host, so
you can point the client at a recorder of your own and read the wire form
directly; no environment variable overrides that refusal. An endpoint the client
refuses turns telemetry off for the run rather than falling back to a different
destination.
Any failure — DNS, connect, TLS, timeout, non-2xx — drops the batch. There is no retry, no backoff, and no re-queue. A permanently offline machine attempts at most once per flush point and never grows a queue.
Event schema
SCHEMA_VERSION = 1. Every field is an integer, a boolean, or a closed enum string, except exactly three bounded strings: app_version, git_sha, panic_site. Each of the three has a written rule and a test pinning the rule. There is no free-form string type in this schema, and no open-keyed map. That is the property that makes red line 3 enforceable rather than aspirational.
Batch envelope — sent on every POST
{
"schema_version": 1,
"sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
"install_id": "3f2a…", // uuid v4, rotates every 90 days
"app_version": "0.9.4",
"git_sha": null, // non-null only for release-CI builds
"surface": "tui",
"os": "macos",
"arch": "aarch64",
"libc": "none",
"tty": true,
"events": [ … ]
}
| Field | Type | Source anchor | Rule |
|---|---|---|---|
schema_version |
u32 |
const in crates/telemetry/src/event.rs |
Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
sent_at |
RFC3339 | chrono::Utc::now() |
Second precision. Per-batch only — events carry no timestamps at all. |
install_id |
uuid v4 | crates/telemetry/src/envelope.rs |
Random, never derived, rotated every 90 days. See "Where it lives" above. |
app_version |
string | env!("CARGO_PKG_VERSION"), as at crates/tui/src/tui/ui.rs:17894 |
Must match ^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$. |
git_sha |
string | null | option_env!("CODEWHALE_RELEASE_BUILD_SHA") — a new rustc-env |
First 12 hex chars. Emitted only when codewhale_build_support::release_build_sha saw DEEPSEEK_BUILD_SHA or GITHUB_SHA in the build environment, i.e. only for release-CI builds. null for every locally built binary, unconditionally, with no runtime lookup of any kind. Never CODEWHALE_BUILD_COMMIT — that falls back to git_commit and is the builder's private HEAD. Never Thread.git_sha (crates/state/src/lib.rs:88) — that is the user's workspace commit and a red line, one identifier away by name. |
surface |
enum | set explicitly at each subcommand dispatch | tui | exec | cli | app-server | mcp-server | serve. Not derivable from the executable: codewhale-tui serves at least five surfaces, and app-server runs in-process inside codewhale (crates/cli/src/lib.rs:3945-3968), so current_exe() would report every app-server session as CLI. desktop is omitted — no desktop surface exists. Which of these can emit is governed by the opt-out policy, not by the surface: see "Which surfaces emit" below. |
os |
enum | std::env::consts::OS, as at crates/cli/src/update.rs:41 |
Whitelist: linux | macos | windows | freebsd | android | other. |
arch |
enum | std::env::consts::ARCH |
x86_64 | aarch64 | other. |
libc |
enum | cfg!(target_env) — compile time |
gnu | musl | none. Runtime detection reads distro vendor strings; compile-time is free and leaks nothing. |
tty |
bool | std::io::IsTerminal, as at crates/tui/src/tui/ui.rs:1169 |
stdin().is_terminal() && stdout().is_terminal(). |
events |
array | the drained buffer | Every element is one of the four events below and nothing else. Capped at 200 events or 64 KiB per batch; a batch that would exceed either cap leaves the remainder buffered for the next flush. |
os_major is not collected. Reading it costs unsafe FFI on two platforms plus a file parser on a third, in the one crate whose entire value is being small enough to audit — and os, arch, and libc are free and answer the platform question. It may be reconsidered if the stored data ever shows that the OS-version cut is what triage is missing; a hunch is not that evidence.
Which surfaces emit
A surface emits by default unless the machine has a persistent opt-out or the run has a kill switch. The notice is only rendered on a TTY. So:
tui— enters the native TUI first, shows the disclosure as a startup modal, stays unarmed until the first interactive choice, then follows it.exec,cli,app-server,mcp-server,serve— follow the documented default on a fresh home and every persistent/run-scoped opt-out on any home.- Fleet workers never emit, on any surface, by construction (
crates/tui/src/fleet/host.rs:1362).
Event: install_or_upgrade
Emitted once when state.json's last_version differs from app_version.
{ "event": "install_or_upgrade", "kind": "upgrade", "previous_version": "0.9.3" }
| Field | Type | Source | Rule |
|---|---|---|---|
kind |
enum | derived | install (no prior record) | upgrade | downgrade. |
previous_version |
string | null | $CODEWHALE_HOME/telemetry/state.json only |
Same regex as app_version. Never derived from session history or config mtimes — those files have a different privacy contract. |
Event: session_start
{ "event": "session_start", "source": "interactive" }
source is SessionSource (crates/state/src/lib.rs:34-41) stringified by session_source_to_str (:1909-1917): interactive | resume | fork | api | unknown.
Event: session_end
The workhorse. Everything a session accumulated ships here, once.
{
"event": "session_end",
"duration_bucket": "1m_10m",
"exit_class": "clean",
"cold_start_bucket": "250_1000",
"providers": ["deepseek", "custom"],
"counters": { "turns": 14, "tool_calls": 61, "fleet_dispatch": 0, "workflow_run": 0,
"subagent_spawn": 2, "mcp_server_connected": 0, "memory_search": 0,
"approval_modal_shown": 0, "approval_auto_allowed": 0,
"command_palette_open": 3 },
"errors": { "auth_preflight_failed": 0, "provider_http_4xx": 0, "provider_http_5xx": 1,
"tool_denied_by_policy": 0, "tool_timeout": 0, "network_error": 0 },
"turn_wall": { "lt_5s": 9, "5_30s": 4, "30_120s": 1, "gte_120s": 0 }
}
counters and errors are #[derive(Serialize)] structs of named u32 fields, not maps. Every field is serialized including zeros. The key set is closed by the compiler: adding a counter requires editing crates/telemetry/src/event.rs, which is where the doc-match test lives.
duration_bucket — chrono delta from app.session_started_at (crates/tui/src/tui/app.rs:1767). Half-open, seconds: lt_1m (d < 60), 1m_10m (60 ≤ d < 600), 10m_60m (600 ≤ d < 3600), gt_60m (d ≥ 3600).
exit_class — clean | signal | panic | error. Derived from an explicit AtomicU8, never from an exit code. RunTerminationReason::Canceled maps to exit 130 (crates/tui/src/core/runtime_contract/termination.rs:52), the same value the signal task uses (crates/tui/src/main.rs:686, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (crates/tui/src/main.rs:1353), by the signal task (:669-689) before std::process::exit, and on the clean path from RunTerminationReason::is_success() (crates/tui/src/core/runtime_contract/termination.rs:44-46) — error otherwise. Do not use exec_failure_exit_code (crates/tui/src/main.rs:9680-9685): it knows only {75, 1} and would report an approval-required exit (3) as a generic failure.
cold_start_bucket — from startup_trace::elapsed_ms(), which reads PROCESS_START directly and is independent of the startup summary's buffer clear (crates/tui/src/startup_trace.rs:33-48). Boundaries: lt_250, 250_1000, 1000_3000, gte_3000. Absent on non-TUI surfaces.
providers — sorted, deduplicated array of ProviderKind::as_str() (crates/config/src/provider_kind.rs:252-254, a &'static str from a closed enum; Custom yields the literal "custom"). The API takes ProviderKind by value, never &str. Do not call ProviderKind::parse or parse_config_identity (:257, :287) — those are for config-table resolution. Do not read provider_identity_for_persistence() (crates/tui/src/tui/app.rs:4635-4641), provider_id_for_persistence() (:4644-4646), ExecStreamMeta.provider_id (crates/tui/src/main.rs:9506), or PlannedTurnRoute.effective_provider_label (crates/tui/src/turn_route_plan.rs:189-193) — all four return the customer's own [providers.<name>] table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and /status already prints it (crates/tui/src/commands/groups/config/status.rs:24-28). No model id is ever sent, for any provider — crates/tui/src/safe_label.rs:11-15 documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
counters — closed field set. Every bump happens at the call site, never inside a conditionally-entered handler:
| field | source anchor |
|---|---|
turns |
crates/tui/src/tui/ui.rs:4196 — the caller of execute_turn_end_observer_hook. Never inside it: that function's first statement is if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { return Ok(()); } (:1806-1808), and the natural future optimization hoists that check to the call site, silently zeroing the counter for every user without hooks. |
tool_calls |
crates/tui/src/core/engine/tool_execution.rs:474 — surface-agnostic, fires for exec and CLI too |
fleet_dispatch |
crates/tui/src/fleet/manager.rs:334/351/363 |
workflow_run |
counted from the WorkflowAction variant discriminant returned by parse_workflow_action (crates/tui/src/tools/workflow.rs:738-751), never from input["action"]. The JSON Schema at :775-779 is what is published to the model — a declaration, not a guard; the real parse also accepts spawn|wait|list|inspect|stop|abort, and its reject arm at :746-748 embeds the model string verbatim. |
subagent_spawn |
crates/tui/src/tui/ui.rs:1763-1776 |
mcp_server_connected |
count of .connected in the snapshot at crates/tui/src/mcp.rs:3795-3809; never name, command_or_url, or error — server names are user-chosen and routinely internal infra |
memory_search |
tool name at crates/tui/src/tools/native_memory.rs:60-61, counted at the tool_execution choke point |
approval_modal_shown |
crates/tui/src/tui/ui.rs:4670 (consumer of Event::ApprovalRequired, crates/tui/src/core/events.rs:414) |
approval_auto_allowed |
crates/tui/src/core/engine.rs:5571. Count only. Never matched_rule, reason(), the command, or argv — auto_allow patterns are user-authored command strings (crates/tui/src/command_safety.rs:35/309) |
command_palette_open |
crates/tui/src/tui/ui.rs:6192-6213 and crates/tui/src/tui/mouse_ui.rs:1326 |
errors — closed field set. Every value is a variant discriminant, never err.to_string():
| field | source anchor |
|---|---|
auth_preflight_failed |
discriminant of CredentialReadiness (crates/workflow/src/fleet_preflight.rs:37-58) / ProviderAuthClass (crates/tui/src/provider_readiness.rs:32). Discriminant only — Missing { detail } carries free text |
provider_http_4xx |
status.as_u16() / 100 == 4, captured at crates/tui/src/client/chat.rs:595 and :673 before the bail!. One row per field, because the doc-match test reads this table field for field |
provider_http_5xx |
status.as_u16() / 100 == 5, same capture points |
tool_denied_by_policy |
the permission_denied arm of the 8-variant match at crates/tui/src/core/engine/tool_execution.rs:487-495 |
tool_timeout |
the timeout arm, same match |
network_error |
retry_reason_label_and_human()'s &'static str half, crates/tui/src/client.rs:2570-2584 |
Why discriminants and nothing else: ToolError::PathEscape's Display is an absolute path (crates/tools/src/lib.rs:61); fim.rs:48-50's Display is a literal source fragment the model emitted; secrets/src/lib.rs:50's Display carries the secret store's absolute path; every LlmError variant carries the raw provider HTTP body verbatim (crates/tui/src/llm_client/mod.rs:455-511), and a 400 from a content filter routinely echoes the prompt.
turn_wall — a per-session histogram of counts, never per-turn events. lt_5s, 5_30s, 30_120s, gte_120s. Source crates/tui/src/tui/ui.rs:4196, which already has duration in hand.
Event: panic
Appended synchronously by the panic hook, because a session_end may never be written.
{ "event": "panic", "site": "crates/tui/src/tui/ui.rs:8801:17" }
site comes from panic_info.location() (crates/tui/src/main.rs:1368-1371) or Location::caller() (crates/tui/src/utils.rs:523). Allowlist reduction, not optional: emit verbatim only if file() starts with crates/; otherwise emit the literal "<dep>". Must match ^crates/[A-Za-z0-9_/.-]+\.rs:\d+:\d+$ or ^<dep>$. There is no --remap-path-prefix in this repo (no .cargo/config.toml; Cargo.toml:69-74 sets only lto/strip/codegen-units), so a panic inside a registry dependency yields /Users/<builder>/.cargo/registry/src/…/ratatui-0.29.0/src/… — the build machine's username, shipped from every user's binary.
The panic message is never sent. The hook at crates/tui/src/main.rs:1361-1367 builds msg from the payload; telemetry must not read it. A slicing panic embeds the entire string being sliced, and this tree slices user and model text in dozens of places.
What the endpoint does — a shipping gate, not a footnote
This section was a gate on configuring any non-loopback endpoint. The endpoint is now configured by default, so this is a description of a service that exists rather than a promise about one that might.
What it is. https://telemetry.codewhale.net/v1/telemetry — a Cloudflare Worker named codewhale-telemetry-ingest, whose complete source is in this repository at telemetry-ingest/. It is the only component; there is no queue, no proxy, and no other service in the path. It is write-only: nothing in the Worker can read back what was stored, and querying happens out of band through Cloudflare's SQL API with the owner's token. The hostname is deliberately self-describing, so anyone inspecting their own network traffic can tell what it is from the name alone.
What it stores. Everything in this document and nothing else, in Workers Analytics Engine — one row per event. The validator in telemetry-ingest/src/schema.ts is a closed field set: an unknown key anywhere in a batch rejects the whole batch with 400. A future client bug that starts attaching a path, a prompt, or a provider table name gets refused by the server rather than quietly stored. telemetry-ingest/test/schema-doc.test.ts parses the field names and enum spellings back out of this file and asserts set equality against the validator, and telemetry-ingest/test/ingest.test.ts posts the Rust client's own pinned golden batch and asserts it is accepted byte for byte — so this document, the client, and the endpoint cannot drift apart without a red test.
Batches are IP-stripped at ingest. No IP is stored, logged, or joined to install_id — and that is structural rather than a setting anyone could flip. An Analytics Engine row is exactly _sample_interval, blob1–blob20, dataset, double1–double20, index1, and timestamp. Every one of those columns is written by the Worker's own writeDataPoint call; there is no implicit column, so there is no IP, country, or geo column — no slot one could occupy even if the code wanted it there. And the code cannot want it:
- The handler reads exactly two request headers —
content-typeandcontent-length. Nothing else, ever. - It never touches the request's
cfproperty, so country, colo, city, region, ASN, timezone, and coordinates are never in scope. - The function that builds every stored row cannot see the request at all; its input type is the validated batch body.
- Nothing logs.
invocation_logsis off inwrangler.jsonc— Cloudflare describes those as "enriched with information available to Cloudflare in the context of the invocation", which is exactly the class of automatic per-request record this service will not keep — and there is noconsole.*call in the source. - Rate limiting is keyed on
install_idfrom the validated body, never on a network address. An IP-keyed limiter would mean this Worker handles IPs. It is the weaker limiter and it is the right trade. telemetry-ingest/test/no-ip.test.tsreads the shipped source as text and fails the build if any address or geo name appears, if the set of headers read grows past two, if aconsole.*call is added, or if aResponseis ever constructed with a body.
Retention: three months. That is Cloudflare's fixed window for Analytics Engine and it is not configurable, so it is a ceiling rather than a policy — there is no setting that could make it longer.
No third-party analytics processor sits between the client and storage. There is no ad SDK, no analytics SDK, and no session replay, in the endpoint or in the runtime binary.
Every response is a bare status with an empty body — 204 accepted, 400 schema violation, 404/405 wrong path or method, 413 oversized, 415 wrong content type, 429 rate-limited, 500 internal. The endpoint cannot echo back what it received or what it holds, and because the client drops the batch on anything that is not 2xx, a rejection is invisible to you by construction and a server error can never surface as a client-visible failure.
install_id rotates client-side every 90 days (rotated_at in install_id.json), so no single identifier spans a long history. This costs longitudinal accuracy and the docs say so: no count derived from install_id is a user count. It is a lower bound on distinct machine-installs in a window, and it undercounts a returning user across a rotation.
Turning it off deletes what was kept locally, not what was already sent. codewhale config set telemetry false erases the install id, the buffer, and the dry-run records on your machine, and stops anything further. Rows already accepted by the endpoint are keyed only by a rotating random id that is now gone; they age out with the three-month window. There is no deletion API, and this document does not claim one.
What the owner reads back — observed active installs
The one product metric derived from this data is observed active installs: the number of distinct rotating anonymous install ids that produced a session_start event on a UTC day. That is the whole definition. It is not a count of people, not a count of accounts, and not a count of total installs — the id is per installation, rotates every 90 days, and is deleted on opt-out, so no number derived from it can be any of those things.
The exact owner command is checked into the repository, so the routine query is reviewable code rather than SQL pasted from a chat:
cd telemetry-ingest
CF_ACCOUNT_ID=... CF_API_TOKEN=... npm run report:active-installs
It prints the daily series, a 7-day trend over complete UTC days, the freshness of the newest ingested event, and — with the numbers, in both text and --json output — the coverage caveats: clients older than the telemetry feature, opted-out installs, and non-emitting environments (kill switches, fleet workers, offline shutdowns, dropped flushes) are invisible, so every count is a lower bound; and because ids rotate, week-over-week comparisons are not a retention metric. The report's read path is itself tested: the install id appears only inside an aggregate, no payload column is selected, and the wording can never drift into calling the result users. (npm run report:dau remains as a compatibility alias for the same report.)
What is never collected — the public red-line list
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or Authorization headers (including any boolean asserting a key exists); model ids of any kind; custom provider table names; MCP server names, commands, or URLs; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
Two named traps for the implementer. crates/state/src/lib.rs persists git_sha, git_branch, git_origin_url, cwd, and path on the threads table (:88, :394, :648): a payload builder that accepts a Thread or ThreadMeta and derives Serialize breaches the contract in one line. Never derive Serialize over an existing state type — build every telemetry struct from scratch with explicit fields. And crates/core/src/lib.rs:1381-1390 is the one place in the tree where the word telemetry sits inside a JSON object next to prompt, base_url, and has_api_key. It is the object someone will copy. Do not.