diff --git a/Cargo.lock b/Cargo.lock index af2134d1..af3af85b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,6 +2438,8 @@ dependencies = [ "dirs", "expect-test", "flate2", + "getrandom 0.4.2", + "hmac", "home", "indoc", "regex", @@ -2459,6 +2461,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", ] [[package]] @@ -2997,12 +3000,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 8e080599..3d5a72c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ dialoguer = "0.12.0" toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } +uuid = { version = "1.26.0", features = ["v4", "serde"] } +hmac = "0.12.1" +getrandom = "0.4.2" [dev-dependencies] diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 4925caa0..f2504fa8 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -154,10 +154,12 @@ Builtin dispatch currently only acts on `SessionStart`, where `handle_session_st Manages `state.toml` in the config directory. Deserializes through `RawState` and validates into the runtime `State`. Tracks the semver of the binary that last touched the directory (for future migration hooks) and the timestamp of the last update check (to throttle crates.io queries to once per 24 hours). `ensure_current()` is called on startup to silently stamp the current version. `should_check_for_update()` / `record_update_check()` gate the auto-update flow. -### `telemetry.rs` — opt-in usage telemetry +### `telemetry/` — opt-in usage telemetry Implements the local, opt-in [telemetry](./telemetry.md) event log under `/telemetry/`, one JSONL file per UTC day. Off by default; gated by `[telemetry] enabled`. A `TelemetryEvent` is an `at` timestamp plus a kind-tagged `EventKind` (`session_start` / `user_prompt` / `tool_use`), serialized one per line. `record` / `record_kind` append an event; `roll_off` deletes files older than `RETENTION_DAYS` (30); `read_events` / `recent_events` read them back; `usage` + `status_text` back `telemetry status`; `recent_events` backs `telemetry show`. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The recording entry points are not yet called from the hook pipeline, so no events are produced today even when telemetry is enabled. +The replacement recording contract is being built behind private `schema`, `identity`, and `state` submodules before it is connected to production callers. Within `schema`, `mod.rs` owns shared row primitives and version dispatch, while `agent.rs` owns agent vocabulary and agent-originated rows. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Within `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. + ### `report.rs` — structured report layer Provides user-facing output for all commands via a custom tracing layer. Commands emit `tracing::info!` or `tracing::debug!` events with a `report = %ReportEvent::Variant { ... }` field; the `ReportLayer` intercepts these and renders them based on mode: diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 6d596dfb..7e6cf712 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -75,7 +75,7 @@ All measures describe opted-in installations, not the whole user population. Rep These questions have specific limits: -- For Q1, the first observed `session_start` for a `retention_subject` establishes D0. D1, D7, or D30 is present when at least one later session is observed on that cohort day, from the same or a different agent. Multiple sessions on one day count once. This measures a later observed session, not one long session or continued value; session start runs automatically once Symposium is installed. +- For Q1, a stored D0 `session_start` for a `retention_subject` admits that cohort to analysis. D1, D7, or D30 is present when at least one later session is observed on that cohort day, from the same or a different agent. Later rows without a stored D0 are ignored. Multiple sessions on one day count once. This measures a later observed session, not one long session or continued value; session start runs automatically once Symposium is installed. - Q2 proves resolution, not activation. - Q3 records relationship edges, not a complete dependency set. - Q4 counts completed observations, so host termination can be invisible. @@ -175,17 +175,15 @@ Agent and package-manager payloads provide input to existing Symposium operation When enabled recording first needs identity state, Symposium atomically creates a random 32-byte key in private `/telemetry-state.toml` (default `~/.symposium/telemetry-state.toml`). This file is separate from the inspectable `/telemetry/` data directory. Symposium creates and replaces it with owner-only permissions where the platform supports them. -Identifiers use the first 128 bits of HMAC-SHA-256 over a domain, locally anchored 30-day window, and exact dimension: +Identifiers use the first 128 bits of HMAC-SHA-256 over a frozen domain, locally anchored 30-day window, and length-framed dimension fields. The [data contract](contract/recorded-data.md#derivation-format) fixes the byte construction, domain strings, prefixes, and field order. -```text -HMAC(key, "telemetry::v1\0" || window || "\0" || dimension) -``` +The dimension limits what an identifier can link. Identity code constructs it from typed coordinates; producers do not concatenate strings. It represents one installation for one package, agent, or command dimension, never the installation globally. -The dimension limits what an identifier can link. It represents one installation for one package, agent, or command dimension, never the installation globally. +Private state keeps the identity key and the current identifier-window anchor. It adds a return-cohort anchor when the first session is observed. Every recorder reads that state under the telemetry lock, so the same domain, window, and dimension produce the same subject across processes and restarts. -Private state keeps the identity key and the current identifier-window and return-cohort anchors. Every recorder reads that state under the telemetry lock, so the same domain, window, and dimension produce the same subject across processes and restarts. +An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation. This normal rollover changes the window input without replacing the key. `disable` and `clear` preserve the key and anchors. Renewed consent and `reset-identifiers` replace the key, set the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clear the return-cohort anchor. The next observed session starts a new cohort at D0. -Normal 30-day rollover changes the window input rather than replacing the key. `disable` and `clear` preserve the key and anchors. Renewed consent and `reset-identifiers` replace the key and start a new cohort. +Identifier-window age uses that same later day. An anchor later than the wall-clock day is valid after clock rollback and does not by itself make state malformed. The key is private state, not anonymized telemetry. Someone who has it can recompute candidate identifiers. Telemetry commands therefore never print it, and it remains outside the inspectable telemetry data directory. @@ -201,7 +199,7 @@ The key is private state, not anonymized telemetry. Someone who has it can recom | `plugin_subject` | One safe public plugin + 30-day window. | | `command_subject` | One safe command coordinate + 30-day window. | -The return subject is the sole cross-agent exception: it deduplicates Q1 but cannot link to other event kinds. A cohort remains stable through D30; the next observed session starts a new cohort. Accepting new consent or resetting identifiers rotates the key and starts another cohort. +The return subject is the sole cross-agent exception: it deduplicates Q1 but cannot link to other event kinds. A cohort remains stable through D30; the next observed session starts a new cohort. Accepting new consent or resetting identifiers rotates the key, resets the identifier-window anchor without moving it behind the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session becomes D0 of another cohort. `session_id` is absent when the agent supplies none, including Copilot. Raw vendor ids never enter events or an unkeyed hash. There is no global installation/workspace id, and future analysis or upload must not reconstruct one. Missing identity state is created only when enabled; malformed existing state stops recording until explicit identifier reset. @@ -301,9 +299,12 @@ The same all-or-nothing 256-id rule applies to attempted and completed distinct- | `ObservationRouter` | Normalize agent-native extension-use signals and send them only to active sinks. | | `MetricAggregator` | Merge hook, plugin-hook, and extension-invocation observations into bounded rows. | | `JsonlSink` | Lock, cap, retain, append/replace, inspect, and clear files. | +| `ArchiveReader` | Preserve raw inspection and return validated records from immutable closed days. | Only an enabled `Recorder` owns the telemetry sink and identity components. Call sites cannot write directly. The generated installation index is functional sync state and exists independently of telemetry consent. +Keep the schema, recorder, identity, storage writer/reader, retention, limits, and controls in focused submodules beneath `telemetry`. Only narrow recording and control entry points are crate-visible. A future upload module depends on `ArchiveReader`; recording and storage never depend on upload. + A sync returns a structured report with provenance and witnesses. That report drives skill installation, atomic index replacement, and, when recording is enabled, one sanitized relationship batch. A hook prepares its agent response before converting timings and outcomes into aggregate updates. When telemetry is disabled and no other sink is active, the observation router does not load the index or construct an extension-use observation. @@ -316,12 +317,20 @@ Commands are measured once at top-level dispatch. Raw errors never enter telemet Low-volume rows append to `events-YYYY-MM-DD.jsonl`. Current daily hook, plugin-hook, and extension-invocation aggregates live in a bounded, atomically replaced `metrics-YYYY-MM-DD.jsonl` snapshot under the inspectable telemetry data directory. -The sibling private `telemetry-state.toml` holds the identity key, cohort and cleanup metadata, marker state, and temporary keyed session-count sets. These sets are never emitted and expire at day rollover. The telemetry lock remains in the data directory and guards data and private state mutations. +The sibling private `telemetry-state.toml` holds the identity key, cohort and cleanup metadata, the latest opened UTC day, marker state, and temporary keyed session-count sets. These sets are never emitted and expire at day rollover. The telemetry lock remains in the data directory and guards data and private state mutations. + +#### Closed days + +The latest opened day is a durable high-water mark and never moves backward. Observing a later UTC day advances it and permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than reopening a closed day. A forward clock correction can therefore cause recording to remain dropped until the actual date catches up, but a clock rollback cannot modify data already considered closed. + +Raw inspection remains byte-preserving. A separate typed reader returns only recognized, valid rows from closed, unexpired days and reports malformed, invalid, and unknown-version lines separately. Validation includes file/day consistency and other cross-field invariants. The reader loads at most the daily allowance into owned memory under the telemetry lock, then releases the lock before a consumer does further work. An oversized or incompletely read day produces no validated result rather than a partial result presented as complete. #### Concurrent writes and failure Recorders make one non-waiting exclusive-lock attempt. Contention drops the entire buffered batch or aggregate observation. Event batches serialize before one append so concurrent lines cannot interleave. Snapshot updates use same-directory temporary replacement. +While holding that lock, session recording rejects a day before the latest-opened-day high-water mark. It calculates the identifier-window and return-cohort transitions before mutating either anchor. It then applies both transitions and any high-water advancement to one in-memory state, atomically replaces private state once, and only then derives the row identifiers and appends the `session_start` row. If the append fails after a new cohort is stored, later rows for that cohort are ignored by Q1 unless a D0 row was stored. The partial failure therefore causes undercounting rather than unstable identity. + Private-state replacement uses a temporary file beside `telemetry-state.toml` in the config directory. Abandoned state and snapshot temporaries are ignored and cleaned lazily under the telemetry lock. No `fsync` is promised, so a crash can still lose the latest update. Contribution counts detect state and snapshot divergence and permanently mark affected daily session counts incomplete. Aggregate counters are lower bounds. No durable counter can quantify observations lost to lock contention, process termination, or I/O failure because those conditions can also prevent writing the counter. A cap-only counter would not measure total loss. @@ -332,7 +341,7 @@ The event file, aggregate snapshot, and reserved maximum-size `storage_limit` ro Together with D31 expiry, the daily allowance bounds ordinary retained telemetry near 248 MiB, excluding temporary files and private state. Aggregate metrics receive at most 512 KiB. An oversized metric update is dropped without stopping low-volume events. An ordinary batch that cannot fit is replaced by the daily marker, and ordinary recording stops for that day. Relationship batches are never split. -Files survive D30 and become eligible for lazy deletion when `current_day - file_day > 30`, first on D31. `clear` deletes event and metric files plus pending count sets, but preserves consent and identity/cohort state. `reset-identifiers` rotates future identifiers without rewriting old files. +Files survive D30 and become eligible for lazy deletion when `current_day - file_day > 30`, first on D31. `clear` deletes event and metric files plus pending count sets, but preserves consent, identity/cohort state, and the latest-opened-day high-water mark. `reset-identifiers` rotates future identifiers without rewriting old files or moving the high-water mark backward. `disable` stops recording but keeps files by default. Uninstalling Symposium also leaves them. The [telemetry CLI reference](./reference/telemetry-command.md) defines the exact command behavior. @@ -356,9 +365,11 @@ This design accepts the following costs and limits: - Opt-in measurements describe participating installations, not the complete user population. Reports must state this selection bias. - A completed skill activation does not prove that the skill was followed or improved the task. Version 1 also obtains structured skill-use observations only from Claude. +- `os` and `arch` describe the Symposium binary's compilation target, not physical hardware or a host outside a compatibility layer. An `x86_64` binary running under Rosetta records `x86_64`, and a Linux binary under WSL records `linux`. - Scoped pseudonyms do not make a local directory anonymous. File/day/order and one buffered sync can expose co-occurrence; unusual public versions, public skill-use counts, agent/platform combinations, and exact counts can fingerprint an installation. - Best-effort recording undercounts activity. Busy multi-agent sessions contend more, terminated hooks lose final observations, and unsupported agents have configuration but not session or skill-invocation observations. A failure may also prevent writing a durable dropped-update counter, so the missing data cannot be measured completely. - Recording performs bounded in-process work and one non-waiting lock attempt. It does not promise zero latency, although failure and contention never change the user operation's result. +- A large forward system-clock correction can advance the durable day high-water mark. Recording then drops observations dated before that mark rather than risk reopening data already treated as closed. - The daily safety cap permits ordinary retained data near 248 MiB through D30. The implementation also adds cross-cutting provenance, witness, attribution, aggregation, consent, and schema-maintenance work. ### Rationale and alternatives @@ -423,6 +434,12 @@ Accepting this RFD is not consent to upload. A future RFD must define transport/ Upload may use only accepted local fields and must preserve scoped-correlation boundaries. It cannot create a global subject from identifiers, file order, batch membership, request grouping, or transport metadata. `event_id` and per-kind versions enable retry and mixed-version handling; they pre-approve no transport. +The local archive is the durable handoff for that future work. A future uploader consumes only recognized, validated rows from closed UTC days; it does not receive live observations or opaque JSONL files. Malformed, invalid, and unknown-version lines remain locally inspectable but are not uploadable by a reader that does not understand them. + +The uploader remains downstream of storage. It reads a bounded day under the telemetry lock, releases the lock before network work, and retries from unchanged local files. Upload failure does not extend local retention: data becomes ineligible on D31 whether it was acknowledged, failed, or never attempted. The uploader owns separate acknowledgement and retry state so transport concerns do not enter identity state or recording paths. Acknowledging all currently recognized rows is not a whole-day acknowledgement while unknown-version rows remain. + +This RFD supplies the closed-day and validated-reader boundary, not an uploader, upload state, network dependency, endpoint, authentication scheme, schedule, or upload setting. Using the archive avoids both a live second sink, which would couple recording to network behavior, and a duplicate upload outbox containing another copy of the data. + ### Proposed documentation - [What Symposium records](./contract/recorded-data.md): normative fields, enums, examples, and exclusions. @@ -485,14 +502,18 @@ Verify: ### Step 2: Storage and local controls -Add whole-batch event appends, non-waiting process locking, atomically replaced aggregate snapshots, daily caps and reservations, `storage_limit`, and lazy D31 cleanup. Add typed `status`, byte-preserving `show`, `clear`, and `reset-identifiers` commands. +Add whole-batch event appends, non-waiting process locking, atomically replaced aggregate snapshots, daily caps and reservations, `storage_limit`, lazy D31 cleanup, and a durable latest-opened-day high-water mark. Add typed `status`, byte-preserving `show`, a validated closed-day reader, `clear`, and `reset-identifiers` commands. Expose a test-only enabled recorder bound to a caller-supplied temporary telemetry home for integration tests and benchmarks. No production caller writes through the sink yet, and no runtime bypass is added. Verify: - Concurrent complete lines, old-or-new snapshots, whole-operation drops, and cap/marker accounting. -- D30/D31 cleanup, malformed and unknown inspection, and abandoned state/snapshot temporary cleanup. +- D30/D31 cleanup, raw inspection, validated reads, and abandoned state/snapshot temporary cleanup. +- Day advancement, identifier-window rollover, and return-cohort transition share one locked state replacement before a session append. A failed D0 append cannot admit later rows as a return cohort. +- Observations before the high-water mark are dropped before cohort mutation; clock rollback cannot reopen earlier files, and forward-correction drops are non-disruptive. +- Malformed, invalid, and unknown-version lines remain inspectable, are reported separately, and cannot enter validated output. +- Validated output contains no lock, temporary, or private-state file; an oversized or incompletely read day is rejected as a whole. - Private-state permissions and separation, clear/reset semantics, and test-only recorder isolation. - Management commands never record themselves. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index b23aae1c..c8ecfc74 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -16,7 +16,7 @@ Every JSONL row has: | `day` | `2026-08-03` | UTC calendar day. | | `symposium` | `0.4.0` | Symposium version that wrote the event. | -Completed operational events (`session_start` and `command`) also have `at`, an RFC3339 UTC timestamp truncated to one second. Resolution, configuration, and aggregate metric rows have only `day`. +Completed operational events (`session_start` and `command`) also have `at`, an RFC3339 UTC timestamp truncated to one second. Their `day` is the UTC calendar day containing `at`; a mismatch makes the row invalid. Resolution, configuration, and aggregate metric rows have only `day`. Counters and durations are non-negative JSON integers that fit an unsigned 64-bit value. Symposium checks arithmetic and drops an overflowing batch or observation instead of wrapping the value. @@ -43,12 +43,50 @@ These are independent row-shape examples, not one coherent operation or batch. T ### Key and rotation -When an enabled recorder first needs identity state, Symposium stores a random secret key in private `/telemetry-state.toml` (default `~/.symposium/telemetry-state.toml`). The same state holds the current identifier-window and return-cohort anchors. Every recorder reads it under the telemetry lock, so identical domain, window, and dimension inputs produce the same subject across processes and restarts. +When an enabled recorder first needs identity state, Symposium stores a random secret key in private `/telemetry-state.toml` (default `~/.symposium/telemetry-state.toml`). The same state holds the current identifier-window anchor and, after the first observed session, the return-cohort anchor. Every recorder reads it under the telemetry lock, so identical domain, window, and dimension inputs produce the same subject across processes and restarts. -Normal 30-day rollover changes the window input without replacing the key. Renewed consent or `telemetry reset-identifiers` replaces it. `telemetry disable` and `telemetry clear` preserve the key and anchors. +An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation. This normal rollover changes the window input without replacing the key. Renewed consent or `telemetry reset-identifiers` replaces the key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session starts a new cohort at D0. `telemetry disable` and `telemetry clear` preserve the key and whichever anchors exist. + +Identifier-window age uses that same later day. An anchor later than the wall-clock day is valid after clock rollback and is not malformed for that reason alone. This file is separate from the inspectable `/telemetry/` data directory and has owner-only permissions where the platform supports them. The key is private state, not anonymized telemetry. It is not written into events, printed by telemetry commands, or derived from your machine. Someone who has the key can recompute candidate identifiers. +The implementation prevents accidental formatting or serialization of the key. It does not promise to scrub every in-memory copy: the security boundary is the private state file and keeping the key out of telemetry and diagnostics. + +### Derivation format + +Version 1 uses the first 128 bits of HMAC-SHA-256. Each variable-length window or dimension value has an eight-byte unsigned big-endian byte length followed by its bytes: + +```text +frame(value) = u64_be(byte_length(value)) || value + +HMAC( + key, + "telemetry::v1\0" + || frame(window) + || frame(dimension field 1) + || frame(dimension field 2) + || ... +) +``` + +The window is the canonical byte form of the relevant anchor in `telemetry-state.toml`. Dimension fields use the exact UTF-8 bytes of their stable labels and validated strings, without case folding or Unicode normalization. Length framing keeps field boundaries unambiguous even when a value contains a NUL byte. Domains with no dimension fields end after the framed window. + +The domain strings, wire prefixes, and ordered dimension fields are frozen for consent version 1: + +| Identifier | HMAC domain | Wire prefix | Ordered dimension fields | +| --- | --- | --- | --- | +| `session_id` | `session_id` | `sess_` | Agent, vendor session id. | +| `retention_subject` | `retention_subject` | `ret_` | None; the return-cohort anchor is the window. | +| `agent_subject` | `agent_subject` | `agt_` | Agent. | +| `package_subject` | `package_subject` | `pkg_` | Package ecosystem, name, exact version. | +| `extension_subject` | `extension_subject` | `ext_` | Target type, source, name, then the complete safe resolution path. | +| `hook_subject` | `hook_subject` | `hok_` | Agent, hook surface. | +| `plugin_subject` | `plugin_subject` | `plg_` | Public source, plugin name. | +| `command_subject` | `command_subject` | `cmd_` | Command type, then its typed coordinate fields in event order. | + +Structured values such as an extension path use the same framing recursively. A sequence starts with its eight-byte unsigned big-endian item count. Each variant starts with its framed type label, followed by its fields in the order used by the corresponding event schema. Identity code owns this encoding; telemetry producers pass typed coordinates rather than concatenating strings. + ### What identifiers can link Symposium derives each identifier for one narrow purpose: @@ -98,7 +136,7 @@ This row records a completed registered Symposium session-start hook. GitHub Copilot does not currently supply a session id. OpenCode and Goose do not currently call Symposium through a registered session-start hook, so they do not produce this event. -These rows, not `hook_metrics` rows whose `hook` is `session_start`, are authoritative for observed-session and return measurements. For each `retention_subject`, the first row establishes D0. D1, D7, or D30 is present when at least one later session-start row has that `cohort_day`, regardless of agent or vendor session id. Multiple rows on the same cohort day count once. +These rows, not `hook_metrics` rows whose `hook` is `session_start`, are authoritative for observed-session and return measurements. A stored D0 row admits its `retention_subject` cohort to analysis. D1, D7, or D30 is present when at least one later session-start row has that `cohort_day`, regardless of agent or vendor session id. A later row without a stored D0 for the same subject is ignored. Multiple rows on the same cohort day count once. The aggregate hook rows measure only session-start hook reliability and latency. @@ -368,6 +406,10 @@ Low-volume events are appended as JSON lines in `events-YYYY-MM-DD.jsonl` under The lock in the telemetry directory also guards sibling private state. A recorder makes one non-waiting lock attempt. It may drop a complete buffered event batch or aggregate observation rather than delay your hook or command. Recording failures never change the user operation's result. +Under that lock, session recording rejects a day before the latest-opened-day high-water mark. It calculates the identifier-window and return-cohort transitions before mutating either anchor. It then applies both transitions and any high-water advancement to one in-memory state, atomically replaces private state once, and only then derives the row identifiers and appends the `session_start` row. If that append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating unstable identity. + +Private state keeps the latest opened UTC day as a high-water mark. Observing a later day permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than modifying a closed day. Raw inspection still preserves every stored line. Typed reading of a closed day returns only recognized rows that pass their versioned schema and file/day invariants, and reports malformed, invalid, and unknown-version lines separately. It rejects an oversized or incompletely read day as a whole rather than returning a partial validated result. + ### Daily limits and retention The event file, aggregate-metric snapshot, and reserved maximum-size `storage_limit` line share an 8 MiB daily allowance. This allowance is a safety ceiling, not expected volume or preallocation. It bounds damage from a producer bug or unexpectedly large resolution batch. @@ -378,13 +420,13 @@ A file is eligible for deletion only when `current_utc_day - file_utc_day > 30`. ### Private state -The sibling private `/telemetry-state.toml` holds the identity key, current identifier-window and return-cohort anchors, cleanup and marker metadata, bounded keyed session sets, and snapshot contribution counts used to calculate complete distinct-session counts. +The sibling private `/telemetry-state.toml` holds the identity key, current identifier-window anchor, optional return-cohort anchor, the latest opened UTC day, cleanup and marker metadata, bounded keyed session sets, and snapshot contribution counts used to calculate complete distinct-session counts. Symposium creates and replaces it atomically with owner-only permissions where supported. Replacement uses a same-directory temporary file beside `config.toml`; abandoned state temporaries are ignored and cleaned lazily under the telemetry lock. The session sets are not printed or copied into metric rows. Symposium discards them at UTC-day rollover and removes them when `telemetry clear` or `telemetry reset-identifiers` runs. State is replaced before the corresponding metric snapshot. If a later snapshot write fails, a contribution-count mismatch on the next update discards the sets and permanently marks the row's session counts incomplete for that day. -`telemetry clear` deletes event and aggregate-metric files and rewrites private state to remove pending sets while preserving the identity key and current anchors. `telemetry reset-identifiers` rotates future identifiers and starts a new retention cohort. `telemetry disable` stops recording; existing files remain unless the user accepts its interactive clear offer or runs `telemetry clear` later. +`telemetry clear` deletes event and aggregate-metric files and rewrites private state to remove pending sets while preserving the identity key, current anchors, and latest-opened-day high-water mark. `telemetry reset-identifiers` rotates future identifiers, sets the identifier-window anchor to the later of the current UTC day and that high-water mark, and clears the return-cohort anchor without moving the high-water mark backward. The next observed session starts a new retention cohort at D0. `telemetry disable` stops recording; existing files remain unless the user accepts its interactive clear offer or runs `telemetry clear` later. ### Installation index diff --git a/md/rfds/telemetry-recording/reference/configuration.md b/md/rfds/telemetry-recording/reference/configuration.md index c8b0abc2..4ba1281b 100644 --- a/md/rfds/telemetry-recording/reference/configuration.md +++ b/md/rfds/telemetry-recording/reference/configuration.md @@ -33,7 +33,7 @@ A future release may require a higher consent version after changing the recorde Adding an agent enum value creates a new schema version for each affected event kind so typed readers do not reinterpret old schemas. It does not by itself require renewed consent when the recorded fields, categories, timestamp precision, and correlation boundaries remain unchanged. New fields, hook surfaces, or linkage for that agent do require a higher consent version. -Accepting a newer consent version rotates the telemetry identity key and starts a new D0-D30 return cohort. Existing event and aggregate-metric files are neither rewritten nor deleted, but their scoped identifiers cannot link to later rows. +Accepting a newer consent version rotates the telemetry identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor. The next observed session starts a new D0-D30 return cohort. Existing event and aggregate-metric files are neither rewritten nor deleted, but their scoped identifiers cannot link to later rows. ## Changing telemetry state @@ -43,7 +43,7 @@ Accepting a newer consent version rotates the telemetry identity key and starts ## Related private and installation state -Consent configuration is separate from the random identity key and current identifier-window and cohort anchors in private `/telemetry-state.toml` (default `~/.symposium/telemetry-state.toml`). This state sits outside the inspectable `/telemetry/` data directory and uses owner-only permissions where supported. +Consent configuration is separate from the random identity key, current identifier-window anchor, optional return-cohort anchor, and latest opened UTC day in private `/telemetry-state.toml` (default `~/.symposium/telemetry-state.toml`). This state sits outside the inspectable `/telemetry/` data directory and uses owner-only permissions where supported. It persists across `disable` and `clear`, keeping identifiers consistent inside the active window. It is not configuration, and Symposium never reads it from project configuration. diff --git a/md/rfds/telemetry-recording/reference/telemetry-command.md b/md/rfds/telemetry-recording/reference/telemetry-command.md index 8c92096d..bcff1bd7 100644 --- a/md/rfds/telemetry-recording/reference/telemetry-command.md +++ b/md/rfds/telemetry-recording/reference/telemetry-command.md @@ -28,16 +28,17 @@ $ cargo agents telemetry status Telemetry: enabled (consent version 1) data directory: ~/.symposium/telemetry/ stored: 3 file(s), 28.4 KiB - physical lines: 71 + physical lines: 72 supported rows: 68 unknown schemas: 2 + invalid rows: 1 malformed lines: 1 range: 2026-08-01 through 2026-08-03 ``` Possible states are `disabled`, `consent required`, and `enabled`. `Consent required` means the config contains an earlier or unversioned opt-in; Symposium records nothing until you accept the current disclosure. -Stored files/bytes and physical lines cover daily event and aggregate-metric files; they exclude `.lock`, temporary files, and sibling private `telemetry-state.toml`. Supported rows are lines the current binary recognizes by kind and schema version. Unknown and malformed lines stay on disk and remain visible through `show`. `status` never prints the secret identity key or pending keyed session sets. +Stored files/bytes and physical lines cover daily event and aggregate-metric files; they exclude `.lock`, temporary files, and sibling private `telemetry-state.toml`. Supported rows are lines the current binary recognizes by kind and schema version and that satisfy the complete schema. An unknown schema has an unrecognized kind or version. An invalid row names a recognized schema but violates it. A malformed line has no usable `{v, kind}` JSON envelope. Unknown, invalid, and malformed lines stay on disk and remain visible through `show`. `status` never prints the secret identity key or pending keyed session sets. ## `enable` @@ -109,7 +110,7 @@ Enable telemetry under consent version 1? [y/N] In a non-interactive environment, `enable` does not change config unless `--acknowledge` is supplied explicitly. This prevents scripts or a manually retained unversioned boolean from upgrading consent silently. -Enabling telemetry does not rewrite or assign new identifiers to old stored lines. Accepting a new consent version rotates the secret identity key and starts a new retention cohort, severing old and new scoped identifiers. +Enabling telemetry does not rewrite or assign new identifiers to old stored lines. Accepting a new consent version rotates the secret identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, and clears the return-cohort anchor, severing old and new scoped identifiers. The next observed session starts a new retention cohort at D0. ## `disable` @@ -152,11 +153,11 @@ $ cargo agents telemetry clear Deleted 12 telemetry data file(s) from ~/.symposium/telemetry/. ``` -`clear` preserves the telemetry directory, lock, identity/cohort state, identity key, and consent setting. New rows in the same identifier window can therefore carry the same scoped subjects as cleared rows. Severing that future linkage also requires `reset-identifiers`. +`clear` preserves the telemetry directory, lock, identity/cohort state, identity key, latest-opened-day high-water mark, and consent setting. New rows in the same identifier window can therefore carry the same scoped subjects as cleared rows. Severing that future linkage also requires `reset-identifiers`. ## `reset-identifiers` -`reset-identifiers` severs identifier linkage between future and existing rows. It acquires the telemetry lock, replaces the secret identity key, discards pending aggregate session-count sets, and starts a new retention cohort: +`reset-identifiers` severs identifier linkage between future and existing rows. It acquires the telemetry lock, replaces the secret identity key, sets the identifier-window anchor to the later of the current UTC day and the latest-opened-day high-water mark, clears the return-cohort anchor, and discards pending aggregate session-count sets. The next observed session starts a new retention cohort at D0: ```console $ cargo agents telemetry reset-identifiers @@ -180,9 +181,11 @@ If no identity state exists, the command reports that there is nothing to reset Each project skills parent may also contain a generated `.symposium/index-v1.json` installation index. It maps agent-facing skill identifiers to Symposium-managed installations so a later hook can attribute a skill activation. The index is gitignored installation state, not telemetry: `show`, `clear`, retention, and identifier reset do not read or delete it, and this RFD does not upload it. -`telemetry-state.toml` is private Symposium state outside the inspectable telemetry data directory. It contains the secret identity key, current identifier-window and return-cohort anchors, cleanup and marker metadata, and bounded keyed session sets plus contribution counts used for complete aggregate session counts. All recorders read this state under the telemetry lock. +`telemetry-state.toml` is private Symposium state outside the inspectable telemetry data directory. It contains the secret identity key, current identifier-window anchor, optional return-cohort anchor, the latest opened UTC day, cleanup and marker metadata, and bounded keyed session sets plus contribution counts used for complete aggregate session counts. All recorders read this state under the telemetry lock. -Normal 30-day rollover changes the window anchor without replacing the key. Renewed consent or `reset-identifiers` replaces the key; `disable` and `clear` preserve it. +An identifier window includes its anchor day as day 0 and remains active through day 29. The first recording-capable observation on day 30 or later starts a new window anchored to that observation without replacing the key. Renewed consent or `reset-identifiers` replaces the key, resets the identifier-window anchor, and clears the return-cohort anchor; `disable` and `clear` preserve them. None of these operations moves the latest-opened-day high-water mark backward. + +For a session, high-water advancement, identifier-window rollover, and return-cohort transition form one state transition under the telemetry lock. Symposium writes them with one atomic private-state replacement before deriving identifiers or appending the `session_start` row. Symposium atomically creates and replaces the file with owner-only permissions where supported. Replacement uses a same-directory temporary file beside `config.toml`; abandoned state temporaries are ignored and cleaned lazily under the telemetry lock. diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index a8c5c741..4892b346 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -1287,7 +1287,9 @@ mod tests { "/path with spaces/symposium", ); assert_eq!( - doc["extensions"]["test-server"]["args"][0].as_str().unwrap(), + doc["extensions"]["test-server"]["args"][0] + .as_str() + .unwrap(), "--flag:value", ); } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index e34c4b95..6d01965c 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -219,9 +219,7 @@ impl Agent { .filter(|v| !v.is_empty()) .map(PathBuf::from) }; - let xdg_config = || { - env_dir("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")) - }; + let xdg_config = || env_dir("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")); match (self, scope) { // Project MCP is `.mcp.json`; the user-level file is `.claude.json`. @@ -231,7 +229,9 @@ impl Agent { .unwrap_or_else(|| home.to_path_buf()) .join(".claude.json"), - (Agent::Gemini, McpScope::Project) => project_root.join(".gemini").join("settings.json"), + (Agent::Gemini, McpScope::Project) => { + project_root.join(".gemini").join("settings.json") + } (Agent::Gemini, McpScope::User) => home.join(".gemini").join("settings.json"), (Agent::OpenCode, McpScope::Project) => project_root.join("opencode.json"), @@ -281,7 +281,9 @@ impl Agent { Agent::Claude => { mcp_server_registration::register_claude_mcp_servers(&path, servers, out) } - Agent::Codex => mcp_server_registration::register_codex_mcp_servers(&path, servers, out), + Agent::Codex => { + mcp_server_registration::register_codex_mcp_servers(&path, servers, out) + } Agent::Copilot => { mcp_server_registration::register_copilot_mcp_servers(&path, servers, out) } @@ -289,7 +291,9 @@ impl Agent { mcp_server_registration::register_gemini_mcp_servers(&path, servers, out) } Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers(&path, servers, out), - Agent::Goose => mcp_server_registration::register_goose_mcp_servers(&path, servers, out), + Agent::Goose => { + mcp_server_registration::register_goose_mcp_servers(&path, servers, out) + } Agent::OpenCode => { mcp_server_registration::register_opencode_mcp_servers(&path, servers, out) } @@ -310,7 +314,9 @@ impl Agent { Agent::Claude => { mcp_server_registration::unregister_claude_mcp_servers(&path, names, out) } - Agent::Codex => mcp_server_registration::unregister_codex_mcp_servers(&path, names, out), + Agent::Codex => { + mcp_server_registration::unregister_codex_mcp_servers(&path, names, out) + } Agent::Copilot => { mcp_server_registration::unregister_copilot_mcp_servers(&path, names, out) } @@ -318,7 +324,9 @@ impl Agent { mcp_server_registration::unregister_gemini_mcp_servers(&path, names, out) } Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers(&path, names, out), - Agent::Goose => mcp_server_registration::unregister_goose_mcp_servers(&path, names, out), + Agent::Goose => { + mcp_server_registration::unregister_goose_mcp_servers(&path, names, out) + } Agent::OpenCode => { mcp_server_registration::unregister_opencode_mcp_servers(&path, names, out) } @@ -1049,7 +1057,11 @@ mod tests { let project = Path::new("/project"); let home = Path::new("/home/user"); let cases = [ - (Agent::Claude, "/project/.mcp.json", "/home/user/.claude.json"), + ( + Agent::Claude, + "/project/.mcp.json", + "/home/user/.claude.json", + ), ( Agent::Gemini, "/project/.gemini/settings.json", @@ -1149,13 +1161,13 @@ mod tests { if agent == Agent::Gemini { continue; } - for (scope, root) in [ - (McpScope::Project, project), - (McpScope::User, home), - ] { + for (scope, root) in [(McpScope::Project, project), (McpScope::User, home)] { let mcp = agent.mcp_config_path(scope, project, home); for hooks in hook_paths_for(agent, root) { - assert_ne!(mcp, hooks, "{agent:?} {scope:?} writes MCP into its hooks file"); + assert_ne!( + mcp, hooks, + "{agent:?} {scope:?} writes MCP into its hooks file" + ); } } } diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs new file mode 100644 index 00000000..92e1e09c --- /dev/null +++ b/src/telemetry/identity.rs @@ -0,0 +1,855 @@ +//! Domain-specific identifiers used by telemetry rows. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "identifier types are built before telemetry producers use them." + ) +)] + +use std::{ + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, + str::FromStr, +}; + +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use sha2::Sha256; + +const IDENTIFIER_BYTES: usize = 16; +const ENCODED_DIGITS: usize = IDENTIFIER_BYTES * 2; +const IDENTITY_KEY_BYTES: usize = 32; + +type HmacSha256 = Hmac; + +/// A 256-bit secret used to derive telemetry pseudonyms. +/// +/// This type deliberately implements no formatting traits, which prevents the +/// key from being printed accidentally in diagnostics. It does not promise to +/// scrub every in-memory copy when dropped. +pub(super) struct IdentityKey([u8; IDENTITY_KEY_BYTES]); + +impl IdentityKey { + #[must_use] + const fn from_bytes(bytes: [u8; IDENTITY_KEY_BYTES]) -> Self { + Self(bytes) + } + + /// Generate a key from the operating system's preferred random source. + /// + /// # Errors + /// + /// Returns an error when the operating system cannot provide random bytes. + pub(super) fn generate() -> Result { + Self::generate_with(getrandom::fill) + } + + pub(super) fn generate_with( + fill: impl FnOnce(&mut [u8]) -> Result<(), E>, + ) -> Result { + let mut bytes = [0; IDENTITY_KEY_BYTES]; + fill(&mut bytes)?; + Ok(Self::from_bytes(bytes)) + } +} + +/// Canonical bytes for an identifier-window or return-cohort anchor. +/// +/// Keeping this distinct from a dimension makes their order in the HMAC input +/// impossible to swap accidentally. +struct IdentityWindow<'a>(&'a [u8]); + +#[cfg(test)] +impl<'a> IdentityWindow<'a> { + #[must_use] + const fn from_bytes(bytes: &'a [u8]) -> Self { + Self(bytes) + } +} + +/// Canonically framed dimension fields belonging to domain `D`. +/// +/// Domain-specific constructors will own field selection and order. Telemetry +/// producers never concatenate dimension strings themselves. +struct ScopedDimension { + encoded_fields: Vec, + domain: PhantomData, +} + +#[cfg(test)] +impl ScopedDimension { + #[must_use] + fn from_fields<'a>(fields: impl IntoIterator) -> Self { + let mut encoded_fields = Vec::new(); + for field in fields { + write_frame(field, |bytes| encoded_fields.extend_from_slice(bytes)); + } + + Self { + encoded_fields, + domain: PhantomData, + } + } +} + +/// A 128-bit telemetry identifier belonging to domain `D`. +/// +/// Its wire form is the domain prefix followed by `ENCODED_DIGITS` lowercase +/// hexadecimal digits. +pub(super) struct ScopedId { + bytes: [u8; IDENTIFIER_BYTES], + domain: PhantomData, +} + +impl ScopedId { + /// Wrap the leading 128 bits of a derived pseudonym. + /// + /// Private, so identifier derivation has to live in this module rather than + /// anywhere in telemetry that happens to hold sixteen bytes. + #[must_use] + const fn from_bytes(bytes: [u8; IDENTIFIER_BYTES]) -> Self { + Self { + bytes, + domain: PhantomData, + } + } +} + +// Written out rather than derived: a derive puts the same bound on `D`, so an +// identifier would only gain each trait when its zero-sized marker declared it. +impl Clone for ScopedId { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for ScopedId {} + +impl PartialEq for ScopedId { + fn eq(&self, other: &Self) -> bool { + self.bytes == other.bytes + } +} + +impl Eq for ScopedId {} + +impl PartialOrd for ScopedId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ScopedId { + fn cmp(&self, other: &Self) -> Ordering { + self.bytes.cmp(&other.bytes) + } +} + +impl Hash for ScopedId { + fn hash(&self, state: &mut H) { + self.bytes.hash(state); + } +} + +/// Marker supplying a [`ScopedId`] domain's frozen derivation and wire labels. +/// +/// Private, which seals it: both constants are published contract surfaces, +/// not extension points. Changing either requires a new consent version. +trait ScopedIdDomain { + const PREFIX: &'static str; + const HMAC_DOMAIN: &'static str; +} + +/// Derives purpose-scoped telemetry identifiers from one secret key. +struct IdentityDeriver { + key: IdentityKey, +} + +impl IdentityDeriver { + #[must_use] + const fn new(key: IdentityKey) -> Self { + Self { key } + } + + /// Derive an identifier from a canonical window and domain-specific fields. + #[must_use] + fn derive(&self, window: &IdentityWindow<'_>, dimension: &ScopedDimension) -> ScopedId + where + D: ScopedIdDomain, + { + let mut hmac = HmacSha256::new_from_slice(&self.key.0) + .expect("BUG: HMAC-SHA-256 must accept a 32-byte key"); + hmac.update(b"telemetry:"); + hmac.update(D::HMAC_DOMAIN.as_bytes()); + hmac.update(b":v1\0"); + write_frame(window.0, |bytes| hmac.update(bytes)); + hmac.update(&dimension.encoded_fields); + + let digest = hmac.finalize().into_bytes(); + let mut bytes = [0; IDENTIFIER_BYTES]; + bytes.copy_from_slice(&digest[..IDENTIFIER_BYTES]); + ScopedId::from_bytes(bytes) + } +} + +/// Write one unambiguous variable-length value to an HMAC input or buffer. +fn write_frame(value: &[u8], mut write: impl FnMut(&[u8])) { + let length = u64::try_from(value.len()) + .expect("BUG: a slice length must fit the telemetry frame format"); + write(&length.to_be_bytes()); + write(value); +} + +/// Reason a stored scoped identifier is not canonical. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ParseScopedIdError { + IncorrectPrefix, + IncorrectLength, + InvalidHex, +} + +impl fmt::Display for ParseScopedIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IncorrectPrefix => formatter.write_str("identifier has the wrong domain prefix"), + Self::IncorrectLength => write!( + formatter, + "identifier must contain exactly {ENCODED_DIGITS} hexadecimal digits" + ), + Self::InvalidHex => { + formatter.write_str("identifier contains a non-lowercase-hexadecimal character") + } + } + } +} + +impl std::error::Error for ParseScopedIdError {} + +// Debug prints the wire form too: the derived one dumps sixteen decimal numbers, +// which makes a failed identifier comparison unreadable. +impl fmt::Debug for ScopedId +where + D: ScopedIdDomain, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self}") + } +} + +impl fmt::Display for ScopedId +where + D: ScopedIdDomain, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(D::PREFIX)?; + write_lower_hex(&self.bytes, formatter) + } +} + +impl FromStr for ScopedId +where + D: ScopedIdDomain, +{ + type Err = ParseScopedIdError; + + fn from_str(value: &str) -> Result { + let encoded = value + .strip_prefix(D::PREFIX) + .ok_or(ParseScopedIdError::IncorrectPrefix)?; + + let bytes = decode_lower_hex_array(encoded).map_err(|error| match error { + DecodeLowerHexError::IncorrectLength => ParseScopedIdError::IncorrectLength, + DecodeLowerHexError::InvalidDigit => ParseScopedIdError::InvalidHex, + })?; + + Ok(Self::from_bytes(bytes)) + } +} + +impl Serialize for ScopedId +where + D: ScopedIdDomain, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de, D> Deserialize<'de> for ScopedId +where + D: ScopedIdDomain, +{ + fn deserialize(deserializer: De) -> Result + where + De: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(De::Error::custom) + } +} + +/// Write `bytes` as lowercase hexadecimal digits. +/// +/// Takes any [`fmt::Write`] so a formatter and a string buffer share one encoder. +fn write_lower_hex(bytes: &[u8], out: &mut impl fmt::Write) -> fmt::Result { + for byte in bytes { + write!(out, "{byte:02x}")?; + } + + Ok(()) +} + +/// Reason lowercase hexadecimal digits do not decode to a fixed byte array. +/// +/// Deliberately unnamed in user-facing text: each caller names the failure in +/// terms of the value it was parsing. +enum DecodeLowerHexError { + IncorrectLength, + InvalidDigit, +} + +/// Decode `N` bytes from exactly `2 * N` lowercase hexadecimal digits. +fn decode_lower_hex_array(encoded: &str) -> Result<[u8; N], DecodeLowerHexError> { + if encoded.len() != N * 2 { + return Err(DecodeLowerHexError::IncorrectLength); + } + + // The length check leaves no remainder, so every digit reaches a pair. + let (digit_pairs, _) = encoded.as_bytes().as_chunks::<2>(); + + let mut bytes = [0; N]; + for (&[high, low], output) in digit_pairs.iter().zip(&mut bytes) { + let high = decode_lower_hex_digit(high).ok_or(DecodeLowerHexError::InvalidDigit)?; + let low = decode_lower_hex_digit(low).ok_or(DecodeLowerHexError::InvalidDigit)?; + *output = (high << 4) | low; + } + + Ok(bytes) +} + +fn decode_lower_hex_digit(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + +/// Serde adapter for the identity key stored in `telemetry-state.toml`. +/// +/// Keeping this adapter here avoids giving [`IdentityKey`] general-purpose +/// formatting or serialization traits that could expose it elsewhere. Writing +/// the key does materialize it as a hexadecimal string, which is not scrubbed: +/// see the boundary documented on [`IdentityKey`]. +pub(super) mod state_key_hex { + use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + + use super::{ + DecodeLowerHexError, IDENTITY_KEY_BYTES, IdentityKey, decode_lower_hex_array, + write_lower_hex, + }; + + const ENCODED_KEY_DIGITS: usize = IDENTITY_KEY_BYTES * 2; + + pub(in crate::telemetry) fn serialize( + key: &IdentityKey, + serializer: S, + ) -> Result + where + S: Serializer, + { + let mut encoded = String::with_capacity(ENCODED_KEY_DIGITS); + write_lower_hex(&key.0, &mut encoded).expect("BUG: writing to a String cannot fail"); + + serializer.serialize_str(&encoded) + } + + pub(in crate::telemetry) fn deserialize<'de, D>( + deserializer: D, + ) -> Result + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + + let bytes = + decode_lower_hex_array::(&encoded).map_err( + |error| match error { + DecodeLowerHexError::IncorrectLength => D::Error::custom(format_args!( + "identity key must contain exactly {ENCODED_KEY_DIGITS} hexadecimal digits" + )), + DecodeLowerHexError::InvalidDigit => { + D::Error::custom("identity key must use lowercase hexadecimal digits") + } + }, + )?; + + Ok(IdentityKey::from_bytes(bytes)) + } +} + +/// Declares each identifier domain, its contract prefix, and its alias. +/// +/// A macro because a function cannot introduce types, and the prefix is the +/// hand-written part worth handing to the tests as a table. +macro_rules! scoped_id_domains { + ( + $( + $domain:ident => $alias:ident { + hmac_domain: $hmac_domain:literal, + wire_prefix: $prefix:literal, + } + )+ + ) => { + $( + pub(super) enum $domain {} + + impl ScopedIdDomain for $domain { + const PREFIX: &'static str = $prefix; + const HMAC_DOMAIN: &'static str = $hmac_domain; + } + + pub(super) type $alias = ScopedId<$domain>; + )+ + + #[cfg(test)] + const PREFIX_PARSERS: &[(&str, fn(&str) -> bool)] = &[ + $(($prefix, |value| value.parse::<$alias>().is_ok())),+ + ]; + + #[cfg(test)] + const DOMAIN_CONTRACTS: &[(&str, &str)] = &[ + $(($hmac_domain, $prefix)),+ + ]; + }; +} + +scoped_id_domains! { + SessionDomain => SessionId { + hmac_domain: "session_id", + wire_prefix: "sess_", + } + RetentionDomain => RetentionSubject { + hmac_domain: "retention_subject", + wire_prefix: "ret_", + } + AgentDomain => AgentSubject { + hmac_domain: "agent_subject", + wire_prefix: "agt_", + } + PackageDomain => PackageSubject { + hmac_domain: "package_subject", + wire_prefix: "pkg_", + } + ExtensionDomain => ExtensionSubject { + hmac_domain: "extension_subject", + wire_prefix: "ext_", + } + HookDomain => HookSubject { + hmac_domain: "hook_subject", + wire_prefix: "hok_", + } + PluginDomain => PluginSubject { + hmac_domain: "plugin_subject", + wire_prefix: "plg_", + } + CommandDomain => CommandSubject { + hmac_domain: "command_subject", + wire_prefix: "cmd_", + } +} + +#[cfg(test)] +mod tests { + use std::{ + any::TypeId, + collections::HashSet, + mem::{size_of, size_of_val}, + }; + + use super::*; + + const RECORDED_DATA: &str = + include_str!("../../md/rfds/telemetry-recording/contract/recorded-data.md"); + + const TEST_HEX: &str = "00112233445566778899aabbccddeeff"; + + const TEST_BYTES: [u8; IDENTIFIER_BYTES] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, + ]; + + enum TestDomain {} + + impl ScopedIdDomain for TestDomain { + const PREFIX: &'static str = "test_"; + const HMAC_DOMAIN: &'static str = "test_subject"; + } + + /// Reads the first identifier the contract spells with `prefix`. + fn contract_identifier(prefix: &str) -> &'static str { + let opening_quote = RECORDED_DATA + .find(&format!("\"{prefix}")) + .unwrap_or_else(|| panic!("recorded-data contract names no {prefix} identifier")); + + let value = &RECORDED_DATA[opening_quote + 1..]; + let closing_quote = value + .find('"') + .expect("recorded-data identifier must be terminated"); + + &value[..closing_quote] + } + + #[test] + fn identity_key_wraps_exactly_32_bytes() { + let bytes = [0x5a; IDENTITY_KEY_BYTES]; + + let key = IdentityKey::from_bytes(bytes); + + assert_eq!(key.0, bytes); + assert_eq!(size_of::(), IDENTITY_KEY_BYTES); + } + + #[test] + fn identity_key_generation_fills_the_complete_key() { + let expected = [0x5a; IDENTITY_KEY_BYTES]; + + let key = IdentityKey::generate_with(|bytes| { + bytes.copy_from_slice(&expected); + Ok::<_, std::convert::Infallible>(()) + }) + .expect("infallible test source must generate a key"); + + assert_eq!(key.0, expected); + } + + #[test] + fn identity_key_generation_propagates_source_failure() { + #[derive(Debug, PartialEq, Eq)] + struct TestError; + + let result = IdentityKey::generate_with(|_| Err(TestError)); + + assert!(matches!(result, Err(TestError))); + } + + #[test] + fn identity_key_can_be_generated_from_the_operating_system() { + let key = IdentityKey::generate().expect("operating system must provide random bytes"); + + assert_eq!(size_of_val(&key), IDENTITY_KEY_BYTES); + } + + #[test] + fn identity_derivation_matches_contract_vector() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let deriver = IdentityDeriver::new(key); + let window = IdentityWindow::from_bytes(b"window-1"); + let dimension = ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + let identifier = deriver.derive(&window, &dimension); + + // Cross-checked with .NET's HMACSHA256 over the contract's header and + // two unsigned 64-bit big-endian length-prefixed values. The complete + // digest is ec1c11acdcca37eb4ab17f381c80df5246b0a66438f757eaa2a52195da40afbd. + assert_eq!( + identifier.to_string(), + "sess_ec1c11acdcca37eb4ab17f381c80df52" + ); + } + + #[test] + fn identical_derivation_inputs_are_stable() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let deriver = IdentityDeriver::new(key); + let window = IdentityWindow::from_bytes(b"window-1"); + let dimension = ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + let first = deriver.derive(&window, &dimension); + let second = deriver.derive(&window, &dimension); + + assert_eq!(first, second); + } + + #[test] + fn length_framing_separates_nul_at_different_boundaries() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let first_window = IdentityWindow::from_bytes(b"a\0b"); + let first_dimension = ScopedDimension::::from_fields([b"c".as_slice()]); + let second_window = IdentityWindow::from_bytes(b"a"); + let second_dimension = ScopedDimension::::from_fields([b"b\0c".as_slice()]); + + let first = deriver.derive(&first_window, &first_dimension); + let second = deriver.derive(&second_window, &second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn length_framing_separates_dimension_field_boundaries() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let window = IdentityWindow::from_bytes(b"window-1"); + let first_dimension = ScopedDimension::::from_fields([ + b"cargo".as_slice(), + b"foo1".as_slice(), + b"2.3.4".as_slice(), + ]); + let second_dimension = ScopedDimension::::from_fields([ + b"cargo".as_slice(), + b"foo".as_slice(), + b"12.3.4".as_slice(), + ]); + + let first = deriver.derive(&window, &first_dimension); + let second = deriver.derive(&window, &second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn changing_any_derivation_scope_changes_the_identifier() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let other_deriver = + IdentityDeriver::new(IdentityKey::from_bytes([0x24; IDENTITY_KEY_BYTES])); + let first_window = IdentityWindow::from_bytes(b"window-1"); + let second_window = IdentityWindow::from_bytes(b"window-2"); + let session_dimension = + ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + let other_session_dimension = + ScopedDimension::::from_fields([b"dimension-2".as_slice()]); + let command_dimension = + ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + // Different domain markers produce different identifier types, so use + // their shared byte representation for this one collection. + let identifiers = [ + deriver.derive(&first_window, &session_dimension).bytes, + other_deriver + .derive(&first_window, &session_dimension) + .bytes, + deriver.derive(&first_window, &command_dimension).bytes, + deriver.derive(&second_window, &session_dimension).bytes, + deriver + .derive(&first_window, &other_session_dimension) + .bytes, + ]; + + let unique_identifiers = identifiers.into_iter().collect::>(); + + assert_eq!(unique_identifiers.len(), identifiers.len()); + } + + #[test] + fn domain_marker_adds_no_storage_to_identifier() { + let bytes = [0x5a; IDENTIFIER_BYTES]; + + let identifier = ScopedId::::from_bytes(bytes); + + assert_eq!(identifier, ScopedId::::from_bytes(bytes)); + assert_eq!(size_of::>(), IDENTIFIER_BYTES); + } + + #[test] + fn identifier_domains_are_distinct_types() { + let domains = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + let unique_domains = domains.into_iter().collect::>(); + + assert_eq!(unique_domains.len(), domains.len()); + } + + #[test] + fn domain_prefixes_are_distinct() { + let prefixes = PREFIX_PARSERS + .iter() + .map(|(prefix, _)| *prefix) + .collect::>(); + + assert_eq!(prefixes.len(), PREFIX_PARSERS.len()); + } + + #[test] + fn hmac_domains_are_distinct() { + let domains = DOMAIN_CONTRACTS + .iter() + .map(|(domain, _)| *domain) + .collect::>(); + + assert_eq!(domains.len(), DOMAIN_CONTRACTS.len()); + } + + #[test] + fn derivation_constants_match_the_recorded_data_contract() { + for (domain, prefix) in DOMAIN_CONTRACTS { + let contract_row = format!("| `{domain}` | `{domain}` | `{prefix}` |"); + + assert!( + RECORDED_DATA.contains(&contract_row), + "recorded-data contract does not contain {contract_row}" + ); + } + } + + #[test] + fn each_domain_accepts_only_its_own_prefix() { + for (prefix, parses) in PREFIX_PARSERS { + for (candidate_prefix, _) in PREFIX_PARSERS { + let value = format!("{candidate_prefix}{TEST_HEX}"); + + assert_eq!( + parses(&value), + prefix == candidate_prefix, + "{prefix} domain mishandled {value}" + ); + } + } + } + + #[test] + fn contract_identifiers_parse_in_their_own_domain() { + for (prefix, parses) in PREFIX_PARSERS { + let identifier = contract_identifier(prefix); + + assert!( + parses(identifier), + "contract identifier {identifier} does not parse in the {prefix} domain" + ); + } + } + + #[test] + fn identifiers_display_with_their_contract_prefixes() { + let rendered = [ + SessionId::from_bytes(TEST_BYTES).to_string(), + RetentionSubject::from_bytes(TEST_BYTES).to_string(), + AgentSubject::from_bytes(TEST_BYTES).to_string(), + PackageSubject::from_bytes(TEST_BYTES).to_string(), + ExtensionSubject::from_bytes(TEST_BYTES).to_string(), + HookSubject::from_bytes(TEST_BYTES).to_string(), + PluginSubject::from_bytes(TEST_BYTES).to_string(), + CommandSubject::from_bytes(TEST_BYTES).to_string(), + ]; + + assert_eq!( + rendered, + [ + "sess_00112233445566778899aabbccddeeff", + "ret_00112233445566778899aabbccddeeff", + "agt_00112233445566778899aabbccddeeff", + "pkg_00112233445566778899aabbccddeeff", + "ext_00112233445566778899aabbccddeeff", + "hok_00112233445566778899aabbccddeeff", + "plg_00112233445566778899aabbccddeeff", + "cmd_00112233445566778899aabbccddeeff", + ] + ); + } + + #[test] + fn identifier_debug_uses_the_wire_form() { + let identifier = SessionId::from_bytes(TEST_BYTES); + + let debug = format!("{identifier:?}"); + + assert_eq!(debug, "sess_00112233445566778899aabbccddeeff"); + } + + #[test] + fn identifier_text_round_trips() { + let identifier = SessionId::from_bytes(TEST_BYTES); + + let parsed = identifier.to_string().parse::().unwrap(); + + assert_eq!(parsed, identifier); + } + + #[test] + fn identifier_json_round_trips() { + let identifier = SessionId::from_bytes(TEST_BYTES); + + let json = serde_json::to_string(&identifier).unwrap(); + + assert_eq!(json, format!("\"sess_{TEST_HEX}\"")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + identifier + ); + } + + #[test] + fn identifier_json_rejects_another_domain_prefix() { + let json = format!("\"cmd_{TEST_HEX}\""); + + let result = serde_json::from_str::(&json); + + assert!(result.is_err()); + } + + #[test] + fn identifier_json_rejects_non_string_values() { + for json in ["17", "{}", "[]", "true", "null"] { + let result = serde_json::from_str::(json); + + assert!(result.is_err(), "accepted non-string identifier: {json}"); + } + } + + #[test] + fn identifier_rejects_another_domain_prefix() { + let value = format!("cmd_{TEST_HEX}"); + + let result = value.parse::(); + + assert_eq!(result, Err(ParseScopedIdError::IncorrectPrefix)); + } + + #[test] + fn identifier_rejects_wrong_lengths() { + let cases = [ + "sess_", + "sess_00112233445566778899aabbccddeef", + "sess_00112233445566778899aabbccddeeff0", + ]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(ParseScopedIdError::IncorrectLength), + "accepted identifier with the wrong length: {value}" + ); + } + } + + #[test] + fn identifier_rejects_uppercase_hexadecimal() { + let value = "sess_00112233445566778899AABBCCDDEEFF"; + + let result = value.parse::(); + + assert_eq!(result, Err(ParseScopedIdError::InvalidHex)); + } + + #[test] + fn identifier_rejects_non_hexadecimal_character() { + let value = "sess_00112233445566778899aabbccddeefg"; + + let result = value.parse::(); + + assert_eq!(result, Err(ParseScopedIdError::InvalidHex)); + } +} diff --git a/src/telemetry.rs b/src/telemetry/mod.rs similarity index 99% rename from src/telemetry.rs rename to src/telemetry/mod.rs index 8bfe1fc3..88413323 100644 --- a/src/telemetry.rs +++ b/src/telemetry/mod.rs @@ -11,6 +11,10 @@ //! Every entry point here is best-effort: a failure to read or write the log //! must never break a hook, so errors are logged and swallowed. +mod identity; +mod schema; +mod state; + use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs new file mode 100644 index 00000000..f93e28d2 --- /dev/null +++ b/src/telemetry/schema/agent.rs @@ -0,0 +1,605 @@ +//! Closed vocabulary shared by agent-originated telemetry rows. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + CohortDay, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, + deserialize_version_one, +}; +use crate::{ + agents::Agent, + telemetry::identity::{AgentSubject, RetentionSubject, SessionId}, +}; + +/// Agent included in the daily configuration snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum SupportedAgent { + Claude, + Codex, + Copilot, + Gemini, + Kiro, + #[serde(rename = "opencode")] + OpenCode, + Goose, +} + +impl From for SupportedAgent { + fn from(agent: Agent) -> Self { + match agent { + Agent::Claude => Self::Claude, + Agent::Codex => Self::Codex, + Agent::Copilot => Self::Copilot, + Agent::Gemini => Self::Gemini, + Agent::Kiro => Self::Kiro, + Agent::OpenCode => Self::OpenCode, + Agent::Goose => Self::Goose, + } + } +} + +/// Agent that invoked a registered Symposium hook. +/// +/// Unlike the platform enums, this has no `Other`: Symposium owns the set of +/// registered agent hooks, so adding an agent changes the row schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum HookAgent { + Claude, + Codex, + Copilot, + Gemini, + Kiro, +} + +impl From for SupportedAgent { + fn from(agent: HookAgent) -> Self { + match agent { + HookAgent::Claude => Self::Claude, + HookAgent::Codex => Self::Codex, + HookAgent::Copilot => Self::Copilot, + HookAgent::Gemini => Self::Gemini, + HookAgent::Kiro => Self::Kiro, + } + } +} + +/// Operating-system class for the running Symposium build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum OperatingSystem { + Linux, + Macos, + Windows, + Other, +} + +impl OperatingSystem { + /// Return the contract bucket for the running Symposium build. + #[must_use] + pub(in crate::telemetry) fn current() -> Self { + Self::from_target(std::env::consts::OS) + } + + fn from_target(target: &str) -> Self { + match target { + "linux" => Self::Linux, + "macos" => Self::Macos, + "windows" => Self::Windows, + _ => Self::Other, + } + } +} + +/// Architecture class for the running Symposium build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum Architecture { + X86_64, + Aarch64, + Other, +} + +impl Architecture { + /// Return the contract bucket for the running Symposium build. + #[must_use] + pub(in crate::telemetry) fn current() -> Self { + Self::from_target(std::env::consts::ARCH) + } + + fn from_target(target: &str) -> Self { + match target { + "x86_64" => Self::X86_64, + "aarch64" => Self::Aarch64, + _ => Self::Other, + } + } +} + +/// Agent-supplied classification of how a session began. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum SessionStartKind { + Fresh, + Resumed, + Unknown, +} + +/// Agent-supplied and derived fields for one completed session-start hook. +/// +/// These fields are repeated on [`SessionStartV1`] because flattening this +/// struct into the row would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct SessionStartFields { + pub(in crate::telemetry) agent: HookAgent, + pub(in crate::telemetry) os: OperatingSystem, + pub(in crate::telemetry) arch: Architecture, + pub(in crate::telemetry) start: SessionStartKind, + pub(in crate::telemetry) session_id: Option, + pub(in crate::telemetry) retention_subject: RetentionSubject, + pub(in crate::telemetry) cohort_day: CohortDay, +} + +/// Version 1 record of a completed registered session-start hook. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawSessionStartV1")] +pub(in crate::telemetry) struct SessionStartV1 { + #[serde(rename = "v")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + agent: HookAgent, + os: OperatingSystem, + arch: Architecture, + start: SessionStartKind, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + retention_subject: RetentionSubject, + cohort_day: CohortDay, +} + +impl SessionStartV1 { + /// Create a record for a completed registered session-start hook. + #[must_use] + pub(in crate::telemetry) fn new(at: UtcSecond, fields: SessionStartFields) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::SessionStart, + event_id: EventId::new(), + day: at.day(), + at, + symposium: SymposiumVersion::current(), + agent: fields.agent, + os: fields.os, + arch: fields.arch, + start: fields.start, + session_id: fields.session_id, + retention_subject: fields.retention_subject, + cohort_day: fields.cohort_day, + } + } +} + +/// Invalid relationship between fields in a session-start row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionStartError { + DayDoesNotMatchTimestamp { stored: UtcDay, timestamp: UtcDay }, +} + +impl fmt::Display for SessionStartError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DayDoesNotMatchTimestamp { stored, timestamp } => write!( + formatter, + "stored session-start day {stored} does not match timestamp day {timestamp}" + ), + } + } +} + +impl std::error::Error for SessionStartError {} + +/// Strict wire representation validated before becoming a session-start row. +/// +/// Serde's `try_from` deserializes this type rather than the outer row, so its +/// version and unknown-field checks are deliberately declared here. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSessionStartV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + agent: HookAgent, + os: OperatingSystem, + arch: Architecture, + start: SessionStartKind, + session_id: Option, + retention_subject: RetentionSubject, + cohort_day: CohortDay, +} + +impl TryFrom for SessionStartV1 { + type Error = SessionStartError; + + fn try_from(raw: RawSessionStartV1) -> Result { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(SessionStartError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); + } + + Ok(Self { + version: raw.version, + kind: raw.kind, + event_id: raw.event_id, + day: raw.day, + at: raw.at, + symposium: raw.symposium, + agent: raw.agent, + os: raw.os, + arch: raw.arch, + start: raw.start, + session_id: raw.session_id, + retention_subject: raw.retention_subject, + cohort_day: raw.cohort_day, + }) + } +} + +/// Fields that vary for each entry in a daily agent configuration snapshot. +/// +/// These fields are repeated on [`AgentConfigurationV1`] because flattening +/// this struct into the row would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct AgentConfigurationFields { + pub(in crate::telemetry) agent: SupportedAgent, + pub(in crate::telemetry) configured: bool, + pub(in crate::telemetry) agent_subject: AgentSubject, +} + +/// Version 1 daily observation of one supported agent's configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct AgentConfigurationV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + agent: SupportedAgent, + configured: bool, + os: OperatingSystem, + arch: Architecture, + agent_subject: AgentSubject, +} + +impl AgentConfigurationV1 { + /// Create one agent entry in a daily configuration snapshot. + #[must_use] + pub(in crate::telemetry) fn new( + day: UtcDay, + os: OperatingSystem, + arch: Architecture, + fields: AgentConfigurationFields, + ) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::AgentConfiguration, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + agent: fields.agent, + configured: fields.configured, + os, + arch, + agent_subject: fields.agent_subject, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::{NaiveDate, TimeZone, Utc}; + + use super::super::{RowClassification, TelemetryRow, classify_row}; + use super::*; + + fn session_start_fields(session_id: Option) -> SessionStartFields { + SessionStartFields { + agent: HookAgent::Claude, + os: OperatingSystem::Linux, + arch: Architecture::X86_64, + start: SessionStartKind::Fresh, + session_id, + retention_subject: "ret_74ddf26f80ad8b58de7f03e6c632e654".parse().unwrap(), + cohort_day: CohortDay::D0, + } + } + + fn session_start_time() -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 9, 14, 2).unwrap()) + } + + #[test] + fn hook_agents_round_trip_with_contract_names() { + let cases = [ + (HookAgent::Claude, "claude"), + (HookAgent::Codex, "codex"), + (HookAgent::Copilot, "copilot"), + (HookAgent::Gemini, "gemini"), + (HookAgent::Kiro, "kiro"), + ]; + + for (agent, name) in cases { + let json = serde_json::to_string(&agent).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, agent); + } + } + + #[test] + fn hook_agent_names_match_supported_agent_names() { + let agents = [ + HookAgent::Claude, + HookAgent::Codex, + HookAgent::Copilot, + HookAgent::Gemini, + HookAgent::Kiro, + ]; + + for agent in agents { + let hook_name = serde_json::to_string(&agent).unwrap(); + let supported_name = serde_json::to_string(&SupportedAgent::from(agent)).unwrap(); + + assert_eq!(hook_name, supported_name); + } + } + + #[test] + fn supported_agents_round_trip_with_contract_names() { + let cases = [ + (SupportedAgent::Claude, "claude"), + (SupportedAgent::Codex, "codex"), + (SupportedAgent::Copilot, "copilot"), + (SupportedAgent::Gemini, "gemini"), + (SupportedAgent::Kiro, "kiro"), + (SupportedAgent::OpenCode, "opencode"), + (SupportedAgent::Goose, "goose"), + ]; + + for (agent, name) in cases { + let json = serde_json::to_string(&agent).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, agent); + } + } + + #[test] + fn project_agent_names_match_telemetry_contract_names() { + for &agent in Agent::all() { + let telemetry_name = serde_json::to_string(&SupportedAgent::from(agent)).unwrap(); + let config_name = format!(r#""{}""#, agent.config_name()); + + assert_eq!(telemetry_name, config_name); + } + } + + #[test] + fn operating_systems_round_trip_with_contract_names() { + let cases = [ + (OperatingSystem::Linux, "linux"), + (OperatingSystem::Macos, "macos"), + (OperatingSystem::Windows, "windows"), + (OperatingSystem::Other, "other"), + ]; + + for (operating_system, name) in cases { + let json = serde_json::to_string(&operating_system).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, operating_system); + } + } + + #[test] + fn operating_system_target_names_map_to_contract_buckets() { + let cases = [ + ("linux", OperatingSystem::Linux), + ("macos", OperatingSystem::Macos), + ("windows", OperatingSystem::Windows), + ("freebsd", OperatingSystem::Other), + ]; + + for (target, expected) in cases { + assert_eq!(OperatingSystem::from_target(target), expected); + } + } + + #[test] + fn current_operating_system_matches_the_compile_target() { + #[cfg(target_os = "linux")] + let expected = OperatingSystem::Linux; + #[cfg(target_os = "macos")] + let expected = OperatingSystem::Macos; + #[cfg(target_os = "windows")] + let expected = OperatingSystem::Windows; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let expected = OperatingSystem::Other; + + assert_eq!(OperatingSystem::current(), expected); + } + + #[test] + fn architectures_round_trip_with_contract_names() { + let cases = [ + (Architecture::X86_64, "x86_64"), + (Architecture::Aarch64, "aarch64"), + (Architecture::Other, "other"), + ]; + + for (architecture, name) in cases { + let json = serde_json::to_string(&architecture).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, architecture); + } + } + + #[test] + fn architecture_target_names_map_to_contract_buckets() { + let cases = [ + ("x86_64", Architecture::X86_64), + ("aarch64", Architecture::Aarch64), + ("riscv64", Architecture::Other), + ]; + + for (target, expected) in cases { + assert_eq!(Architecture::from_target(target), expected); + } + } + + #[test] + fn current_architecture_matches_the_compile_target() { + #[cfg(target_arch = "x86_64")] + let expected = Architecture::X86_64; + #[cfg(target_arch = "aarch64")] + let expected = Architecture::Aarch64; + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + let expected = Architecture::Other; + + assert_eq!(Architecture::current(), expected); + } + + #[test] + fn session_start_kinds_round_trip_with_contract_names() { + let cases = [ + (SessionStartKind::Fresh, "fresh"), + (SessionStartKind::Resumed, "resumed"), + (SessionStartKind::Unknown, "unknown"), + ]; + + for (start_kind, name) in cases { + let json = serde_json::to_string(&start_kind).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, start_kind); + } + } + + #[test] + fn agent_vocabulary_rejects_unknown_contract_names() { + let unknown = r#""future_value""#; + + let supported_agent = serde_json::from_str::(unknown); + let hook_agent = serde_json::from_str::(unknown); + let operating_system = serde_json::from_str::(unknown); + let architecture = serde_json::from_str::(unknown); + let start_kind = serde_json::from_str::(unknown); + + assert!(supported_agent.is_err()); + assert!(hook_agent.is_err()); + assert!(operating_system.is_err()); + assert!(architecture.is_err()); + assert!(start_kind.is_err()); + } + + #[test] + fn new_session_start_uses_fixed_common_fields_and_timestamp_day() { + let at = session_start_time(); + let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); + let fields = session_start_fields(Some(session_id)); + + let row = SessionStartV1::new(at, fields); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::SessionStart); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, at.day()); + assert_eq!(row.at, at); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.agent, fields.agent); + assert_eq!(row.os, fields.os); + assert_eq!(row.arch, fields.arch); + assert_eq!(row.start, fields.start); + assert_eq!(row.session_id, fields.session_id); + assert_eq!(row.retention_subject, fields.retention_subject); + assert_eq!(row.cohort_day, fields.cohort_day); + } + + #[test] + fn new_agent_configuration_uses_fixed_common_fields() { + let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let agent_subject = "agt_9255770e1679cb789796a9f9e86325c5".parse().unwrap(); + + let row = AgentConfigurationV1::new( + day, + OperatingSystem::Linux, + Architecture::X86_64, + AgentConfigurationFields { + agent: SupportedAgent::Claude, + configured: true, + agent_subject, + }, + ); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::AgentConfiguration); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.agent, SupportedAgent::Claude); + assert!(row.configured); + assert_eq!(row.os, OperatingSystem::Linux); + assert_eq!(row.arch, Architecture::X86_64); + assert_eq!(row.agent_subject, agent_subject); + } + + #[test] + fn session_start_without_session_id_classifies_and_round_trips() { + let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + + let json = serde_json::to_string(&row).unwrap(); + let value = serde_json::from_str::(&json).unwrap(); + let RowClassification::Supported(TelemetryRow::SessionStart(decoded)) = classify_row(&json) + else { + panic!("session_start without a session id was not classified as supported"); + }; + + assert_eq!(value.get("session_id"), None); + assert!(decoded.session_id.is_none()); + assert_eq!(serde_json::to_string(&decoded).unwrap(), json); + } + + #[test] + fn session_start_rejects_a_day_that_disagrees_with_its_timestamp() { + let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + let mut value = serde_json::to_value(row).unwrap(); + value["day"] = serde_json::Value::String("2026-08-04".to_owned()); + + let result = serde_json::from_value::(value); + + assert!(result.unwrap_err().to_string().contains( + "stored session-start day 2026-08-04 does not match timestamp day 2026-08-03" + )); + } +} diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs new file mode 100644 index 00000000..7d9aea23 --- /dev/null +++ b/src/telemetry/schema/mod.rs @@ -0,0 +1,947 @@ +//! Types that define telemetry's serialized data contract. +#![cfg_attr( + not(test), + expect(dead_code, reason = "the new schema is built before storage uses it.") +)] + +mod agent; +mod resolution; + +use std::{fmt, num::NonZeroU64, sync::LazyLock}; + +use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; +use semver::Version; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{DeserializeOwned, Error as _}, +}; +use uuid::Uuid; + +use agent::{AgentConfigurationV1, SessionStartV1}; +use resolution::ResolutionSummaryV1; + +/// Random identifier for one telemetry row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(super) struct EventId(Uuid); + +impl EventId { + /// Generate a new random version 4 UUID. + pub(super) fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +/// Positive schema version carried by a telemetry row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(super) struct SchemaVersion(NonZeroU64); + +impl SchemaVersion { + /// Initial version of every telemetry row kind. + pub(super) const V1: Self = Self(NonZeroU64::MIN); +} + +/// Kind of row stored in the telemetry data files. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum RowKind { + SessionStart, + AgentConfiguration, + ResolutionSummary, + PackageResolution, + ExtensionResolution, + HookMetrics, + PluginHookMetrics, + ExtensionInvocationMetrics, + Command, + StorageLimit, +} + +/// Result of interpreting one physical telemetry line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum RowClassification { + Supported(TelemetryRow), + UnknownSchema, + Invalid, + Malformed, +} + +/// Telemetry row understood by this version of Symposium. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum TelemetryRow { + SessionStart(SessionStartV1), + AgentConfiguration(AgentConfigurationV1), + ResolutionSummary(ResolutionSummaryV1), + StorageLimit(StorageLimitV1), +} + +impl Serialize for TelemetryRow { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::SessionStart(row) => row.serialize(serializer), + Self::AgentConfiguration(row) => row.serialize(serializer), + Self::ResolutionSummary(row) => row.serialize(serializer), + Self::StorageLimit(row) => row.serialize(serializer), + } + } +} + +/// Lenient header used to select a complete versioned row schema. +#[derive(Debug, Deserialize)] +struct RowEnvelope { + #[serde(rename = "v")] + version: u64, + kind: String, +} + +fn deserialize_version_one<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = SchemaVersion::deserialize(deserializer)?; + + if version != SchemaVersion::V1 { + return Err(D::Error::custom(format_args!( + "expected schema version 1, found {}", + version.0 + ))); + } + + Ok(version) +} + +/// UTC calendar day used to partition telemetry rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct UtcDay(NaiveDate); + +impl UtcDay { + /// Wrap a calendar date known to be in UTC. + #[must_use] + pub(super) const fn from_date(date: NaiveDate) -> Self { + Self(date) + } + + /// Return the signed number of calendar days from `earlier` to this day. + #[must_use] + pub(super) fn days_since(self, earlier: Self) -> i64 { + self.0.signed_duration_since(earlier.0).num_days() + } +} + +impl fmt::Display for UtcDay { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0.format("%Y-%m-%d")) + } +} + +impl Serialize for UtcDay { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for UtcDay { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + + if !has_utc_day_shape(&value) { + return Err(D::Error::custom("expected a UTC day in YYYY-MM-DD form")); + } + + let date = NaiveDate::parse_from_str(&value, "%Y-%m-%d").map_err(D::Error::custom)?; + Ok(Self(date)) + } +} + +fn has_utc_day_shape(value: &str) -> bool { + let bytes = value.as_bytes(); + + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[..4].iter().all(u8::is_ascii_digit) + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[8..].iter().all(u8::is_ascii_digit) +} + +/// Zero-based day within one D0-D30 observed-session return cohort. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct CohortDay(u8); + +impl CohortDay { + /// The first observed day in a return cohort. + pub(super) const D0: Self = Self(0); + + /// The last day in a return cohort. + pub(super) const D30: Self = Self(30); + + /// Return the zero-based day number. + #[must_use] + pub(super) const fn get(self) -> u8 { + self.0 + } +} + +/// A day number outside the D0-D30 return-cohort range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct CohortDayOutOfRange(i64); + +impl fmt::Display for CohortDayOutOfRange { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "cohort day must be from 0 through {}, found {}", + CohortDay::D30.get(), + self.0 + ) + } +} + +impl std::error::Error for CohortDayOutOfRange {} + +impl TryFrom for CohortDay { + type Error = CohortDayOutOfRange; + + fn try_from(value: i64) -> Result { + let last_cohort_day = i64::from(Self::D30.get()); + if !(0..=last_cohort_day).contains(&value) { + return Err(CohortDayOutOfRange(value)); + } + + let value = u8::try_from(value).expect("BUG: a validated return-cohort day must fit in u8"); + Ok(Self(value)) + } +} + +impl Serialize for CohortDay { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u8(self.0) + } +} + +impl<'de> Deserialize<'de> for CohortDay { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = i64::deserialize(deserializer)?; + Self::try_from(value).map_err(D::Error::custom) + } +} + +/// RFC 3339 UTC timestamp with no subsecond precision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct UtcSecond(DateTime); + +impl UtcSecond { + /// Convert a UTC timestamp, discarding any subsecond precision. + pub(super) fn from_datetime(timestamp: DateTime) -> Self { + Self( + timestamp + .with_nanosecond(0) + .expect("BUG: zero nanoseconds must be valid for a UTC timestamp"), + ) + } + + /// Return the UTC calendar day containing this timestamp. + pub(super) fn day(&self) -> UtcDay { + UtcDay::from_date(self.0.date_naive()) + } +} + +impl Serialize for UtcSecond { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_rfc3339_opts(SecondsFormat::Secs, true)) + } +} + +impl<'de> Deserialize<'de> for UtcSecond { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let timestamp = DateTime::parse_from_rfc3339(&value).map_err(D::Error::custom)?; + + if timestamp.offset().local_minus_utc() != 0 { + return Err(D::Error::custom("expected a UTC timestamp")); + } + + let canonical_z = timestamp.to_rfc3339_opts(SecondsFormat::Secs, true); + let canonical_offset = timestamp.to_rfc3339_opts(SecondsFormat::Secs, false); + + if value != canonical_z && value != canonical_offset { + return Err(D::Error::custom( + "expected a UTC timestamp with whole-second precision", + )); + } + + Ok(Self(timestamp.with_timezone(&Utc))) + } +} + +/// Version of Symposium that produced a telemetry row. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct SymposiumVersion(Version); + +static CURRENT_SYMPOSIUM_VERSION: LazyLock = LazyLock::new(|| { + Version::parse(env!("CARGO_PKG_VERSION")) + .expect("BUG: Cargo package version must be valid semantic versioning") +}); + +impl SymposiumVersion { + /// Return the version of the running Symposium binary. + pub(super) fn current() -> Self { + Self(CURRENT_SYMPOSIUM_VERSION.clone()) + } +} + +impl Serialize for SymposiumVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for SymposiumVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Version::parse(&value).map(Self).map_err(D::Error::custom) + } +} + +// Versioned rows repeat their common fields deliberately. Serde does not support +// combining flattened structs with strict unknown-field rejection. + +/// Version 1 marker recording that the daily storage limit rejected an operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct StorageLimitV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + dropped_operation: DroppedOperation, +} + +impl StorageLimitV1 { + /// Create a marker for an operation rejected by the daily storage limit. + #[must_use] + pub(super) fn new(day: UtcDay, dropped_operation: DroppedOperation) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::StorageLimit, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + dropped_operation, + } + } +} + +/// Operation whose telemetry did not fit in the daily storage allowance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum DroppedOperation { + SessionStart, + ManualSync, + Use, + Remove, + Init, + Configuration, + Command, +} + +/// Classify a physical JSONL line and return typed data only for a known schema. +/// +/// This is the only supported entry point for reading typed rows. Individual +/// versioned row types assume the envelope dispatch has already matched their +/// `kind` and must not be deserialized directly. +pub(super) fn classify_row(line: &str) -> RowClassification { + let Ok(envelope) = serde_json::from_str::(line) else { + return RowClassification::Malformed; + }; + + match (envelope.kind.as_str(), envelope.version) { + ("session_start", 1) => deserialize_supported_row(line, TelemetryRow::SessionStart), + ("agent_configuration", 1) => { + deserialize_supported_row(line, TelemetryRow::AgentConfiguration) + } + ("resolution_summary", 1) => { + deserialize_supported_row(line, TelemetryRow::ResolutionSummary) + } + ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), + _ => RowClassification::UnknownSchema, + } +} + +fn deserialize_supported_row(line: &str, wrap: fn(T) -> TelemetryRow) -> RowClassification +where + T: DeserializeOwned, +{ + match serde_json::from_str(line) { + Ok(row) => RowClassification::Supported(wrap(row)), + Err(_) => RowClassification::Invalid, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RECORDED_DATA: &str = + include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); + + fn example_row(requested_kind: &str) -> &'static str { + let (_, after_fence) = RECORDED_DATA + .split_once("```jsonl") + .expect("recorded-data contract must contain a JSONL example block"); + let (example_block, _) = after_fence + .split_once("```") + .expect("recorded-data JSONL example block must have a closing fence"); + + example_block + .lines() + .filter_map(|line| { + serde_json::from_str::(line) + .ok() + .map(|envelope| (line, envelope)) + }) + .find_map(|(line, envelope)| (envelope.kind == requested_kind).then_some(line)) + .unwrap_or_else(|| panic!("missing {requested_kind} example in recorded-data contract")) + } + + #[test] + fn new_event_id_is_uuid_v4() { + let event_id = EventId::new(); + + assert_eq!(event_id.0.get_version(), Some(uuid::Version::Random)); + } + + #[test] + fn event_id_serializes_as_uuid_string() { + let uuid = Uuid::parse_str("9f2c41b6-495e-4c88-a22b-c597f8102aed").unwrap(); + let event_id = EventId(uuid); + + let json = serde_json::to_string(&event_id).unwrap(); + + assert_eq!(json, r#""9f2c41b6-495e-4c88-a22b-c597f8102aed""#); + } + + #[test] + fn event_id_round_trips_through_json() { + let event_id = EventId::new(); + + let json = serde_json::to_string(&event_id).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(decoded, event_id); + } + + #[test] + fn event_id_rejects_invalid_uuid() { + let result = serde_json::from_str::(r#""not-a-uuid""#); + + assert!(result.is_err()); + } + + #[test] + fn schema_version_one_serializes_as_number() { + let json = serde_json::to_string(&SchemaVersion::V1).unwrap(); + + assert_eq!(json, "1"); + } + + #[test] + fn schema_version_accepts_future_positive_value() { + let version = serde_json::from_str::("2").unwrap(); + + assert_eq!(version.0.get(), 2); + } + + #[test] + fn schema_version_rejects_invalid_values() { + for invalid in ["0", "-1", "1.5", r#""1""#] { + assert!( + serde_json::from_str::(invalid).is_err(), + "accepted invalid schema version {invalid}" + ); + } + } + + #[test] + fn row_kinds_round_trip_with_contract_names() { + let cases = [ + (RowKind::SessionStart, "session_start"), + (RowKind::AgentConfiguration, "agent_configuration"), + (RowKind::ResolutionSummary, "resolution_summary"), + (RowKind::PackageResolution, "package_resolution"), + (RowKind::ExtensionResolution, "extension_resolution"), + (RowKind::HookMetrics, "hook_metrics"), + (RowKind::PluginHookMetrics, "plugin_hook_metrics"), + ( + RowKind::ExtensionInvocationMetrics, + "extension_invocation_metrics", + ), + (RowKind::Command, "command"), + (RowKind::StorageLimit, "storage_limit"), + ]; + + for (kind, name) in cases { + let json = serde_json::to_string(&kind).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, kind); + } + } + + #[test] + fn row_kind_rejects_unknown_name() { + let result = serde_json::from_str::(r#""future_kind""#); + + assert!(result.is_err()); + } + + #[test] + fn utc_day_serializes_as_calendar_date() { + let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + let json = serde_json::to_string(&day).unwrap(); + + assert_eq!(json, r#""2026-08-03""#); + } + + #[test] + fn utc_day_displays_as_calendar_date() { + let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + let displayed = day.to_string(); + + assert_eq!(displayed, "2026-08-03"); + } + + #[test] + fn utc_day_round_trips_through_json() { + let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + let json = serde_json::to_string(&day).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(decoded, day); + } + + #[test] + fn utc_day_rejects_invalid_calendar_date() { + let result = serde_json::from_str::(r#""2026-02-30""#); + + assert!(result.is_err()); + } + + #[test] + fn utc_day_rejects_noncanonical_shapes() { + for value in ["2026-8-03", "2026-08-3", "+2026-08-03", "2026/08/03"] { + let json = format!(r#""{value}""#); + + assert!( + serde_json::from_str::(&json).is_err(), + "accepted noncanonical UTC day {value}" + ); + } + } + + #[test] + fn utc_day_difference_is_signed() { + let earlier = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 11).unwrap()); + let later = UtcDay(NaiveDate::from_ymd_opt(2026, 9, 10).unwrap()); + + assert_eq!(later.days_since(earlier), 30); + assert_eq!(earlier.days_since(later), -30); + } + + #[test] + fn cohort_day_accepts_d0_through_d30() { + for value in 0_u8..=30 { + let cohort_day = CohortDay::try_from(i64::from(value)).unwrap(); + + assert_eq!(cohort_day.get(), value); + } + + assert_eq!(CohortDay::D0.get(), 0); + assert_eq!(CohortDay::D30.get(), 30); + } + + #[test] + fn cohort_day_rejects_values_outside_d0_through_d30() { + for value in [-1_i64, 31] { + let error = CohortDay::try_from(value).unwrap_err(); + + assert_eq!( + error.to_string(), + format!("cohort day must be from 0 through 30, found {value}") + ); + } + } + + #[test] + fn cohort_day_round_trips_as_a_json_number() { + for value in [0_i64, 1, 30] { + let cohort_day = CohortDay::try_from(value).unwrap(); + + let json = serde_json::to_string(&cohort_day).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, value.to_string()); + assert_eq!(decoded, cohort_day); + } + } + + #[test] + fn cohort_day_rejects_invalid_json_values() { + for invalid in ["-1", "31", "255", "256", "1.5", r#""1""#] { + assert!( + serde_json::from_str::(invalid).is_err(), + "accepted invalid cohort day {invalid}" + ); + } + } + + #[test] + fn utc_second_constructor_removes_subsecond_precision() { + let timestamp = DateTime::parse_from_rfc3339("2026-08-03T09:14:02.987Z") + .unwrap() + .with_timezone(&Utc); + + let utc_second = UtcSecond::from_datetime(timestamp); + + assert_eq!(utc_second.0.nanosecond(), 0); + } + + #[test] + fn utc_second_serializes_as_canonical_utc() { + let timestamp = DateTime::parse_from_rfc3339("2026-08-03T09:14:02Z") + .unwrap() + .with_timezone(&Utc); + let utc_second = UtcSecond::from_datetime(timestamp); + + let json = serde_json::to_string(&utc_second).unwrap(); + + assert_eq!(json, r#""2026-08-03T09:14:02Z""#); + } + + #[test] + fn utc_second_accepts_zero_offset() { + let utc_second = + serde_json::from_str::(r#""2026-08-03T09:14:02+00:00""#).unwrap(); + + let json = serde_json::to_string(&utc_second).unwrap(); + + assert_eq!(json, r#""2026-08-03T09:14:02Z""#); + } + + #[test] + fn utc_second_rejects_fractional_precision() { + let result = serde_json::from_str::(r#""2026-08-03T09:14:02.000Z""#); + + assert!(result.is_err()); + } + + #[test] + fn utc_second_rejects_non_utc_offset() { + let result = serde_json::from_str::(r#""2026-08-03T12:14:02+03:00""#); + + assert!(result.is_err()); + } + + #[test] + fn utc_second_rejects_unknown_local_offset() { + let result = serde_json::from_str::(r#""2026-08-03T09:14:02-00:00""#); + + assert!(result.is_err()); + } + + #[test] + fn utc_second_returns_its_utc_day() { + let utc_second = serde_json::from_str::(r#""2026-08-03T23:59:59Z""#).unwrap(); + + assert_eq!( + utc_second.day(), + UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()) + ); + } + + #[test] + fn current_symposium_version_uses_package_version() { + let version = SymposiumVersion::current(); + + assert_eq!(version.0.to_string(), env!("CARGO_PKG_VERSION")); + } + + #[test] + fn symposium_version_serializes_as_semver_string() { + let version = SymposiumVersion(Version::new(1, 2, 3)); + + let json = serde_json::to_string(&version).unwrap(); + + assert_eq!(json, r#""1.2.3""#); + } + + #[test] + fn symposium_version_round_trips_prerelease_and_build_metadata() { + let version = SymposiumVersion(Version::parse("1.2.3-beta.1+build.7").unwrap()); + + let json = serde_json::to_string(&version).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(decoded, version); + } + + #[test] + fn symposium_version_rejects_invalid_semver() { + let result = serde_json::from_str::(r#""not-a-version""#); + + assert!(result.is_err()); + } + + #[test] + fn storage_limit_example_round_trips() { + let example = example_row("storage_limit"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("storage_limit contract example was not classified as supported"); + }; + + // Compared as text, not as `Value`: a `Value` map sorts its keys, which + // would stop this from pinning the contract's field order. + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn session_start_example_round_trips() { + let example = example_row("session_start"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("session_start contract example was not classified as supported"); + }; + + // Compared as text, not as `Value`: a `Value` map sorts its keys, which + // would stop this from pinning the contract's field order. + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn unsupported_session_start_version_is_unknown_schema() { + let example = example_row("session_start"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn session_start_with_unknown_field_is_invalid() { + let example = example_row("session_start"); + let json = example.replacen(r#""agent""#, r#""future_field":true,"agent""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn session_start_with_a_timestamp_from_another_day_is_invalid() { + let example = example_row("session_start"); + let mut value = serde_json::from_str::(example).unwrap(); + value["day"] = serde_json::Value::String("2026-08-04".to_owned()); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn agent_configuration_example_round_trips() { + let example = example_row("agent_configuration"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("agent_configuration contract example was not classified as supported"); + }; + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn resolution_summary_example_round_trips() { + let example = example_row("resolution_summary"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("resolution_summary contract example was not classified as supported"); + }; + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn unsupported_resolution_summary_version_is_unknown_schema() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn resolution_summary_with_unknown_field_is_invalid() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""trigger""#, r#""future_field":true,"trigger""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn resolution_summary_with_mismatched_unnamed_count_is_invalid() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""unnamed_packages":1"#, r#""unnamed_packages":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn unsupported_agent_configuration_version_is_unknown_schema() { + let example = example_row("agent_configuration"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn agent_configuration_with_unknown_field_is_invalid() { + let example = example_row("agent_configuration"); + let json = example.replacen(r#""configured""#, r#""future_field":true,"configured""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn new_storage_limit_uses_fixed_common_fields() { + let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + let row = StorageLimitV1::new(day, DroppedOperation::ManualSync); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::StorageLimit); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.dropped_operation, DroppedOperation::ManualSync); + } + + #[test] + fn dropped_operations_round_trip_with_contract_names() { + let cases = [ + (DroppedOperation::SessionStart, "session_start"), + (DroppedOperation::ManualSync, "manual_sync"), + (DroppedOperation::Use, "use"), + (DroppedOperation::Remove, "remove"), + (DroppedOperation::Init, "init"), + (DroppedOperation::Configuration, "configuration"), + (DroppedOperation::Command, "command"), + ]; + + for (operation, name) in cases { + let json = serde_json::to_string(&operation).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, operation); + } + } + + #[test] + fn unsupported_storage_limit_versions_are_unknown_schema() { + let example = example_row("storage_limit"); + + for version in [0, 2] { + let json = example.replacen(r#""v":1"#, &format!(r#""v":{version}"#), 1); + + assert_eq!(classify_row(&json), RowClassification::UnknownSchema); + } + } + + #[test] + fn unknown_row_kind_is_unknown_schema() { + let json = r#"{"v":1,"kind":"future_kind","future_field":true}"#; + + let classification = classify_row(json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn recognized_schema_with_unknown_field_is_invalid() { + let example = example_row("storage_limit"); + let json = example.replacen( + r#""dropped_operation""#, + r#""at":"2026-08-03T10:02:11Z","dropped_operation""#, + 1, + ); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn unusable_row_envelopes_are_malformed() { + let cases = [ + "not JSON", + "[]", + "{}", + r#"{"v":"1","kind":"storage_limit"}"#, + r#"{"v":1}"#, + r#"{"v":1,"v":2,"kind":"storage_limit"}"#, + ]; + + for line in cases { + assert_eq!( + classify_row(line), + RowClassification::Malformed, + "accepted unusable envelope {line}" + ); + } + } +} diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs new file mode 100644 index 00000000..4f6904ce --- /dev/null +++ b/src/telemetry/schema/resolution.rs @@ -0,0 +1,708 @@ +//! Schema types for resolution telemetry. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, + deserialize_version_one, +}; +use crate::telemetry::identity::SessionId; + +/// Operation that caused a full resolution and sync. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ResolutionTrigger { + SessionStart, + ManualSync, + Use, + Remove, +} + +impl From for DroppedOperation { + fn from(trigger: ResolutionTrigger) -> Self { + match trigger { + ResolutionTrigger::SessionStart => Self::SessionStart, + ResolutionTrigger::ManualSync => Self::ManualSync, + ResolutionTrigger::Use => Self::Use, + ResolutionTrigger::Remove => Self::Remove, + } + } +} + +/// Result of a completed full resolution and sync. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ResolutionOutcome { + Ok, + Partial, + Error, +} + +/// Public package ecosystem approved for version 1 telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum PackageEcosystem { + Cargo, +} + +/// Kind of extension content contributed by one public package. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ExtensionMatch { + Public, + UnnamedOnly, + None, +} + +/// One reason that a package coordinate cannot be named. +/// +/// The public-identity policy selects this reason after applying source +/// provenance precedence. Recording accepts one selected reason at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(in crate::telemetry) enum UnnamedPackageReason { + PrivateRegistry, + Git, + Path, + Workspace, + UnknownSource, + InvalidCoordinate, +} + +/// Mutually exclusive reasons that package coordinates cannot be named. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct UnnamedPackageReasons { + private_registry: u64, + git: u64, + path: u64, + workspace: u64, + unknown_source: u64, + invalid_coordinate: u64, +} + +impl UnnamedPackageReasons { + /// Increment exactly one reason counter. + /// + /// # Errors + /// + /// Returns [`ResolutionSummaryError::UnnamedPackageCountOverflow`] when + /// the selected counter cannot be incremented. + #[must_use = "counter overflow must drop the containing telemetry batch"] + pub(in crate::telemetry) fn checked_record( + &mut self, + reason: UnnamedPackageReason, + ) -> Result<(), ResolutionSummaryError> { + let counter = match reason { + UnnamedPackageReason::PrivateRegistry => &mut self.private_registry, + UnnamedPackageReason::Git => &mut self.git, + UnnamedPackageReason::Path => &mut self.path, + UnnamedPackageReason::Workspace => &mut self.workspace, + UnnamedPackageReason::UnknownSource => &mut self.unknown_source, + UnnamedPackageReason::InvalidCoordinate => &mut self.invalid_coordinate, + }; + + *counter = counter + .checked_add(1) + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + Ok(()) + } + + /// Return the total number of unnamed packages, or `None` on overflow. + #[must_use] + fn checked_total(self) -> Option { + [ + self.private_registry, + self.git, + self.path, + self.workspace, + self.unknown_source, + self.invalid_coordinate, + ] + .into_iter() + .try_fold(0_u64, u64::checked_add) + } +} + +/// Fields supplied by a completed full resolution and sync. +/// +/// These fields are repeated on [`ResolutionSummaryV1`] because flattening +/// this constructor input would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct ResolutionSummaryFields { + pub(in crate::telemetry) trigger: ResolutionTrigger, + pub(in crate::telemetry) outcome: ResolutionOutcome, + pub(in crate::telemetry) duration_ms: u64, + pub(in crate::telemetry) public_packages: u64, + pub(in crate::telemetry) unnamed_package_reasons: UnnamedPackageReasons, + pub(in crate::telemetry) plugins: u64, + pub(in crate::telemetry) skills: u64, + pub(in crate::telemetry) installed: u64, + pub(in crate::telemetry) updated: u64, + pub(in crate::telemetry) reaped: u64, + pub(in crate::telemetry) session_id: Option, +} + +/// Version 1 summary of one completed full resolution and sync. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawResolutionSummaryV1")] +pub(in crate::telemetry) struct ResolutionSummaryV1 { + #[serde(rename = "v")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + trigger: ResolutionTrigger, + outcome: ResolutionOutcome, + duration_ms: u64, + public_packages: u64, + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + plugins: u64, + skills: u64, + installed: u64, + updated: u64, + reaped: u64, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, +} + +impl ResolutionSummaryV1 { + /// Create a summary for one completed full resolution and sync. + /// + /// # Errors + /// + /// Returns [`ResolutionSummaryError::UnnamedPackageCountOverflow`] when + /// the unnamed-package reason counters cannot be represented by `u64`. + pub(in crate::telemetry) fn new( + day: UtcDay, + fields: ResolutionSummaryFields, + ) -> Result { + let unnamed_packages = fields + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + Ok(Self { + version: SchemaVersion::V1, + kind: RowKind::ResolutionSummary, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + trigger: fields.trigger, + outcome: fields.outcome, + duration_ms: fields.duration_ms, + public_packages: fields.public_packages, + unnamed_packages, + unnamed_package_reasons: fields.unnamed_package_reasons, + plugins: fields.plugins, + skills: fields.skills, + installed: fields.installed, + updated: fields.updated, + reaped: fields.reaped, + session_id: fields.session_id, + }) + } +} + +/// Invalid relationship between fields in a resolution summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum ResolutionSummaryError { + UnnamedPackageCountOverflow, + UnnamedPackageCountMismatch { stored: u64, derived: u64 }, +} + +impl fmt::Display for ResolutionSummaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnnamedPackageCountOverflow => { + formatter.write_str("unnamed package reason counters overflow u64") + } + Self::UnnamedPackageCountMismatch { stored, derived } => write!( + formatter, + "stored unnamed package count {stored} does not match derived reason count {derived}" + ), + } + } +} + +impl std::error::Error for ResolutionSummaryError {} + +/// Strict wire representation validated before becoming a resolution summary. +/// +/// Serde's `try_from` deserializes this type rather than the outer row, so its +/// version and unknown-field checks are deliberately declared here. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawResolutionSummaryV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + trigger: ResolutionTrigger, + outcome: ResolutionOutcome, + duration_ms: u64, + public_packages: u64, + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + plugins: u64, + skills: u64, + installed: u64, + updated: u64, + reaped: u64, + session_id: Option, +} + +impl TryFrom for ResolutionSummaryV1 { + type Error = ResolutionSummaryError; + + fn try_from(raw: RawResolutionSummaryV1) -> Result { + let derived = raw + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + if raw.unnamed_packages != derived { + return Err(ResolutionSummaryError::UnnamedPackageCountMismatch { + stored: raw.unnamed_packages, + derived, + }); + } + + Ok(Self { + version: raw.version, + kind: raw.kind, + event_id: raw.event_id, + day: raw.day, + symposium: raw.symposium, + trigger: raw.trigger, + outcome: raw.outcome, + duration_ms: raw.duration_ms, + public_packages: raw.public_packages, + unnamed_packages: raw.unnamed_packages, + unnamed_package_reasons: raw.unnamed_package_reasons, + plugins: raw.plugins, + skills: raw.skills, + installed: raw.installed, + updated: raw.updated, + reaped: raw.reaped, + session_id: raw.session_id, + }) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::*; + + fn example_reasons() -> UnnamedPackageReasons { + UnnamedPackageReasons { + private_registry: 1, + git: 2, + path: 3, + workspace: 4, + unknown_source: 5, + invalid_coordinate: 6, + } + } + + fn summary_day() -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()) + } + + fn summary_fields(session_id: Option) -> ResolutionSummaryFields { + ResolutionSummaryFields { + trigger: ResolutionTrigger::SessionStart, + outcome: ResolutionOutcome::Ok, + duration_ms: 142, + public_packages: 2, + unnamed_package_reasons: UnnamedPackageReasons { + private_registry: 1, + ..UnnamedPackageReasons::default() + }, + plugins: 1, + skills: 1, + installed: 2, + updated: 0, + reaped: 0, + session_id, + } + } + + fn raw_summary( + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + ) -> RawResolutionSummaryV1 { + RawResolutionSummaryV1 { + version: SchemaVersion::V1, + kind: RowKind::ResolutionSummary, + event_id: EventId::new(), + day: summary_day(), + symposium: SymposiumVersion::current(), + trigger: ResolutionTrigger::SessionStart, + outcome: ResolutionOutcome::Ok, + duration_ms: 142, + public_packages: 2, + unnamed_packages, + unnamed_package_reasons, + plugins: 1, + skills: 1, + installed: 2, + updated: 0, + reaped: 0, + session_id: None, + } + } + + #[test] + fn new_resolution_summary_derives_fixed_fields_and_unnamed_total() { + let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); + let fields = summary_fields(Some(session_id)); + + let row = ResolutionSummaryV1::new(summary_day(), fields).unwrap(); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::ResolutionSummary); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, summary_day()); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.trigger, fields.trigger); + assert_eq!(row.outcome, fields.outcome); + assert_eq!(row.duration_ms, fields.duration_ms); + assert_eq!(row.public_packages, fields.public_packages); + assert_eq!(row.unnamed_packages, 1); + assert_eq!(row.unnamed_package_reasons, fields.unnamed_package_reasons); + assert_eq!(row.plugins, fields.plugins); + assert_eq!(row.skills, fields.skills); + assert_eq!(row.installed, fields.installed); + assert_eq!(row.updated, fields.updated); + assert_eq!(row.reaped, fields.reaped); + assert_eq!(row.session_id, fields.session_id); + } + + #[test] + fn new_resolution_summary_reports_reason_counter_overflow() { + let mut fields = summary_fields(None); + fields.unnamed_package_reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + git: 1, + ..UnnamedPackageReasons::default() + }; + + let result = ResolutionSummaryV1::new(summary_day(), fields); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + } + + #[test] + fn direct_resolution_summary_deserialization_rejects_future_version() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let future = json.replacen(r#""v":1"#, r#""v":2"#, 1); + + let result = serde_json::from_str::(&future); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_rejects_unknown_fields() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let unknown = json.replacen(r#""trigger""#, r#""future_field":true,"trigger""#, 1); + + let result = serde_json::from_str::(&unknown); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_requires_every_top_level_field() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let missing = json.replacen(r#","plugins":1"#, "", 1); + + let result = serde_json::from_str::(&missing); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_json_rejects_mismatched_unnamed_count() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let mismatched = json.replacen(r#""unnamed_packages":1"#, r#""unnamed_packages":2"#, 1); + + let error = serde_json::from_str::(&mismatched).unwrap_err(); + + assert!( + error + .to_string() + .contains("stored unnamed package count 2 does not match derived reason count 1") + ); + } + + #[test] + fn resolution_summary_rejects_mismatched_unnamed_count() { + let raw = raw_summary(20, example_reasons()); + + let result = ResolutionSummaryV1::try_from(raw); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountMismatch { + stored: 20, + derived: 21, + }) + ); + } + + #[test] + fn resolution_summary_validation_reports_reason_counter_overflow() { + let reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + git: 1, + ..UnnamedPackageReasons::default() + }; + let raw = raw_summary(u64::MAX, reasons); + + let result = ResolutionSummaryV1::try_from(raw); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + } + + #[test] + fn resolution_summary_without_session_id_round_trips_without_the_field() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + + let json = serde_json::to_string(&row).unwrap(); + let value = serde_json::from_str::(&json).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(value.get("session_id"), None); + assert_eq!(decoded, row); + } + + #[test] + fn resolution_triggers_round_trip_and_match_storage_names() { + let cases = [ + (ResolutionTrigger::SessionStart, "session_start"), + (ResolutionTrigger::ManualSync, "manual_sync"), + (ResolutionTrigger::Use, "use"), + (ResolutionTrigger::Remove, "remove"), + ]; + + for (trigger, name) in cases { + let json = serde_json::to_string(&trigger).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + let dropped_operation = + serde_json::to_string(&DroppedOperation::from(trigger)).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, trigger); + assert_eq!(dropped_operation, json); + } + } + + #[test] + fn resolution_outcomes_round_trip_with_contract_names() { + let cases = [ + (ResolutionOutcome::Ok, "ok"), + (ResolutionOutcome::Partial, "partial"), + (ResolutionOutcome::Error, "error"), + ]; + + for (outcome, name) in cases { + let json = serde_json::to_string(&outcome).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, outcome); + } + } + + #[test] + fn package_ecosystems_round_trip_with_contract_names() { + let cases = [(PackageEcosystem::Cargo, "cargo")]; + + for (ecosystem, name) in cases { + let json = serde_json::to_string(&ecosystem).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, ecosystem); + } + } + + #[test] + fn extension_matches_round_trip_with_contract_names() { + let cases = [ + (ExtensionMatch::Public, "public"), + (ExtensionMatch::UnnamedOnly, "unnamed_only"), + (ExtensionMatch::None, "none"), + ]; + + for (extension_match, name) in cases { + let json = serde_json::to_string(&extension_match).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, extension_match); + } + } + + #[test] + fn resolution_vocabulary_rejects_unknown_contract_names() { + let unknown = r#""future_value""#; + + let trigger = serde_json::from_str::(unknown); + let outcome = serde_json::from_str::(unknown); + let ecosystem = serde_json::from_str::(unknown); + let extension_match = serde_json::from_str::(unknown); + + assert!(trigger.is_err()); + assert!(outcome.is_err()); + assert!(ecosystem.is_err()); + assert!(extension_match.is_err()); + } + + #[test] + fn unnamed_package_reasons_round_trip_in_contract_order() { + let reasons = example_reasons(); + + let json = serde_json::to_string(&reasons).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + r#"{"private_registry":1,"git":2,"path":3,"workspace":4,"unknown_source":5,"invalid_coordinate":6}"# + ); + assert_eq!(decoded, reasons); + } + + #[test] + fn unnamed_package_reasons_reject_unknown_fields() { + let json = r#"{"private_registry":1,"git":2,"path":3,"workspace":4,"unknown_source":5,"invalid_coordinate":6,"future_source":7}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn unnamed_package_reasons_require_every_contract_field() { + let json = r#"{"private_registry":1,"git":2,"path":3,"workspace":4,"unknown_source":5}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn unnamed_package_reason_total_uses_checked_arithmetic() { + let reasons = example_reasons(); + + let total = reasons.checked_total(); + + assert_eq!(total, Some(21)); + } + + #[test] + fn recording_a_reason_increments_only_its_counter() { + let cases = [ + ( + UnnamedPackageReason::PrivateRegistry, + UnnamedPackageReasons { + private_registry: 1, + ..UnnamedPackageReasons::default() + }, + ), + ( + UnnamedPackageReason::Git, + UnnamedPackageReasons { + git: 1, + ..UnnamedPackageReasons::default() + }, + ), + ( + UnnamedPackageReason::Path, + UnnamedPackageReasons { + path: 1, + ..UnnamedPackageReasons::default() + }, + ), + ( + UnnamedPackageReason::Workspace, + UnnamedPackageReasons { + workspace: 1, + ..UnnamedPackageReasons::default() + }, + ), + ( + UnnamedPackageReason::UnknownSource, + UnnamedPackageReasons { + unknown_source: 1, + ..UnnamedPackageReasons::default() + }, + ), + ( + UnnamedPackageReason::InvalidCoordinate, + UnnamedPackageReasons { + invalid_coordinate: 1, + ..UnnamedPackageReasons::default() + }, + ), + ]; + + for (reason, expected) in cases { + let mut reasons = UnnamedPackageReasons::default(); + + let recorded = reasons.checked_record(reason); + + assert_eq!(recorded, Ok(())); + assert_eq!(reasons, expected); + } + } + + #[test] + fn recording_a_reason_rejects_overflow_without_mutation() { + let mut reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + ..UnnamedPackageReasons::default() + }; + let before = reasons; + + let recorded = reasons.checked_record(UnnamedPackageReason::PrivateRegistry); + + assert_eq!( + recorded, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + assert_eq!(reasons, before); + } + + #[test] + fn unnamed_package_reason_total_rejects_overflow() { + let reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + git: 1, + ..UnnamedPackageReasons::default() + }; + + let total = reasons.checked_total(); + + assert_eq!(total, None); + } +} diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs new file mode 100644 index 00000000..96408b16 --- /dev/null +++ b/src/telemetry/state/lifecycle.rs @@ -0,0 +1,555 @@ +//! Identity-window and return-cohort transitions in private telemetry state. + +use std::fmt; + +use super::{IdentityState, TelemetryStateV1}; +use crate::telemetry::{ + identity::IdentityKey, + schema::{CohortDay, UtcDay}, +}; + +/// Exclusive length of an identifier window in UTC-day positions. +/// +/// Offsets 0 through 29 remain in the window; unlike the cohort's inclusive +/// [`CohortDay::D30`] bound, offset 30 starts a new window. +const IDENTIFIER_WINDOW_DAYS: i64 = 30; + +impl TelemetryStateV1 { + /// Rotate future identifiers and begin a new identifier window. + /// + /// `identifier_window_anchor` must be the later of the current UTC day and + /// the durable latest-opened-day high-water mark. Unlike a stale session + /// observation, an explicit reset is clamped to that high-water mark rather + /// than dropped. The next observed session starts a new return cohort at + /// D0. + /// + /// The selected anchor may precede the stored window anchor after a clock + /// rollback. That is intentional: rotating the key severs the previous + /// identity scope, so reset does not compare the new anchor with the old + /// one. + /// + /// The storage-level reset must preserve the durable high-water mark and + /// clear pending keyed session-count sets once those sibling state sections + /// are added. + /// + /// Key generation completes before any state changes, so a failure leaves + /// the existing key and anchors intact. + /// + /// # Errors + /// + /// Returns an error when the operating system cannot generate a secret key. + pub(super) fn reset_identifiers( + &mut self, + identifier_window_anchor: UtcDay, + ) -> Result<(), getrandom::Error> { + self.reset_identifiers_with(identifier_window_anchor, getrandom::fill) + } + + /// Reset identifiers using a caller-provided source of key bytes. + /// + /// # Errors + /// + /// Returns the source error without changing state if key generation fails. + fn reset_identifiers_with( + &mut self, + identifier_window_anchor: UtcDay, + fill_key: impl FnOnce(&mut [u8]) -> Result<(), E>, + ) -> Result<(), E> { + let key = IdentityKey::generate_with(fill_key)?; + self.identity = IdentityState { + key, + identifier_window_anchor, + return_cohort_anchor: None, + }; + Ok(()) + } + + /// Observe a session on a day accepted by the monotonic clock policy. + /// + /// This selects the identifier window and return cohort before mutating + /// either anchor. The first observed session establishes cohort D0. An + /// existing cohort keeps its anchor through D30; the first later + /// observation starts another D0. + /// + /// Storage must call this while holding the telemetry lock, after rejecting + /// a day before the latest-opened-day high-water mark. Any high-water + /// advancement and this complete session transition belong to one + /// private-state replacement. That replacement must complete before the + /// `session_start` row is appended or either returned anchor is used to + /// derive an identifier. + /// + /// # Errors + /// + /// Returns an error if `effective_day` precedes either stored anchor. A + /// conforming storage caller filters this case through its durable day + /// policy; these checks protect against an incorrect caller or inconsistent + /// state. Neither anchor changes when validation fails. + pub(super) fn observe_session( + &mut self, + effective_day: UtcDay, + ) -> Result { + let identifier_window = self.select_identifier_window(effective_day)?; + let return_cohort = self.select_return_cohort(effective_day)?; + + self.identity.identifier_window_anchor = identifier_window.anchor(); + self.identity.return_cohort_anchor = Some(return_cohort.anchor()); + + Ok(SessionObservation { + identifier_window, + return_cohort, + }) + } + + /// Select the identifier window without mutating private state. + fn select_identifier_window( + &self, + effective_day: UtcDay, + ) -> Result { + let anchor = self.identity.identifier_window_anchor; + let elapsed_days = effective_day.days_since(anchor); + + if elapsed_days < 0 { + return Err(SessionObservationError::BeforeIdentifierWindow { + observed_day: effective_day, + window_anchor: anchor, + }); + } + + if elapsed_days < IDENTIFIER_WINDOW_DAYS { + return Ok(IdentifierWindowUpdate::Current { anchor }); + } + + Ok(IdentifierWindowUpdate::Advanced { + anchor: effective_day, + }) + } + + /// Select the return cohort without mutating private state. + fn select_return_cohort( + &self, + effective_day: UtcDay, + ) -> Result { + let Some(anchor) = self.identity.return_cohort_anchor else { + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); + }; + + let elapsed_days = effective_day.days_since(anchor); + if elapsed_days < 0 { + return Err(SessionObservationError::BeforeReturnCohort { + observed_day: effective_day, + cohort_anchor: anchor, + }); + } + + if elapsed_days > i64::from(CohortDay::D30.get()) { + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); + } + + let day = CohortDay::try_from(elapsed_days) + .expect("BUG: a session between D0 and D30 must have a valid cohort day"); + Ok(ReturnCohortUpdate::Current { anchor, day }) + } +} + +/// Identity and return-cohort selections for one observed session. +#[must_use = "session identity state must be persisted before identifiers are emitted"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SessionObservation { + pub(super) identifier_window: IdentifierWindowUpdate, + pub(super) return_cohort: ReturnCohortUpdate, +} + +/// Whether selecting an identifier window changed private state. +#[must_use = "an advanced identifier window must be persisted before use"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum IdentifierWindowUpdate { + /// The existing window remains active. + Current { anchor: UtcDay }, + /// A new observation-anchored window was started. + Advanced { anchor: UtcDay }, +} + +impl IdentifierWindowUpdate { + /// Return the anchor selected for identifier derivation. + #[must_use] + pub(super) fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor } | Self::Advanced { anchor } => anchor, + } + } +} + +/// Whether selecting a return cohort changed private state. +#[must_use = "a started return cohort must be persisted before use"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReturnCohortUpdate { + /// The existing cohort remains active at `day`. + Current { anchor: UtcDay, day: CohortDay }, + /// A new cohort was started at D0. + Started { anchor: UtcDay }, +} + +impl ReturnCohortUpdate { + /// Return the anchor selected for retention-subject derivation. + #[must_use] + pub(super) fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor, .. } | Self::Started { anchor } => anchor, + } + } + + /// Return the observed day within the selected cohort. + #[must_use] + pub(super) fn day(self) -> CohortDay { + match self { + Self::Current { day, .. } => day, + Self::Started { .. } => CohortDay::D0, + } + } +} + +/// An observed session earlier than one of its stored identity anchors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SessionObservationError { + BeforeIdentifierWindow { + observed_day: UtcDay, + window_anchor: UtcDay, + }, + BeforeReturnCohort { + observed_day: UtcDay, + cohort_anchor: UtcDay, + }, +} + +impl fmt::Display for SessionObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BeforeIdentifierWindow { + observed_day, + window_anchor, + } => write!( + formatter, + "observed day {observed_day} precedes the identifier-window anchor {window_anchor}" + ), + Self::BeforeReturnCohort { + observed_day, + cohort_anchor, + } => write!( + formatter, + "observed session day {observed_day} precedes the return-cohort anchor {cohort_anchor}" + ), + } + } +} + +impl std::error::Error for SessionObservationError {} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use chrono::NaiveDate; + + use super::*; + + const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const GENERATED_KEY_BYTE: u8 = 0x42; + /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. + const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; + + #[derive(Debug, PartialEq, Eq)] + struct TestKeySourceError; + + fn day(year: i32, month: u32, day: u32) -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) + } + + fn state_with_return_cohort(key: &str) -> String { + state_with_anchors(key, "2026-09-10", "2026-08-11") + } + + fn state_with_anchors( + key: &str, + identifier_window_anchor: &str, + return_cohort_anchor: &str, + ) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\nreturn-cohort-anchor = \"{return_cohort_anchor}\"\n" + ) + } + + fn state_without_return_cohort(key: &str) -> String { + state_without_return_cohort_at(key, "2026-09-10") + } + + fn state_without_return_cohort_at(key: &str, identifier_window_anchor: &str) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\n" + ) + } + + #[test] + fn identifier_reset_rotates_the_key_resets_the_window_and_clears_the_cohort() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let reset_day = day(2026, 10, 15); + + state + .reset_identifiers_with::(reset_day, |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Ok(()) + }) + .unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_without_return_cohort_at(GENERATED_KEY, "2026-10-15"); + assert_eq!(serialized, expected); + } + + #[test] + fn failed_identifier_reset_preserves_the_complete_state() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let result = state.reset_identifiers_with(day(2026, 10, 15), |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Err(TestKeySourceError) + }); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!(result, Err(TestKeySourceError)); + assert_eq!(serialized, source); + } + + #[test] + fn identifier_reset_can_use_operating_system_randomness() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let reset_day = day(2026, 10, 15); + + state.reset_identifiers(reset_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(!serialized.contains(KEY)); + assert_eq!(state.identity.identifier_window_anchor, reset_day); + assert!(state.identity.return_cohort_anchor.is_none()); + } + + #[test] + fn observations_on_days_zero_through_twenty_nine_keep_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let anchor = day(2026, 9, 10); + + for observed_day in [anchor, day(2026, 10, 9)] { + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { anchor } + ); + assert_eq!(observation.identifier_window.anchor(), anchor); + assert_eq!(state.identity.identifier_window_anchor, anchor); + } + } + + #[test] + fn observation_on_day_thirty_advances_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 10); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D30); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + } + + #[test] + fn observation_after_inactivity_anchors_both_lifecycles_to_the_observation() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn window_rollover_preserves_the_key_and_return_cohort() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(day(2026, 10, 10)).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert_eq!(serialized, expected); + } + + #[test] + fn observation_before_the_window_anchor_is_rejected_without_mutation() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + error.to_string(), + "observed day 2026-09-09 precedes the identifier-window anchor 2026-09-10" + ); + assert_eq!(serialized, source); + } + + #[test] + fn first_observed_session_starts_d0() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 10); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!(observation.return_cohort.day(), CohortDay::D0); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn first_session_after_window_expiry_starts_d0_and_advances_the_window() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn observations_through_d30_keep_the_existing_cohort() { + let anchor = day(2026, 8, 11); + + for (window_anchor, observed_day, expected_day) in [ + ("2026-08-11", day(2026, 8, 11), 0_i64), + ("2026-08-11", day(2026, 8, 12), 1), + ("2026-09-01", day(2026, 9, 10), 30), + ] { + let source = state_with_anchors(KEY, window_anchor, "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.return_cohort.day(), + CohortDay::try_from(expected_day).unwrap() + ); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Current { .. } + )); + assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); + } + } + + #[test] + fn cohort_rollover_preserves_the_identifier_window_and_key() { + let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 11); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { + anchor: day(2026, 9, 1) + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D0); + assert_eq!(serialized, expected); + } + + #[test] + fn both_session_lifecycles_roll_over_in_one_transition() { + let source = state_with_anchors(KEY, "2026-08-12", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 11); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn invalid_cohort_day_does_not_partially_advance_the_window() { + let source = state_with_anchors(KEY, "2026-08-01", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + error.to_string(), + "observed session day 2026-09-09 precedes the return-cohort anchor 2026-09-10" + ); + assert_eq!(serialized, source); + } +} diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs new file mode 100644 index 00000000..8bcb0b2e --- /dev/null +++ b/src/telemetry/state/mod.rs @@ -0,0 +1,279 @@ +//! Private telemetry state and its lifecycle. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "the state schema is built before persistence uses it." + ) +)] + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +use super::{ + identity::{IdentityKey, state_key_hex}, + schema::UtcDay, +}; + +mod lifecycle; + +/// The initial schema version of `telemetry-state.toml`. +/// +/// Exact rather than permissive, unlike a row's `SchemaVersion`: a row written by +/// a newer binary is classified as an unknown schema and skipped, but private +/// single-writer state must never be half-understood. +#[derive(Clone, Copy)] +struct StateVersion; + +impl Serialize for StateVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u64(1) + } +} + +impl<'de> Deserialize<'de> for StateVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let version = u64::deserialize(deserializer)?; + if version != 1 { + return Err(D::Error::custom(format_args!( + "expected telemetry state version 1, found {version}" + ))); + } + + Ok(Self) + } +} + +/// Version 1 of the complete private telemetry state file. +/// +/// A field may be absent only when absence represents a real lifecycle state, +/// in which case its type records that explicitly. Once this version ships, +/// adding a required field needs a migration or a new state version; a default +/// must not silently turn malformed state into valid state. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct TelemetryStateV1 { + version: StateVersion, + identity: IdentityState, +} + +impl TelemetryStateV1 { + /// Create private state anchored to the day recording first needs identity. + /// + /// The return cohort remains absent until a session is observed. + /// + /// # Errors + /// + /// Returns an error when the operating system cannot generate a secret key. + fn new(identifier_window_anchor: UtcDay) -> Result { + let key = IdentityKey::generate()?; + Ok(Self::with_key(identifier_window_anchor, key)) + } + + /// Construct state from identity material that has already been generated. + #[must_use] + fn with_key(identifier_window_anchor: UtcDay, key: IdentityKey) -> Self { + Self { + version: StateVersion, + identity: IdentityState { + key, + identifier_window_anchor, + return_cohort_anchor: None, + }, + } + } +} + +/// Stable identity material and the dates that define its rotation windows. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct IdentityState { + #[serde(with = "state_key_hex")] + key: IdentityKey, + identifier_window_anchor: UtcDay, + #[serde(skip_serializing_if = "Option::is_none")] + return_cohort_anchor: Option, +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use chrono::NaiveDate; + + use super::{IdentityKey, TelemetryStateV1}; + use crate::telemetry::schema::UtcDay; + + const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const GENERATED_KEY_BYTE: u8 = 0x42; + /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. + const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; + + fn day(year: i32, month: u32, day: u32) -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) + } + + fn state_with_return_cohort(key: &str) -> String { + state_with_anchors(key, "2026-09-10", "2026-08-11") + } + + fn state_with_anchors( + key: &str, + identifier_window_anchor: &str, + return_cohort_anchor: &str, + ) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\nreturn-cohort-anchor = \"{return_cohort_anchor}\"\n" + ) + } + + fn state_without_return_cohort(key: &str) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"2026-09-10\"\n" + ) + } + + /// Rejection text for `source`, so each test can pin the reason it failed. + /// + /// Destructures rather than calling `unwrap_err`, which would need `Debug` on + /// the state: the identity key deliberately has no formatting traits. + fn rejection_message(source: &str) -> String { + let Err(error) = toml::from_str::(source) else { + panic!("accepted invalid telemetry state:\n{source}"); + }; + + error.to_string() + } + + #[test] + fn version_one_state_round_trips_in_canonical_form() { + let source = state_with_return_cohort(KEY); + + let state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!(serialized, source); + } + + #[test] + fn new_state_starts_an_identity_window_without_a_return_cohort() { + let day = day(2026, 9, 10); + + let state = TelemetryStateV1::new(day).unwrap(); + + assert_eq!(state.identity.identifier_window_anchor, day); + assert!(state.identity.return_cohort_anchor.is_none()); + } + + #[test] + fn generated_state_has_the_canonical_initial_file_shape() { + let key = IdentityKey::generate_with::(|bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Ok(()) + }) + .unwrap(); + + let state = TelemetryStateV1::with_key(day(2026, 9, 10), key); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_without_return_cohort(GENERATED_KEY); + assert_eq!(serialized, expected); + } + + #[test] + fn state_without_an_observed_session_has_no_return_cohort() { + let source = state_without_return_cohort(KEY); + + let state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(state.identity.return_cohort_anchor.is_none()); + assert_eq!(serialized, source); + } + + #[test] + fn future_state_version_is_rejected() { + let source = state_with_return_cohort(KEY).replacen("version = 1", "version = 2", 1); + + let message = rejection_message(&source); + + assert!( + message.contains("expected telemetry state version 1, found 2"), + "unexpected rejection reason: {message}" + ); + } + + #[test] + fn unknown_top_level_field_is_rejected() { + let source = state_with_return_cohort(KEY).replacen( + "\n[identity]", + "\nunexpected = true\n\n[identity]", + 1, + ); + + let message = rejection_message(&source); + + assert!( + message.contains("unknown field `unexpected`"), + "unexpected rejection reason: {message}" + ); + } + + #[test] + fn unknown_identity_field_is_rejected() { + let mut source = state_with_return_cohort(KEY); + source.push_str("unexpected = true\n"); + + let message = rejection_message(&source); + + assert!( + message.contains("unknown field `unexpected`"), + "unexpected rejection reason: {message}" + ); + } + + #[test] + fn identity_key_must_have_exactly_64_digits() { + let one_short = &KEY[..KEY.len() - 1]; + let one_long = format!("{KEY}0"); + + for key in ["", one_short, &one_long] { + let message = rejection_message(&state_with_return_cohort(key)); + + assert!( + message.contains("exactly 64 hexadecimal digits"), + "accepted or misreported a {}-digit key: {message}", + key.len() + ); + } + } + + #[test] + fn identity_key_must_use_lowercase_hexadecimal() { + for key in [KEY.to_uppercase(), KEY.replacen('f', "g", 1)] { + let message = rejection_message(&state_with_return_cohort(&key)); + + assert!( + message.contains("lowercase hexadecimal digits"), + "accepted or misreported {key}: {message}" + ); + } + } + + #[test] + fn identity_anchor_must_be_a_canonical_utc_day() { + let source = state_with_return_cohort(KEY).replacen("2026-09-10", "2026-9-10", 1); + + let message = rejection_message(&source); + + assert!( + message.contains("UTC day"), + "unexpected rejection reason: {message}" + ); + } +}