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..072d684e 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, `macros.rs` owns strict versioned-row boilerplate while each row module keeps its validation rules, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. 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. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, command derives `command_subject` from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. 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..63ea1b63 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. @@ -231,7 +229,7 @@ Safe nodes are public `package` and `extension` coordinates, `all` contributors, Shell commands, paths, environment values, custom predicate details, workspace members, wildcards, private names, and a negated child never enter the path. -Witness depth counts nested evidence nodes from the root, which is level 1, to a terminal package, extension, `not`, or opaque node. A subtree that would exceed level 8 becomes `opaque: limit`. The complete path is also limited to 16 evidence leaves and 4 KiB. These limits do not count filesystem path components; filesystem paths are never recorded. +The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, `not`, or opaque node. A subtree that would exceed depth 8 becomes `opaque: limit`. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. These limits do not count filesystem path components; filesystem paths are never recorded. Full sync builds safe evidence for successful installations because the generated attribution index needs it even when telemetry is disabled. Only an enabled recorder serializes that evidence as telemetry. @@ -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..e9ea7e88 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 anchor's ASCII `YYYY-MM-DD` representation from `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 | Window anchor | Ordered dimension fields | +| --- | --- | --- | --- | --- | +| `session_id` | `session_id` | `sess_` | `identifier-window` | Agent, vendor session id. | +| `retention_subject` | `retention_subject` | `ret_` | `return-cohort` | None. | +| `agent_subject` | `agent_subject` | `agt_` | `identifier-window` | Agent. | +| `package_subject` | `package_subject` | `pkg_` | `identifier-window` | Package ecosystem, name, exact version. | +| `extension_subject` | `extension_subject` | `ext_` | `identifier-window` | Target type, source, name, then the complete safe resolution path. | +| `hook_subject` | `hook_subject` | `hok_` | `identifier-window` | Agent, hook surface. | +| `plugin_subject` | `plugin_subject` | `plg_` | `identifier-window` | Public source, plugin name. | +| `command_subject` | `command_subject` | `cmd_` | `identifier-window` | 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: @@ -79,6 +117,8 @@ Only the following stable labels can make package, plugin, skill, or plugin-comm `crates-io` applies only when core proves allowlisted crates.io provenance; `symposium-recommendations` identifies the built-in registry. `user-plugins`, configured registries, paths, workspaces, and arbitrary git sources remain unnamed. Raw registry names and URLs are never enum values. Adding an ecosystem or public-source label expands name eligibility and requires a new consent version. +Public plugin and skill names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, `-`, or `_`. Unlike public package names, extension names may begin with a digit because they are authored extension identifiers rather than crates.io package coordinates. Symposium preserves the spelling without case folding or Unicode normalization. An otherwise valid extension whose name does not fit this telemetry grammar remains usable but is treated as unnamed by telemetry. + ## Event kinds ### `session_start` @@ -98,7 +138,9 @@ 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 producer captures `at` once when session-start handling completes. That timestamp determines `day` and the session observation from which the producer derives `session_id`, `retention_subject`, and `cohort_day`. The aggregate hook rows measure only session-start hook reliability and latency. @@ -150,13 +192,17 @@ This row records one eligible public package used as resolution input during a f | Field | Values | Meaning | | ------------------- | ----------------------- | -------------------------------------------------------------------- | | `package.ecosystem` | `cargo` | Stable public ecosystem label. | -| `package.name` | validated string | Public package name. | +| `package.name` | validated string | Public package name using the fixed version 1 grammar. | | `package.version` | validated exact version | Exact resolved public version, never a range or `*`. | | `extension_match` | `public`, `unnamed_only`, `none` | What kind of resolved extension, if any, the package contributed to. | | `package_subject` | scoped id | Deduplicates this exact coordinate for 30 days. | A package is named only when its package manager reports provenance matching a reviewed public-registry allowlist. Registry URLs themselves are not recorded. +The version 1 package-name grammar is 1 through 64 ASCII bytes. The first byte is an ASCII letter; the remaining bytes are ASCII letters, digits, `-`, or `_`. Symposium preserves the spelling without case folding or treating hyphens and underscores as equivalent. This stable telemetry grammar does not copy a registry's changing reserved-name list. + +An exact package version has three numeric semantic-version components and may include prerelease or build metadata. Missing versions, ranges, and wildcards are invalid coordinates. + `extension_match` describes what the package contributed: - `public`: at least one eligible public extension matched, including when unnamed content also matched. @@ -173,7 +219,7 @@ This row records one public plugin or skill and one safe path that selected it. | ------------------- | --------------------------- | --------------------------------------------------- | | `target.type` | `plugin`, `skill` | Resolved extension type. | | `target.source` | `symposium-recommendations`, `crates-io` | Stable label, never a configured URL or local name. | -| `target.name` | validated string | Name defined by eligible public content. | +| `target.name` | public extension name | Name defined by eligible public content. | | `path` | bounded typed nodes | Actual safe package/predicate/extension chain. | | `extension_subject` | scoped id | Deduplicates this safe target/path for 30 days. | @@ -188,9 +234,26 @@ Path nodes are limited to: | `not` | Marker only; the child is not recorded. | | `opaque` | Fixed reason: `private_source`, `non_package_predicate`, or `limit`. | +The exact version 1 node shapes are shown below. These examples use public +placeholder coordinates; the same package and extension validation rules +described above apply inside path nodes. + +```json +{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"} +{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"} +{"type":"all","children":[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"any","child":{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}},{"type":"not"},{"type":"opaque","reason":"limit"}]} +{"type":"any","child":{"type":"not"}} +{"type":"not"} +{"type":"opaque","reason":"non_package_predicate"} +``` + +`all.children` contains one or more nodes. `any.child` contains the one branch +that made the expression succeed. Each object is strict: missing fields, +additional fields, unknown node types, and unknown opaque reasons are invalid. + Shell commands, paths, environment variables, custom predicate names or arguments, and private package or extension names never enter a path. An opaque marker can represent their position. -Witness depth counts nested evidence nodes from the root, which is level 1, to a terminal package, extension, `not`, or opaque node. A subtree that would exceed level 8 becomes `opaque: limit`. The complete path is also limited to 16 evidence leaves and 4 KiB. Evidence depth does not count filesystem components; filesystem paths are never recorded. +The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, `not`, or opaque node. A subtree that would exceed depth 8 becomes `opaque: limit`. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. Evidence depth does not count filesystem components; filesystem paths are never recorded. This event says an extension resolved. It does not say that an agent read or used the extension; a matching `extension_invocation_metrics` aggregate separately reports observed agent activation. Version 1 can produce that aggregate only for Claude. @@ -226,7 +289,7 @@ This cumulative row combines plugin-hook observations for one UTC day, agent, ho | `hook` | `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `session_start`, `stop` | Symposium hook surface. | | `plugin_scope` | `public`, `unnamed`, `overflow` | Whether the bucket names an eligible public plugin. | | `plugin.source` | `symposium-recommendations`, `crates-io`, conditional | Present only when `plugin_scope=public`. | -| `plugin.name` | validated string, conditional | Present only when `plugin_scope=public`. | +| `plugin.name` | public extension name, conditional | Present only when `plugin_scope=public`. | | `attempts` | integer | Plugin-hook attempts that reached an observed terminal result. | | `executions` | integer | Those completed attempts that reached child execution. | | `outcomes` | plugin outcome counters | Exact counters named `ok`, `blocked`, and `error`. | @@ -277,7 +340,7 @@ This cumulative row combines skill-invocation observations for one UTC day, supp | `target_scope` | `public`, `unnamed`, `overflow` | Whether the bucket names an eligible public skill. | | `target.type` | `skill`, conditional | Present only when `target_scope=public`. | | `target.source` | `symposium-recommendations`, `crates-io`, conditional | Reviewed public source; present only when `target_scope=public`. | -| `target.name` | validated string, conditional | Public skill name; present only when `target_scope=public`. | +| `target.name` | public extension name, conditional | Public skill name; present only when `target_scope=public`. | | `unnamed_reason` | `ineligible`, `not_indexed`, `attribution_unavailable`, `ambiguous`, `invalid_signal`, conditional | Present only when `target_scope=unnamed`. | | `attempted` | integer | Valid Claude `PreToolUse:Skill` observations merged into the row. | | `completed` | integer | Successful Claude `PostToolUse:Skill` observations merged into the row. | @@ -327,7 +390,7 @@ This row records one completed eligible top-level user command. Arguments are never recorded. Internal `hook`, all `telemetry` commands, and ineligible external/plugin commands do not produce command events. -Built-in names are `init`, `sync`, `search`, `use`, `status`, `plugin_sync`, `plugin_list`, `plugin_show`, `plugin_validate`, `self_update`, and `crate_info`: +Built-in command operations are `init`, `sync`, `search`, `use`, `remove`, `status`, `plugin_sync`, `plugin_list`, `plugin_show`, `plugin_validate`, `self_update`, and `crate_info`. `remove` is the telemetry name for `cargo agents use --remove`; it is not a separate CLI subcommand. ```json {"type":"builtin","name":"use"} @@ -339,6 +402,8 @@ An eligible plugin command contains only its reviewed public-source label, publi {"type":"plugin","source":"symposium-recommendations","plugin":"example-tools","name":"example-check"} ``` +Public plugin-command names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, `-`, or `_`. Symposium preserves the spelling without case folding or Unicode normalization. A plugin command outside this telemetry grammar remains usable but does not produce a command event. + ### `storage_limit` This row means that the next complete low-volume event batch did not fit in the shared daily 8 MiB allowance. In addition to the common fields, `dropped_operation` is `session_start`, `manual_sync`, `use`, `remove`, `init`, `configuration`, or `command`. It identifies the top-level operation whose batch was rejected. @@ -368,6 +433,12 @@ 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, a recording operation captures its completion timestamp once, uses its UTC day, and rejects it when it precedes the latest-opened-day high-water mark. It calculates the identifier-window transition, applies it with any high-water advancement to one in-memory state, atomically replaces private state once, and only then binds the timestamp, day, and identifier window for row construction. The replacement also occurs when the selected window remains current, so every operation has the same persist-before-bind boundary. Every row in one operation reuses that bound recording context. In particular, a full resolution uses one context for its summary, package, and extension rows. + +Session recording also calculates the return-cohort transition before mutating either anchor. The identifier-window transition, return-cohort transition, and high-water advancement are persisted together. The `session_start` row is then derived from the same bound session context. If its 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 +449,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..29de088f --- /dev/null +++ b/src/telemetry/identity.rs @@ -0,0 +1,1081 @@ +//! 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)) + } +} + +/// Identity material bound to one canonical rotation window. +/// +/// Private telemetry state creates this handle after applying lifecycle +/// transitions. Subject-bearing row constructors can use it to derive an +/// identifier from the corresponding source value instead of accepting the two +/// independently. +pub(super) struct IdentityScope<'a, A> { + key: &'a IdentityKey, + window: String, + anchor: PhantomData, +} + +impl<'a, A> IdentityScope<'a, A> { + /// Bind a private identity key to one canonical window value. + /// + /// In production, `window` comes from a validated state anchor. Keeping the + /// conversion here avoids making the identity module depend on row schema + /// types. + #[must_use] + pub(super) fn new(key: &'a IdentityKey, window: String) -> Self { + Self { + key, + window, + anchor: PhantomData, + } + } + + /// Derive the identifier belonging to a typed source value. + #[must_use] + pub(super) fn derive(&self, dimension: &I) -> ScopedId + where + I: IdentityDimension, + I::Domain: ScopedIdDomain, + { + derive_scoped_id(self.key, self.window.as_bytes(), dimension) + } +} + +/// Identity material bound to the active 30-day identifier window. +pub(super) type IdentifierWindowScope<'a> = IdentityScope<'a, IdentifierWindowAnchor>; + +/// Identity material bound to the active D0-D30 return cohort. +pub(super) type ReturnCohortScope<'a> = IdentityScope<'a, ReturnCohortAnchor>; + +/// A typed value that supplies one identifier domain's canonical fields. +/// +/// Each schema type implements this trait for the domain it belongs to. This +/// keeps field selection and order beside the validated value while leaving +/// framing under the identity module's control. A domain with no schema value, +/// such as retention, keeps its empty dimension here so private state can use +/// the production encoding without depending on a row module. +pub(super) trait IdentityDimension { + type Domain; + + /// Write this dimension's fields in their frozen contract order. + /// + /// Use [`DimensionWriter::variant`] for tagged values and + /// [`DimensionWriter::sequence`] for counted collections. Implementations + /// must not add their own framing. + fn write(&self, writer: &mut DimensionWriter<'_>); +} + +/// The empty dimension used to derive a return-cohort subject. +/// +/// A retention subject is scoped only by its return-cohort anchor. Keeping the +/// empty dimension as a type ensures callers cannot add an accidental field to +/// that derivation. +pub(super) struct RetentionDimension; + +impl IdentityDimension for RetentionDimension { + type Domain = RetentionDomain; + + /// Write no fields, as required by the version 1 identity contract. + fn write(&self, _writer: &mut DimensionWriter<'_>) {} +} + +/// Writes canonical identity-dimension framing to a private byte sink. +/// +/// Only this module can create a writer. Schema types can use its structured +/// operations from an [`IdentityDimension`] implementation, but telemetry +/// producers cannot construct dimensions from loose byte slices. +pub(super) struct DimensionWriter<'a> { + write: &'a mut dyn FnMut(&[u8]), +} + +impl<'a> DimensionWriter<'a> { + fn new(write: &'a mut dyn FnMut(&[u8])) -> Self { + Self { write } + } + + /// Write one length-prefixed field. + pub(super) fn field(&mut self, value: &[u8]) { + write_frame(value, |bytes| (self.write)(bytes)); + } + + /// Write a tagged variant followed by its canonically framed fields. + /// + /// Variant labels are frozen contract values, so callers supply a static + /// string rather than data obtained at runtime. + pub(super) fn variant(&mut self, label: &'static str, write_fields: impl FnOnce(&mut Self)) { + self.field(label.as_bytes()); + write_fields(self); + } + + /// Write a counted sequence whose items own their recursive encoding. + pub(super) fn sequence(&mut self, items: &[T], mut write_item: impl FnMut(&mut Self, &T)) { + let count = u64::try_from(items.len()) + .expect("BUG: a slice length must fit the telemetry sequence format"); + (self.write)(&count.to_be_bytes()); + + for item in items { + write_item(self, item); + } + } +} + +#[cfg(test)] +pub(super) fn encode_dimension_for_test(dimension: &impl IdentityDimension) -> Vec { + let mut encoded = Vec::new(); + let mut append = |bytes: &[u8]| encoded.extend_from_slice(bytes); + let mut writer = DimensionWriter::new(&mut append); + dimension.write(&mut writer); + encoded +} + +/// 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); + } +} + +mod sealed { + pub trait Sealed {} +} + +/// Marker for the state anchor that scopes an identifier domain. +pub(super) trait IdentityAnchor: sealed::Sealed { + const CONTRACT_NAME: &'static str; +} + +/// The anchor shared by identifiers that rotate on the 30-day window. +pub(super) enum IdentifierWindowAnchor {} + +impl sealed::Sealed for IdentifierWindowAnchor {} + +impl IdentityAnchor for IdentifierWindowAnchor { + const CONTRACT_NAME: &'static str = "identifier-window"; +} + +/// The anchor dedicated to D0-D30 return measurement. +pub(super) enum ReturnCohortAnchor {} + +impl sealed::Sealed for ReturnCohortAnchor {} + +impl IdentityAnchor for ReturnCohortAnchor { + const CONTRACT_NAME: &'static str = "return-cohort"; +} + +/// Marker supplying a [`ScopedId`] domain's frozen derivation and wire labels. +/// +/// Visible only inside telemetry so typed derivation APIs can name the bound. +/// The real domains remain declared centrally below: these constants are +/// published contract surfaces, not general extension points. Changing an +/// anchor category or either string requires a new consent version. +pub(super) trait ScopedIdDomain: sealed::Sealed { + type Anchor: IdentityAnchor; + + const PREFIX: &'static str; + const HMAC_DOMAIN: &'static str; +} + +fn derive_scoped_id(key: &IdentityKey, window: &[u8], dimension: &I) -> ScopedId +where + I: IdentityDimension, + I::Domain: ScopedIdDomain, +{ + let mut hmac = + HmacSha256::new_from_slice(&key.0).expect("BUG: HMAC-SHA-256 must accept a 32-byte key"); + hmac.update(b"telemetry:"); + hmac.update(I::Domain::HMAC_DOMAIN.as_bytes()); + hmac.update(b":v1\0"); + write_frame(window, |bytes| hmac.update(bytes)); + let mut update = |bytes: &[u8]| hmac.update(bytes); + let mut writer = DimensionWriter::new(&mut update); + dimension.write(&mut writer); + + 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 anchor, contract strings, and 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 { + anchor: $anchor:ty, + hmac_domain: $hmac_domain:literal, + wire_prefix: $prefix:literal, + } + )+ + ) => { + $( + pub(super) enum $domain {} + + impl sealed::Sealed for $domain {} + + impl ScopedIdDomain for $domain { + type Anchor = $anchor; + + 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, &str)] = &[ + $(($hmac_domain, $prefix, <$anchor as IdentityAnchor>::CONTRACT_NAME)),+ + ]; + }; +} + +scoped_id_domains! { + SessionDomain => SessionId { + anchor: IdentifierWindowAnchor, + hmac_domain: "session_id", + wire_prefix: "sess_", + } + RetentionDomain => RetentionSubject { + anchor: ReturnCohortAnchor, + hmac_domain: "retention_subject", + wire_prefix: "ret_", + } + AgentDomain => AgentSubject { + anchor: IdentifierWindowAnchor, + hmac_domain: "agent_subject", + wire_prefix: "agt_", + } + PackageDomain => PackageSubject { + anchor: IdentifierWindowAnchor, + hmac_domain: "package_subject", + wire_prefix: "pkg_", + } + ExtensionDomain => ExtensionSubject { + anchor: IdentifierWindowAnchor, + hmac_domain: "extension_subject", + wire_prefix: "ext_", + } + HookDomain => HookSubject { + anchor: IdentifierWindowAnchor, + hmac_domain: "hook_subject", + wire_prefix: "hok_", + } + PluginDomain => PluginSubject { + anchor: IdentifierWindowAnchor, + hmac_domain: "plugin_subject", + wire_prefix: "plg_", + } + CommandDomain => CommandSubject { + anchor: IdentifierWindowAnchor, + 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 sealed::Sealed for TestDomain {} + + impl ScopedIdDomain for TestDomain { + type Anchor = IdentifierWindowAnchor; + + const PREFIX: &'static str = "test_"; + const HMAC_DOMAIN: &'static str = "test_subject"; + } + + struct TestDimension { + fields: Vec<&'static [u8]>, + domain: PhantomData, + } + + impl TestDimension { + fn from_fields(fields: [&'static [u8]; N]) -> Self { + Self { + fields: fields.into_iter().collect(), + domain: PhantomData, + } + } + } + + impl IdentityDimension for TestDimension { + type Domain = D; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + for field in &self.fields { + writer.field(field); + } + } + } + + struct TestVariantDimension; + + impl IdentityDimension for TestVariantDimension { + type Domain = TestDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.variant("package", |writer| writer.field(b"cargo")); + } + } + + struct TestNestedSequenceDimension; + + impl IdentityDimension for TestNestedSequenceDimension { + type Domain = TestDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + let groups = [ + [b"a".as_slice(), b"bc".as_slice()], + [b"d".as_slice(), b"ef".as_slice()], + ]; + + writer.sequence(&groups, |writer, group| { + writer.sequence(group, |writer, item| writer.field(item)); + }); + } + } + + /// 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 identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); + let dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); + + let identifier = identity.derive(&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 identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); + let dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); + + let first = identity.derive(&dimension); + let second = identity.derive(&dimension); + + assert_eq!(first, second); + } + + #[test] + fn return_cohort_scope_derives_retention_subjects() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let identity = ReturnCohortScope::new(&key, "2026-08-03".to_owned()); + + let identifier = identity.derive(&RetentionDimension); + + // Cross-checked with .NET's HMACSHA256 over the retention header and + // framed cohort anchor. The complete digest is + // 270adecd2120c543261f04bd771df49170e407de5d3116f98a0468f832fcfcbb. + assert_eq!( + identifier.to_string(), + "ret_270adecd2120c543261f04bd771df491" + ); + } + + #[test] + fn length_framing_separates_nul_at_different_boundaries() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let first_identity = IdentifierWindowScope::new(&key, "a\0b".to_owned()); + let first_dimension = TestDimension::::from_fields([b"c".as_slice()]); + let second_identity = IdentifierWindowScope::new(&key, "a".to_owned()); + let second_dimension = TestDimension::::from_fields([b"b\0c".as_slice()]); + + let first = first_identity.derive(&first_dimension); + let second = second_identity.derive(&second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn length_framing_separates_dimension_field_boundaries() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); + let first_dimension = TestDimension::::from_fields([ + b"cargo".as_slice(), + b"foo1".as_slice(), + b"2.3.4".as_slice(), + ]); + let second_dimension = TestDimension::::from_fields([ + b"cargo".as_slice(), + b"foo".as_slice(), + b"12.3.4".as_slice(), + ]); + + let first = identity.derive(&first_dimension); + let second = identity.derive(&second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn dimension_writer_prefixes_variant_fields_with_their_label() { + let variant_length = 7_u64.to_be_bytes(); + let field_length = 5_u64.to_be_bytes(); + let expected = [ + variant_length.as_slice(), + b"package".as_slice(), + field_length.as_slice(), + b"cargo".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&TestVariantDimension); + + assert_eq!(encoded, expected); + } + + #[test] + fn dimension_writer_encodes_counted_sequences_recursively() { + let item_count = 2_u64.to_be_bytes(); + let one_byte = 1_u64.to_be_bytes(); + let two_bytes = 2_u64.to_be_bytes(); + let expected = [ + item_count.as_slice(), + item_count.as_slice(), + one_byte.as_slice(), + b"a".as_slice(), + two_bytes.as_slice(), + b"bc".as_slice(), + item_count.as_slice(), + one_byte.as_slice(), + b"d".as_slice(), + two_bytes.as_slice(), + b"ef".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&TestNestedSequenceDimension); + + assert_eq!(encoded, expected); + } + + #[test] + fn changing_any_derivation_scope_changes_the_identifier() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let other_key = IdentityKey::from_bytes([0x24; IDENTITY_KEY_BYTES]); + let identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); + let other_key_identity = IdentifierWindowScope::new(&other_key, "window-1".to_owned()); + let other_window_identity = IdentifierWindowScope::new(&key, "window-2".to_owned()); + let session_dimension = + TestDimension::::from_fields([b"dimension-1".as_slice()]); + let other_session_dimension = + TestDimension::::from_fields([b"dimension-2".as_slice()]); + let command_dimension = + TestDimension::::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 = [ + identity.derive(&session_dimension).bytes, + other_key_identity.derive(&session_dimension).bytes, + identity.derive(&command_dimension).bytes, + other_window_identity.derive(&session_dimension).bytes, + identity.derive(&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, anchor) in DOMAIN_CONTRACTS { + let contract_row = format!("| `{domain}` | `{domain}` | `{prefix}` | `{anchor}` |"); + + 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..5be29b18 --- /dev/null +++ b/src/telemetry/schema/agent.rs @@ -0,0 +1,811 @@ +//! 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, macros::strict_versioned_row, +}; +use crate::{ + agents::Agent, + telemetry::{ + identity::{ + AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, + RetentionDimension, RetentionSubject, SessionDomain, SessionId, + }, + state::{BoundRecordingObservation, BoundSessionObservation}, + }, +}; + +/// 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 SupportedAgent { + /// Return the frozen version 1 wire label. + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Copilot => "copilot", + Self::Gemini => "gemini", + Self::Kiro => "kiro", + Self::OpenCode => "opencode", + Self::Goose => "goose", + } + } +} + +impl IdentityDimension for SupportedAgent { + type Domain = AgentDomain; + + /// Write the version 1 `agent_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.as_str().as_bytes()); + } +} + +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 HookAgent { + /// Return the frozen version 1 wire label. + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Copilot => "copilot", + Self::Gemini => "gemini", + Self::Kiro => "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, + } + } +} + +/// A raw vendor session identifier supplied by an agent. +/// +/// This value is used only as an identity-derivation input. It deliberately +/// implements neither formatting nor serialization traits so telemetry cannot +/// accidentally write it to a row or diagnostic. +pub(in crate::telemetry) struct VendorSessionId(String); + +impl VendorSessionId { + /// Wrap a vendor session identifier without changing its UTF-8 bytes. + #[must_use] + pub(in crate::telemetry) fn new(value: String) -> Self { + Self(value) + } + + /// Borrow the exact UTF-8 bytes supplied by the agent. + #[must_use] + fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +/// Canonical version 1 inputs for a scoped session identifier. +struct SessionDimension<'a> { + agent: HookAgent, + vendor_session_id: &'a VendorSessionId, +} + +impl<'a> SessionDimension<'a> { + #[must_use] + const fn new(agent: HookAgent, vendor_session_id: &'a VendorSessionId) -> Self { + Self { + agent, + vendor_session_id, + } + } +} + +impl IdentityDimension for SessionDimension<'_> { + type Domain = SessionDomain; + + /// Write the version 1 `session_id` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.agent.as_str().as_bytes()); + writer.field(self.vendor_session_id.as_bytes()); + } +} + +/// An agent session paired with its optional scoped identifier. +/// +/// The identifier is derived from the same agent stored here, so callers +/// cannot associate one agent with an identifier derived for another. Agents +/// that do not supply a vendor session identifier remain explicitly +/// unidentified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AgentSessionIdentity { + agent: HookAgent, + session_id: Option, +} + +impl AgentSessionIdentity { + /// Derive the identifier for one agent session in an identifier window. + #[must_use] + fn new( + identity: &IdentifierWindowScope<'_>, + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, + ) -> Self { + let session_id = vendor_session_id.map(|vendor_session_id| { + identity.derive(&SessionDimension::new(agent, vendor_session_id)) + }); + + Self { agent, session_id } + } + + /// Return the agent whose session this identity describes. + #[must_use] + const fn agent(self) -> HookAgent { + self.agent + } + + /// Return the scoped identifier when the agent supplied a vendor id. + #[must_use] + const fn session_id(self) -> Option { + self.session_id + } +} + +/// 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, +} + +/// Non-derived inputs for one completed session-start hook. +/// +/// The raw vendor identifier is used only during subject derivation and cannot +/// be formatted or serialized as part of this input bundle. +pub(in crate::telemetry) struct SessionStartFields<'a> { + 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) vendor_session_id: Option<&'a VendorSessionId>, +} + +strict_versioned_row! { + /// Version 1 record of a completed registered session-start hook. + pub(in crate::telemetry) struct SessionStartV1 { + 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, + } + + kind: RowKind::SessionStart, + raw: RawSessionStartV1, + error: SessionStartError, + validate: validate_session_start, +} + +impl SessionStartV1 { + /// Create a record for a completed registered session-start hook. + /// + /// Both scoped identifiers and the cohort day come from the same bound + /// state transition as its captured completion timestamp. + #[must_use] + pub(in crate::telemetry) fn new( + fields: SessionStartFields<'_>, + observation: &BoundSessionObservation<'_>, + ) -> Self { + let at = observation.completed_at(); + let session = AgentSessionIdentity::new( + observation.identifier_window_scope(), + fields.agent, + fields.vendor_session_id, + ); + let retention_subject = observation + .return_cohort_scope() + .derive(&RetentionDimension); + + Self { + version: SchemaVersion::V1, + kind: Self::KIND, + event_id: EventId::new(), + day: at.day(), + at, + symposium: SymposiumVersion::current(), + agent: session.agent(), + os: fields.os, + arch: fields.arch, + start: fields.start, + session_id: session.session_id(), + retention_subject, + cohort_day: observation.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 {} + +fn validate_session_start(raw: &RawSessionStartV1) -> Result<(), SessionStartError> { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(SessionStartError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); + } + + Ok(()) +} + +/// 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, +} + +/// 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( + observation: &BoundRecordingObservation<'_>, + os: OperatingSystem, + arch: Architecture, + fields: AgentConfigurationFields, + ) -> Self { + let agent_subject = observation.identifier_window_scope().derive(&fields.agent); + + Self { + version: SchemaVersion::V1, + kind: RowKind::AgentConfiguration, + event_id: EventId::new(), + day: observation.day(), + symposium: SymposiumVersion::current(), + agent: fields.agent, + configured: fields.configured, + os, + arch, + agent_subject, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, + assert_contract_names_with_labels, classify_row, recording_observation, + }; + use super::*; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; + + fn session_start_fields( + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartFields<'_> { + SessionStartFields { + agent, + os: OperatingSystem::Linux, + arch: Architecture::X86_64, + start: SessionStartKind::Fresh, + vendor_session_id, + } + } + + fn session_start_time() -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 9, 14, 2).unwrap()) + } + + fn session_start( + completed_at: UtcSecond, + vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartV1 { + session_start_for_agent(completed_at, HookAgent::Claude, vendor_session_id) + } + + fn session_start_for_agent( + completed_at: UtcSecond, + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + SessionStartV1::new(session_start_fields(agent, vendor_session_id), &observation) + } + + fn agent_configuration(agent: SupportedAgent) -> AgentConfigurationV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + + AgentConfigurationV1::new( + &observation, + OperatingSystem::Linux, + Architecture::X86_64, + AgentConfigurationFields { + agent, + configured: true, + }, + ) + } + + #[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"), + ]; + + assert_contract_names_with_labels(&cases, HookAgent::as_str); + } + + #[test] + fn session_dimension_uses_agent_then_vendor_session_id() { + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + let dimension = SessionDimension::new(HookAgent::Claude, &vendor_session_id); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 6].as_slice(), + b"claude".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 18].as_slice(), + b"vendor-session-123".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&dimension); + + assert_eq!(encoded, expected); + } + + #[test] + fn session_subject_derivation_matches_independent_vector() { + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + + let row = session_start(session_start_time(), Some(&vendor_session_id)); + + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, agent, and vendor session id. The complete + // digest is + // 2f77ea40740f4be8e85ba05e7924e1ad054037d26629db2ef7dc7097dddf723a. + assert_eq!( + row.session_id, + Some("sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap()) + ); + } + + #[test] + fn session_subject_changes_with_the_agent_or_vendor_session_id() { + let first_vendor_id = VendorSessionId::new("vendor-session-123".to_owned()); + let second_vendor_id = VendorSessionId::new("vendor-session-456".to_owned()); + + let first = session_start_for_agent( + session_start_time(), + HookAgent::Claude, + Some(&first_vendor_id), + ); + let other_agent = session_start_for_agent( + session_start_time(), + HookAgent::Codex, + Some(&first_vendor_id), + ); + let other_vendor_id = session_start_for_agent( + session_start_time(), + HookAgent::Claude, + Some(&second_vendor_id), + ); + + assert_ne!(first.session_id, other_agent.session_id); + assert_ne!(first.session_id, other_vendor_id.session_id); + } + + #[test] + fn agent_session_without_vendor_id_is_unidentified() { + let row = session_start_for_agent(session_start_time(), HookAgent::Copilot, None); + + assert_eq!(row.agent, HookAgent::Copilot); + assert_eq!(row.session_id, None); + } + + #[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"), + ]; + + assert_contract_names_with_labels(&cases, SupportedAgent::as_str); + } + + #[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"), + ]; + + assert_contract_names(&cases); + } + + #[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"), + ]; + + assert_contract_names(&cases); + } + + #[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"), + ]; + + assert_contract_names(&cases); + } + + #[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_derives_identity_and_cohort_from_bound_observation() { + let at = session_start_time(); + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + // These are the independently cross-checked session and retention + // vectors pinned by the focused identity tests above and in + // `identity.rs`. + let expected_session = "sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap(); + let expected_retention = "ret_270adecd2120c543261f04bd771df491".parse().unwrap(); + + let row = session_start(at, Some(&vendor_session_id)); + + 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, HookAgent::Claude); + assert_eq!(row.os, OperatingSystem::Linux); + assert_eq!(row.arch, Architecture::X86_64); + assert_eq!(row.start, SessionStartKind::Fresh); + assert_eq!(row.session_id, Some(expected_session)); + assert_eq!(row.retention_subject, expected_retention); + assert_eq!(row.cohort_day, CohortDay::D0); + } + + #[test] + fn session_start_before_utc_midnight_keeps_row_and_cohort_on_the_same_day() { + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 23, 59, 59).unwrap()); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + let row = SessionStartV1::new(session_start_fields(HookAgent::Claude, None), &observation); + let stored_state = toml::to_string(&state).unwrap(); + let stored_state = toml::from_str::(&stored_state).unwrap(); + let cohort_anchor = stored_state["identity"]["return-cohort-anchor"] + .as_str() + .unwrap(); + + assert_eq!(row.at, completed_at); + assert_eq!(row.day, completed_at.day()); + assert_eq!(cohort_anchor, row.day.to_string()); + assert_eq!(row.cohort_day, CohortDay::D0); + } + + #[test] + fn new_agent_configuration_derives_subject_from_its_agent() { + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, and agent. The complete digest is + // e346647f3c83e0f8bea71a0ff04bfb6fa601f0967a92713894dcea7f793214b0. + let expected_subject = "agt_e346647f3c83e0f8bea71a0ff04bfb6f".parse().unwrap(); + + let row = agent_configuration(SupportedAgent::Claude); + + 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.to_string(), "2026-08-03"); + 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, expected_subject); + } + + #[test] + fn agent_subject_changes_with_the_agent() { + let claude = agent_configuration(SupportedAgent::Claude); + let codex = agent_configuration(SupportedAgent::Codex); + + assert_ne!(claude.agent_subject, codex.agent_subject); + } + + #[test] + fn session_start_without_session_id_classifies_and_round_trips() { + let row = session_start(session_start_time(), 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 = session_start(session_start_time(), 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/command.rs b/src/telemetry/schema/command.rs new file mode 100644 index 00000000..661ead97 --- /dev/null +++ b/src/telemetry/schema/command.rs @@ -0,0 +1,747 @@ +//! Eligible command vocabulary shared by command telemetry. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, + extension::{PublicExtensionName, PublicExtensionNameError, PublicExtensionSource}, + macros::strict_versioned_row, + name::{InitialByteRule, validated_string_newtype}, +}; +use crate::{ + cli::{Commands, PluginCommand}, + telemetry::{ + identity::{CommandDomain, CommandSubject, DimensionWriter, IdentityDimension}, + state::BoundRecordingObservation, + }, +}; + +const MAX_PUBLIC_COMMAND_NAME_BYTES: usize = 64; + +/// Built-in command eligible for version 1 telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum BuiltinCommand { + Init, + Sync, + Search, + Use, + Remove, + Status, + PluginSync, + PluginList, + PluginShow, + PluginValidate, + SelfUpdate, + CrateInfo, +} + +impl BuiltinCommand { + /// Classify a parsed top-level CLI command for command telemetry. + /// + /// Hook and telemetry commands are excluded by the version 1 contract. + /// External commands are classified separately using their public plugin + /// provenance. + #[must_use] + pub(in crate::telemetry) const fn from_cli(command: &Commands) -> Option { + match command { + Commands::Init { .. } => Some(Self::Init), + Commands::Sync => Some(Self::Sync), + Commands::Search { .. } => Some(Self::Search), + Commands::Use { remove: false, .. } => Some(Self::Use), + Commands::Use { remove: true, .. } => Some(Self::Remove), + Commands::Status => Some(Self::Status), + Commands::Plugin { command } => Some(Self::from_plugin_cli(command)), + Commands::SelfUpdate => Some(Self::SelfUpdate), + Commands::CrateInfo { .. } => Some(Self::CrateInfo), + Commands::Hook { .. } | Commands::Telemetry { .. } | Commands::External(_) => None, + } + } + + const fn from_plugin_cli(command: &PluginCommand) -> Self { + match command { + PluginCommand::Sync { .. } => Self::PluginSync, + PluginCommand::List => Self::PluginList, + PluginCommand::Show { .. } => Self::PluginShow, + PluginCommand::Validate { .. } => Self::PluginValidate, + } + } + + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::Init => "init", + Self::Sync => "sync", + Self::Search => "search", + Self::Use => "use", + Self::Remove => "remove", + Self::Status => "status", + Self::PluginSync => "plugin_sync", + Self::PluginList => "plugin_list", + Self::PluginShow => "plugin_show", + Self::PluginValidate => "plugin_validate", + Self::SelfUpdate => "self_update", + Self::CrateInfo => "crate_info", + } + } +} + +/// Closed result of an eligible command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum CommandOutcome { + Ok, + Error, +} + +validated_string_newtype! { + /// Public plugin-command name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicCommandName { + error = PublicCommandNameError; + maximum_bytes = MAX_PUBLIC_COMMAND_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphanumeric; + invalid_initial = NonAlphanumericFirstCharacter; + noun = "public command name"; + as_str_doc = "Return the validated command name without changing its spelling."; + } +} + +/// Eligible public plugin command safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicPluginCommandCoordinate { + source: PublicExtensionSource, + plugin: PublicExtensionName, + name: PublicCommandName, +} + +impl PublicPluginCommandCoordinate { + /// Combine validated components into one public plugin-command coordinate. + #[must_use] + pub(in crate::telemetry) const fn new( + source: PublicExtensionSource, + plugin: PublicExtensionName, + name: PublicCommandName, + ) -> Self { + Self { + source, + plugin, + name, + } + } + + /// Validate raw names and combine them with an allowlisted public source. + /// + /// # Errors + /// + /// Returns an error when either name is outside its version 1 public + /// telemetry grammar. + pub(in crate::telemetry) fn try_new( + source: PublicExtensionSource, + plugin: &str, + name: &str, + ) -> Result { + Ok(Self::new(source, plugin.parse()?, name.parse()?)) + } +} + +/// Reason a public plugin-command coordinate is ineligible for telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum InvalidPublicPluginCommandCoordinate { + PluginName(PublicExtensionNameError), + CommandName(PublicCommandNameError), +} + +impl From for InvalidPublicPluginCommandCoordinate { + fn from(error: PublicExtensionNameError) -> Self { + Self::PluginName(error) + } +} + +impl From for InvalidPublicPluginCommandCoordinate { + fn from(error: PublicCommandNameError) -> Self { + Self::CommandName(error) + } +} + +impl fmt::Display for InvalidPublicPluginCommandCoordinate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PluginName(error) => write!(formatter, "invalid plugin name: {error}"), + Self::CommandName(error) => write!(formatter, "invalid command name: {error}"), + } + } +} + +impl std::error::Error for InvalidPublicPluginCommandCoordinate { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PluginName(error) => Some(error), + Self::CommandName(error) => Some(error), + } + } +} + +/// Typed coordinate of an eligible command. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(in crate::telemetry) enum CommandCoordinate { + Builtin { name: BuiltinCommand }, + Plugin(PublicPluginCommandCoordinate), +} + +impl CommandCoordinate { + /// Build a coordinate for a fixed built-in command. + #[must_use] + pub(in crate::telemetry) const fn builtin(name: BuiltinCommand) -> Self { + Self::Builtin { name } + } + + /// Build a coordinate for an eligible public plugin command. + #[must_use] + pub(in crate::telemetry) const fn plugin(coordinate: PublicPluginCommandCoordinate) -> Self { + Self::Plugin(coordinate) + } +} + +impl IdentityDimension for CommandCoordinate { + type Domain = CommandDomain; + + /// Write the version 1 `command_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + match self { + Self::Builtin { name } => writer.variant("builtin", |writer| { + writer.field(name.as_str().as_bytes()); + }), + Self::Plugin(coordinate) => writer.variant("plugin", |writer| { + writer.field(coordinate.source.as_str().as_bytes()); + writer.field(coordinate.plugin.as_str().as_bytes()); + writer.field(coordinate.name.as_str().as_bytes()); + }), + } + } +} + +strict_versioned_row! { + /// Version 1 record of one completed eligible top-level command. + pub(in crate::telemetry) struct CommandV1 { + at: UtcSecond, + symposium: SymposiumVersion, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + command_subject: CommandSubject, + } + + kind: RowKind::Command, + raw: RawCommandV1, + error: CommandError, + validate: validate_command, +} + +impl CommandV1 { + /// Create a record for one completed eligible top-level command. + #[must_use] + pub(in crate::telemetry) fn new( + observation: &BoundRecordingObservation<'_>, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + ) -> Self { + let at = observation.completed_at(); + let day = observation.day(); + let command_subject = observation.identifier_window_scope().derive(&command); + + Self { + version: SchemaVersion::V1, + kind: Self::KIND, + event_id: EventId::new(), + day, + at, + symposium: SymposiumVersion::current(), + command, + duration_ms, + outcome, + command_subject, + } + } +} + +fn validate_command(raw: &RawCommandV1) -> Result<(), CommandError> { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(CommandError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); + } + + Ok(()) +} + +/// Invalid relationship between fields in a command row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandError { + DayDoesNotMatchTimestamp { stored: UtcDay, timestamp: UtcDay }, +} + +impl fmt::Display for CommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DayDoesNotMatchTimestamp { stored, timestamp } => write!( + formatter, + "stored command day {stored} does not match timestamp day {timestamp}" + ), + } + } +} + +impl std::error::Error for CommandError {} + +#[cfg(test)] +mod tests { + use clap::Parser as _; + + use super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, + assert_contract_names_with_labels, classify_row, recorded_data_example_block_at, + recorded_data_example_row, recording_observation, + }; + use super::*; + use crate::{ + cli::Cli, + telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}, + }; + + fn parse_command(arguments: &[&str]) -> Commands { + Cli::try_parse_from(std::iter::once("cargo-agents").chain(arguments.iter().copied())) + .unwrap() + .command + .unwrap() + } + + fn plugin_command( + source: PublicExtensionSource, + plugin: &str, + name: &str, + ) -> CommandCoordinate { + CommandCoordinate::plugin( + PublicPluginCommandCoordinate::try_new(source, plugin, name).unwrap(), + ) + } + + fn command_row(command: CommandCoordinate) -> CommandV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + + CommandV1::new(&observation, command, 820, CommandOutcome::Ok) + } + + #[test] + fn cli_commands_map_exhaustively_to_telemetry_builtins() { + let cases: &[(&[&str], Option)] = &[ + (&["init"], Some(BuiltinCommand::Init)), + (&["sync"], Some(BuiltinCommand::Sync)), + (&["search", "serde"], Some(BuiltinCommand::Search)), + (&["use", "serde"], Some(BuiltinCommand::Use)), + (&["use", "serde", "--remove"], Some(BuiltinCommand::Remove)), + (&["status"], Some(BuiltinCommand::Status)), + (&["plugin", "sync"], Some(BuiltinCommand::PluginSync)), + (&["plugin", "list"], Some(BuiltinCommand::PluginList)), + ( + &["plugin", "show", "example-tools"], + Some(BuiltinCommand::PluginShow), + ), + ( + &["plugin", "validate", "."], + Some(BuiltinCommand::PluginValidate), + ), + (&["self-update"], Some(BuiltinCommand::SelfUpdate)), + (&["crate-info", "serde"], Some(BuiltinCommand::CrateInfo)), + (&["hook", "claude", "session-start"], None), + (&["telemetry"], None), + (&["example-check"], None), + ]; + + for &(arguments, expected) in cases { + let command = parse_command(arguments); + + assert_eq!(BuiltinCommand::from_cli(&command), expected); + } + } + + #[test] + fn builtin_commands_round_trip_with_contract_names() { + let cases = [ + (BuiltinCommand::Init, "init"), + (BuiltinCommand::Sync, "sync"), + (BuiltinCommand::Search, "search"), + (BuiltinCommand::Use, "use"), + (BuiltinCommand::Remove, "remove"), + (BuiltinCommand::Status, "status"), + (BuiltinCommand::PluginSync, "plugin_sync"), + (BuiltinCommand::PluginList, "plugin_list"), + (BuiltinCommand::PluginShow, "plugin_show"), + (BuiltinCommand::PluginValidate, "plugin_validate"), + (BuiltinCommand::SelfUpdate, "self_update"), + (BuiltinCommand::CrateInfo, "crate_info"), + ]; + + assert_contract_names_with_labels(&cases, BuiltinCommand::as_str); + } + + #[test] + fn command_outcomes_round_trip_with_contract_names() { + let cases = [(CommandOutcome::Ok, "ok"), (CommandOutcome::Error, "error")]; + + assert_contract_names(&cases); + } + + #[test] + fn command_vocabulary_rejects_unknown_contract_names() { + let builtin = serde_json::from_str::(r#""telemetry""#); + let outcome = serde_json::from_str::(r#""cancelled""#); + + assert!(builtin.is_err()); + assert!(outcome.is_err()); + } + + #[test] + fn public_command_names_accept_the_contract_grammar() { + for value in ["0", "Example-check_2", &"a".repeat(64)] { + let name = value.parse::().unwrap(); + + assert_eq!(name.as_str(), value); + } + } + + #[test] + fn public_command_names_reject_invalid_length() { + let empty = "".parse::(); + let too_long = "a".repeat(65).parse::(); + + assert_eq!(empty.unwrap_err(), PublicCommandNameError::Empty); + assert_eq!(too_long.unwrap_err(), PublicCommandNameError::TooLong); + } + + #[test] + fn public_command_names_require_an_ascii_alphanumeric_first_byte() { + for value in ["-check", "_check", "\u{e9}check"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicCommandNameError::NonAlphanumericFirstCharacter + ); + } + } + + #[test] + fn public_command_names_reject_unsupported_characters() { + for value in ["plugin.check", "plugin check", "plugin/check", "a\u{e9}"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicCommandNameError::UnsupportedCharacter + ); + } + } + + #[test] + fn public_command_names_round_trip_without_normalization() { + let name = "Example-check_2".parse::().unwrap(); + + let encoded = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&encoded).unwrap(); + + assert_eq!(encoded, r#""Example-check_2""#); + assert_eq!(decoded, name); + } + + #[test] + fn plugin_command_coordinate_round_trips_in_contract_shape() { + let command = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + + let json = serde_json::to_string(&command).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + recorded_data_example_block_at("### `command`", "```json", 1).trim() + ); + assert_eq!(decoded, command); + } + + #[test] + fn builtin_command_coordinate_round_trips_in_contract_shape() { + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + + let json = serde_json::to_string(&command).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + recorded_data_example_block_at("### `command`", "```json", 0).trim() + ); + assert_eq!(decoded, command); + } + + #[test] + fn builtin_command_subject_dimension_uses_type_then_name() { + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 7].as_slice(), + b"builtin".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 3].as_slice(), + b"use".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&command); + + assert_eq!(encoded, expected); + } + + #[test] + fn plugin_command_subject_dimension_uses_contract_field_order() { + let command = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 6].as_slice(), + b"plugin".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 25].as_slice(), + b"symposium-recommendations".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 13].as_slice(), + b"example-tools".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 13].as_slice(), + b"example-check".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&command); + + assert_eq!(encoded, expected); + } + + #[test] + fn command_subject_derivation_matches_independent_vector() { + let command = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + + let subject = command_row(command).command_subject; + + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, command type, source, plugin name, and command + // name. The complete digest is + // c50f828e42f9eb719589039d90da38fa69c82f644689a19ce34562513a236c41. + assert_eq!( + subject, + "cmd_c50f828e42f9eb719589039d90da38fa".parse().unwrap() + ); + } + + #[test] + fn command_subject_changes_with_the_typed_coordinate() { + let baseline = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + let changed_coordinates = [ + CommandCoordinate::builtin(BuiltinCommand::Use), + plugin_command( + PublicExtensionSource::CratesIo, + "example-tools", + "example-check", + ), + plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "other-tools", + "example-check", + ), + plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "other-check", + ), + ]; + + let baseline_subject = command_row(baseline).command_subject; + let use_subject = + command_row(CommandCoordinate::builtin(BuiltinCommand::Use)).command_subject; + let remove_subject = + command_row(CommandCoordinate::builtin(BuiltinCommand::Remove)).command_subject; + + for coordinate in changed_coordinates { + assert_ne!(command_row(coordinate).command_subject, baseline_subject); + } + assert_ne!(use_subject, remove_subject); + } + + #[test] + fn command_example_round_trips_in_contract_shape() { + let source = recorded_data_example_row("command"); + + let RowClassification::Supported(TelemetryRow::Command(row)) = classify_row(source) else { + panic!("documented command row was not classified as supported"); + }; + let serialized = serde_json::to_string(&row).unwrap(); + + assert_eq!(serialized, source); + } + + #[test] + fn new_command_derives_fixed_fields_day_and_subject() { + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + // Cross-checked in the same independent .NET calculation as the + // plugin-command vector. The complete digest is + // 0b0899a55d1cbe757b8b505091f6e1f3a4646f26a9a05de1769faaea84f249ca. + let expected_subject = "cmd_0b0899a55d1cbe757b8b505091f6e1f3".parse().unwrap(); + + let row = command_row(command.clone()); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::Command); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, row.at.day()); + assert_eq!( + serde_json::to_string(&row.at).unwrap(), + r#""2026-08-03T10:02:11Z""# + ); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.command, command); + assert_eq!(row.duration_ms, 820); + assert_eq!(row.outcome, CommandOutcome::Ok); + assert_eq!(row.command_subject, expected_subject); + } + + #[test] + fn command_rejects_a_day_that_disagrees_with_its_timestamp() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + 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 command day 2026-08-04 does not match timestamp day 2026-08-03") + ); + } + + #[test] + fn command_rejects_future_version() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["v"] = serde_json::Value::from(2); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + + #[test] + fn command_rejects_another_row_kind() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["kind"] = serde_json::Value::String("session_start".to_owned()); + + let result = serde_json::from_value::(value); + + assert!( + result + .unwrap_err() + .to_string() + .contains("expected Command row kind, found SessionStart") + ); + } + + #[test] + fn command_rejects_unknown_fields() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["arguments"] = serde_json::Value::String("example-tools".to_owned()); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + + #[test] + fn command_requires_every_field() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value.as_object_mut().unwrap().remove("duration_ms"); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + + #[test] + fn plugin_command_coordinate_validates_both_names() { + let invalid_plugin = PublicPluginCommandCoordinate::try_new( + PublicExtensionSource::CratesIo, + "private/plugin", + "check", + ); + let invalid_command = PublicPluginCommandCoordinate::try_new( + PublicExtensionSource::CratesIo, + "example-tools", + "private/check", + ); + let invalid_json = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools","name":"private/check"}"#, + ); + + assert_eq!( + invalid_plugin.unwrap_err(), + InvalidPublicPluginCommandCoordinate::PluginName( + PublicExtensionNameError::UnsupportedCharacter + ) + ); + assert_eq!( + invalid_command.unwrap_err(), + InvalidPublicPluginCommandCoordinate::CommandName( + PublicCommandNameError::UnsupportedCharacter + ) + ); + assert!(invalid_json.is_err()); + } + + #[test] + fn command_coordinates_reject_unknown_or_missing_fields() { + let unknown_builtin_field = serde_json::from_str::( + r#"{"type":"builtin","name":"use","args":"example-tools"}"#, + ); + let unknown_plugin_field = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools","name":"check","args":"--all"}"#, + ); + let missing_field = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools"}"#, + ); + let unknown_type = + serde_json::from_str::(r#"{"type":"external","name":"check"}"#); + + assert!(unknown_builtin_field.is_err()); + assert!(unknown_plugin_field.is_err()); + assert!(missing_field.is_err()); + assert!(unknown_type.is_err()); + } +} diff --git a/src/telemetry/schema/extension.rs b/src/telemetry/schema/extension.rs new file mode 100644 index 00000000..973888b9 --- /dev/null +++ b/src/telemetry/schema/extension.rs @@ -0,0 +1,284 @@ +//! Public extension vocabulary shared by telemetry rows. + +use serde::{Deserialize, Serialize}; + +use super::name::{InitialByteRule, validated_string_newtype}; + +const MAX_PUBLIC_EXTENSION_NAME_BYTES: usize = 64; + +/// Plugin or skill named by eligible public telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ExtensionKind { + Plugin, + Skill, +} + +impl ExtensionKind { + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::Plugin => "plugin", + Self::Skill => "skill", + } + } +} + +/// Public source approved for version 1 extension telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(in crate::telemetry) enum PublicExtensionSource { + SymposiumRecommendations, + CratesIo, +} + +impl PublicExtensionSource { + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::SymposiumRecommendations => "symposium-recommendations", + Self::CratesIo => "crates-io", + } + } +} + +validated_string_newtype! { + /// Public plugin or skill name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicExtensionName { + error = PublicExtensionNameError; + maximum_bytes = MAX_PUBLIC_EXTENSION_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphanumeric; + invalid_initial = NonAlphanumericFirstCharacter; + noun = "public extension name"; + as_str_doc = "Return the validated extension name without changing its spelling."; + } +} + +/// Public plugin or skill coordinate safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicExtensionCoordinate { + #[serde(rename = "type")] + kind: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, +} + +impl PublicExtensionCoordinate { + /// Combine validated components into one public extension coordinate. + #[must_use] + pub(in crate::telemetry) const fn new( + kind: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, + ) -> Self { + Self { kind, source, name } + } + + /// Validate a raw public name and combine it with its typed coordinate. + /// + /// # Errors + /// + /// Returns an error when `name` is outside the version 1 public extension + /// grammar. + pub(in crate::telemetry) fn try_new( + kind: ExtensionKind, + source: PublicExtensionSource, + name: &str, + ) -> Result { + Ok(Self::new(kind, source, name.parse()?)) + } + + /// Return the public plugin or skill kind. + #[must_use] + pub(in crate::telemetry) const fn kind(&self) -> ExtensionKind { + self.kind + } + + /// Return the allowlisted public source. + #[must_use] + pub(in crate::telemetry) const fn source(&self) -> PublicExtensionSource { + self.source + } + + /// Return the validated public extension name. + #[must_use] + pub(in crate::telemetry) const fn name(&self) -> &PublicExtensionName { + &self.name + } +} + +#[cfg(test)] +mod tests { + use super::super::assert_contract_names_with_labels; + use super::*; + + #[test] + fn extension_kinds_round_trip_with_contract_names() { + let cases = [ + (ExtensionKind::Plugin, "plugin"), + (ExtensionKind::Skill, "skill"), + ]; + + assert_contract_names_with_labels(&cases, ExtensionKind::as_str); + } + + #[test] + fn public_extension_sources_round_trip_with_contract_names() { + let cases = [ + ( + PublicExtensionSource::SymposiumRecommendations, + "symposium-recommendations", + ), + (PublicExtensionSource::CratesIo, "crates-io"), + ]; + + assert_contract_names_with_labels(&cases, PublicExtensionSource::as_str); + } + + #[test] + fn extension_vocabulary_rejects_unknown_contract_names() { + let kind = serde_json::from_str::(r#""command""#); + let source = serde_json::from_str::(r#""user-plugins""#); + + assert!(kind.is_err()); + assert!(source.is_err()); + } + + #[test] + fn public_extension_names_accept_the_contract_grammar() { + for value in ["0", "Example-runtime_2", &"a".repeat(64)] { + let name = value.parse::().unwrap(); + + assert_eq!(name.as_str(), value); + } + } + + #[test] + fn public_extension_names_reject_invalid_length() { + let empty = "".parse::(); + let too_long = "a".repeat(65).parse::(); + + assert_eq!(empty.unwrap_err(), PublicExtensionNameError::Empty); + assert_eq!(too_long.unwrap_err(), PublicExtensionNameError::TooLong); + } + + #[test] + fn public_extension_names_require_an_ascii_alphanumeric_first_byte() { + for value in ["-extension", "_extension", "\u{e9}xtension"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicExtensionNameError::NonAlphanumericFirstCharacter + ); + } + } + + #[test] + fn public_extension_names_reject_unsupported_characters() { + for value in [ + "extension.name", + "extension name", + "extension/name", + "a\u{e9}", + ] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicExtensionNameError::UnsupportedCharacter + ); + } + } + + #[test] + fn public_extension_names_round_trip_without_normalization() { + let name = "Example-runtime_2".parse::().unwrap(); + + let encoded = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&encoded).unwrap(); + + assert_eq!(encoded, r#""Example-runtime_2""#); + assert_eq!(decoded, name); + } + + #[test] + fn public_extension_name_validation_runs_during_deserialization() { + let invalid = serde_json::from_str::(r#""extension.name""#); + + assert!(invalid.is_err()); + } + + #[test] + fn public_extension_coordinate_round_trips_in_contract_order() { + let coordinate = PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "Example-debugging_2", + ) + .unwrap(); + + let json = serde_json::to_string(&coordinate).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + r#"{"type":"skill","source":"symposium-recommendations","name":"Example-debugging_2"}"# + ); + assert_eq!(decoded, coordinate); + } + + #[test] + fn public_extension_coordinate_exposes_its_validated_components() { + let name = "example-tools".parse::().unwrap(); + let coordinate = PublicExtensionCoordinate::new( + ExtensionKind::Plugin, + PublicExtensionSource::CratesIo, + name.clone(), + ); + + assert_eq!(coordinate.kind(), ExtensionKind::Plugin); + assert_eq!(coordinate.source(), PublicExtensionSource::CratesIo); + assert_eq!(coordinate.name(), &name); + } + + #[test] + fn public_extension_coordinate_rejects_unknown_fields() { + let json = + r#"{"type":"plugin","source":"crates-io","name":"example-tools","path":"private"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_extension_coordinate_requires_every_field() { + let json = r#"{"type":"plugin","source":"crates-io"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_extension_coordinate_validates_its_nested_name() { + let raw_result = PublicExtensionCoordinate::try_new( + ExtensionKind::Plugin, + PublicExtensionSource::CratesIo, + "private/plugin", + ); + let json_result = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","name":"private/plugin"}"#, + ); + + assert_eq!( + raw_result.unwrap_err(), + PublicExtensionNameError::UnsupportedCharacter + ); + assert!(json_result.is_err()); + } +} diff --git a/src/telemetry/schema/macros.rs b/src/telemetry/schema/macros.rs new file mode 100644 index 00000000..373515db --- /dev/null +++ b/src/telemetry/schema/macros.rs @@ -0,0 +1,105 @@ +//! Declarative helpers for strict versioned telemetry rows. + +/// Define a versioned row and its strict, validation-first wire form. +/// +/// The field list is the source of truth for the row's declaration, its raw +/// deserialization type, and the mechanical transfer between them. Validation +/// remains an ordinary function in the row module so its rules stay visible. +/// Serde field attributes are applied to both representations. The macro owns +/// container attributes, so callers can supply row documentation but not a +/// second, potentially conflicting set of derives or Serde rules. +macro_rules! strict_versioned_row { + ( + $(#[doc = $row_doc:literal])* + $visibility:vis struct $row:ident { + $( + $(#[$field_metadata:meta])* + $field:ident: $field_type:ty, + )* + } + + kind: $kind:path, + raw: $raw:ident, + error: $error:ty, + validate: $validate:path, + ) => { + $(#[doc = $row_doc])* + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] + $visibility struct $row { + #[serde(rename = "v")] + version: $crate::telemetry::schema::SchemaVersion, + kind: $crate::telemetry::schema::RowKind, + event_id: $crate::telemetry::schema::EventId, + day: $crate::telemetry::schema::UtcDay, + $( + $(#[$field_metadata])* + $field: $field_type, + )* + } + + impl $row { + const KIND: $crate::telemetry::schema::RowKind = $kind; + } + + // Serde's `try_from` attribute requires a string literal, which + // `macro_rules!` cannot construct from `$raw`. + impl<'de> serde::Deserialize<'de> for $row { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = <$raw as serde::Deserialize>::deserialize(deserializer)?; + + if raw.kind != Self::KIND { + return Err(serde::de::Error::custom(format_args!( + "expected {:?} row kind, found {:?}", + Self::KIND, + raw.kind + ))); + } + + Self::try_from(raw).map_err(serde::de::Error::custom) + } + } + + #[doc = concat!( + "Strict wire representation validated before becoming `", + stringify!($row), + "`." + )] + #[derive(Debug, serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct $raw { + #[serde( + rename = "v", + deserialize_with = "crate::telemetry::schema::deserialize_version_one" + )] + version: $crate::telemetry::schema::SchemaVersion, + kind: $crate::telemetry::schema::RowKind, + event_id: $crate::telemetry::schema::EventId, + day: $crate::telemetry::schema::UtcDay, + $( + $(#[$field_metadata])* + $field: $field_type, + )* + } + + impl TryFrom<$raw> for $row { + type Error = $error; + + fn try_from(raw: $raw) -> Result { + $validate(&raw)?; + + Ok(Self { + version: raw.version, + kind: Self::KIND, + event_id: raw.event_id, + day: raw.day, + $($field: raw.$field,)* + }) + } + } + }; +} + +pub(super) use strict_versioned_row; diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs new file mode 100644 index 00000000..bffb6b24 --- /dev/null +++ b/src/telemetry/schema/mod.rs @@ -0,0 +1,1143 @@ +//! 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 command; +mod extension; +mod macros; +mod name; +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 command::CommandV1; +use resolution::{ + ResolutionSummaryV1, extension::ExtensionResolutionV1, package::PackageResolutionV1, +}; + +/// 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), + PackageResolution(PackageResolutionV1), + ExtensionResolution(ExtensionResolutionV1), + Command(CommandV1), + 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::PackageResolution(row) => row.serialize(serializer), + Self::ExtensionResolution(row) => row.serialize(serializer), + Self::Command(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) + } + ("package_resolution", 1) => { + deserialize_supported_row(line, TelemetryRow::PackageResolution) + } + ("extension_resolution", 1) => { + deserialize_supported_row(line, TelemetryRow::ExtensionResolution) + } + ("command", 1) => deserialize_supported_row(line, TelemetryRow::Command), + ("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)] +const RECORDED_DATA_CONTRACT: &str = + include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); + +#[cfg(test)] +const IDENTIFIER_WINDOW_TEST_STATE: &str = r#"version = 1 + +[identity] +key = "4242424242424242424242424242424242424242424242424242424242424242" +identifier-window-anchor = "2026-08-03" +"#; + +/// Build a recording context that remains inside the fixture's identifier +/// window, so schema tests do not depend on separate timestamp choices. +#[cfg(test)] +fn recording_observation( + state: &mut crate::telemetry::state::TelemetryStateV1, +) -> crate::telemetry::state::BoundRecordingObservation<'_> { + use chrono::TimeZone as _; + + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 10, 2, 11).unwrap()); + let observation = state.observe_recording(completed_at).unwrap(); + state.bind_recording_observation(observation).unwrap() +} + +#[cfg(test)] +fn recorded_data_example_block(section_heading: &str, opening_fence: &str) -> &'static str { + recorded_data_example_block_at(section_heading, opening_fence, 0) +} + +#[cfg(test)] +fn recorded_data_example_block_at( + section_heading: &str, + opening_fence: &str, + block_index: usize, +) -> &'static str { + let (_, after_heading) = RECORDED_DATA_CONTRACT + .split_once(section_heading) + .unwrap_or_else(|| panic!("recorded-data contract must contain {section_heading}")); + let after_fence = after_heading + .split(opening_fence) + .skip(1) + .nth(block_index) + .unwrap_or_else(|| { + panic!("{section_heading} must contain {opening_fence} block {block_index}") + }); + let (example_block, _) = after_fence + .split_once("```") + .unwrap_or_else(|| panic!("{section_heading} example block must have a closing fence")); + + example_block +} + +#[cfg(test)] +fn recorded_data_example_row(requested_kind: &str) -> &'static str { + let example_block = + recorded_data_example_block("## Example JSONL for every row kind", "```jsonl"); + + 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")) +} + +#[cfg(test)] +fn assert_contract_names(cases: &[(T, &str)]) +where + T: Copy + fmt::Debug + PartialEq + Serialize + DeserializeOwned, +{ + for &(value, name) in cases { + let encoded = serde_json::to_string(&value).unwrap(); + let decoded: T = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(encoded, format!(r#""{name}""#)); + assert_eq!(decoded, value); + } +} + +#[cfg(test)] +fn assert_contract_names_with_labels(cases: &[(T, &str)], label: impl Fn(T) -> &'static str) +where + T: Copy + fmt::Debug + PartialEq + Serialize + DeserializeOwned, +{ + assert_contract_names(cases); + + for &(value, name) in cases { + assert_eq!(label(value), name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn example_row(requested_kind: &str) -> &'static str { + recorded_data_example_row(requested_kind) + } + + #[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"), + ]; + + assert_contract_names(&cases); + } + + #[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 package_resolution_example_round_trips() { + let example = example_row("package_resolution"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("package_resolution contract example was not classified as supported"); + }; + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn extension_resolution_example_round_trips() { + let example = example_row("extension_resolution"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("extension_resolution contract example was not classified as supported"); + }; + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn unsupported_extension_resolution_version_is_unknown_schema() { + let example = example_row("extension_resolution"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn extension_resolution_with_unknown_field_is_invalid() { + let example = example_row("extension_resolution"); + let json = example.replacen(r#""target""#, r#""future_field":true,"target""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_missing_field_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value.as_object_mut().unwrap().remove("path"); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_invalid_target_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value["target"]["name"] = serde_json::json!("private/example-debugging"); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_over_limit_path_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value["path"] = serde_json::Value::Array( + std::iter::repeat_n(serde_json::json!({ "type": "not" }), 17).collect(), + ); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn unsupported_package_resolution_version_is_unknown_schema() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn package_resolution_with_unknown_field_is_invalid() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""package""#, r#""future_field":true,"package""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn package_resolution_with_invalid_coordinate_is_invalid() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""version":"1.2.3""#, r#""version":"*""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[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"), + ]; + + assert_contract_names(&cases); + } + + #[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/name.rs b/src/telemetry/schema/name.rs new file mode 100644 index 00000000..5bfe5fbc --- /dev/null +++ b/src/telemetry/schema/name.rs @@ -0,0 +1,200 @@ +//! Validation shared by public names in the telemetry contract. + +/// Define a public-name newtype and its grammar-specific error type. +macro_rules! validated_string_newtype { + ( + $(#[$metadata:meta])* + $visibility:vis struct $name:ident { + error = $error:ident; + maximum_bytes = $maximum_bytes:expr; + initial_byte_rule = $initial_byte_rule:expr; + invalid_initial = $invalid_initial:ident; + noun = $noun:literal; + as_str_doc = $as_str_doc:literal; + } + ) => { + $(#[$metadata])* + #[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, + )] + #[serde(try_from = "String")] + $visibility struct $name(String); + + impl $name { + #[doc = $as_str_doc] + #[must_use] + $visibility fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = $error; + + fn try_from(value: String) -> Result { + $crate::telemetry::schema::name::validate_public_name( + &value, + $maximum_bytes, + $initial_byte_rule, + ) + .map_err($error::from)?; + Ok(Self(value)) + } + } + + impl std::str::FromStr for $name { + type Err = $error; + + fn from_str(value: &str) -> Result { + $crate::telemetry::schema::name::validate_public_name( + value, + $maximum_bytes, + $initial_byte_rule, + ) + .map_err($error::from)?; + Ok(Self(value.to_owned())) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } + } + + #[doc = concat!("Reason a ", $noun, " cannot enter public telemetry.")] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + $visibility enum $error { + Empty, + TooLong, + $invalid_initial, + UnsupportedCharacter, + } + + impl From<$crate::telemetry::schema::name::PublicNameViolation> for $error { + fn from( + violation: $crate::telemetry::schema::name::PublicNameViolation, + ) -> Self { + match violation { + $crate::telemetry::schema::name::PublicNameViolation::Empty => Self::Empty, + $crate::telemetry::schema::name::PublicNameViolation::TooLong => Self::TooLong, + $crate::telemetry::schema::name::PublicNameViolation::InvalidInitialByte => { + Self::$invalid_initial + } + $crate::telemetry::schema::name::PublicNameViolation::UnsupportedCharacter => { + Self::UnsupportedCharacter + } + } + } + } + + impl std::fmt::Display for $error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(formatter, "{} must not be empty", $noun), + Self::TooLong => write!( + formatter, + "{} exceeds {} bytes", + $noun, + $maximum_bytes, + ), + Self::$invalid_initial => write!( + formatter, + "{} must start with {}", + $noun, + ($initial_byte_rule).description(), + ), + Self::UnsupportedCharacter => write!( + formatter, + "{} may contain only ASCII letters, digits, hyphens, and underscores", + $noun, + ), + } + } + } + + impl std::error::Error for $error {} + }; +} + +pub(super) use validated_string_newtype; + +/// Rule applied to the first byte of a public name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InitialByteRule { + Alphabetic, + Alphanumeric, +} + +impl InitialByteRule { + /// Describe the initial byte accepted by this validation rule. + pub(super) const fn description(self) -> &'static str { + match self { + Self::Alphabetic => "an ASCII letter", + Self::Alphanumeric => "an ASCII letter or digit", + } + } +} + +/// Structural reason a public name fails its versioned grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PublicNameViolation { + Empty, + TooLong, + InvalidInitialByte, + UnsupportedCharacter, +} + +/// Validate the common ASCII shape of a versioned public telemetry name. +pub(super) fn validate_public_name( + value: &str, + maximum_bytes: usize, + initial_byte_rule: InitialByteRule, +) -> Result<(), PublicNameViolation> { + let Some((first, rest)) = value.as_bytes().split_first() else { + return Err(PublicNameViolation::Empty); + }; + + if value.len() > maximum_bytes { + return Err(PublicNameViolation::TooLong); + } + + let initial_byte_is_valid = match initial_byte_rule { + InitialByteRule::Alphabetic => first.is_ascii_alphabetic(), + InitialByteRule::Alphanumeric => first.is_ascii_alphanumeric(), + }; + if !initial_byte_is_valid { + return Err(PublicNameViolation::InvalidInitialByte); + } + + if !rest + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PublicNameViolation::UnsupportedCharacter); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_byte_rules_describe_their_enforced_grammar() { + assert_eq!(InitialByteRule::Alphabetic.description(), "an ASCII letter"); + assert_eq!( + InitialByteRule::Alphanumeric.description(), + "an ASCII letter or digit" + ); + } +} diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs new file mode 100644 index 00000000..07655a52 --- /dev/null +++ b/src/telemetry/schema/resolution.rs @@ -0,0 +1,617 @@ +//! Schema types for resolution telemetry. + +pub(in crate::telemetry) mod extension; +pub(in crate::telemetry) mod package; + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, + macros::strict_versioned_row, +}; +use crate::telemetry::{identity::SessionId, state::BoundRecordingObservation}; + +/// 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, +} + +/// 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, +} + +strict_versioned_row! { + /// Version 1 summary of one completed full resolution and sync. + pub(in crate::telemetry) struct ResolutionSummaryV1 { + 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, + } + + kind: RowKind::ResolutionSummary, + raw: RawResolutionSummaryV1, + error: ResolutionSummaryError, + validate: validate_resolution_summary, +} + +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( + observation: &BoundRecordingObservation<'_>, + fields: ResolutionSummaryFields, + ) -> Result { + let unnamed_packages = fields + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + Ok(Self { + version: SchemaVersion::V1, + kind: Self::KIND, + event_id: EventId::new(), + day: observation.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 {} + +fn validate_resolution_summary(raw: &RawResolutionSummaryV1) -> Result<(), ResolutionSummaryError> { + 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(()) +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, UtcDay, assert_contract_names, recording_observation, + }; + use super::*; + use crate::telemetry::state::TelemetryStateV1; + + 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 resolution_summary( + fields: ResolutionSummaryFields, + ) -> Result { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + + ResolutionSummaryV1::new(&observation, fields) + } + + 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 = resolution_summary(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 = resolution_summary(fields); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + } + + #[test] + fn direct_resolution_summary_deserialization_rejects_future_version() { + let row = resolution_summary(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 = resolution_summary(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 = resolution_summary(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 = resolution_summary(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 = resolution_summary(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"), + ]; + + assert_contract_names(&cases); + + for (trigger, name) in cases { + let dropped_operation = + serde_json::to_string(&DroppedOperation::from(trigger)).unwrap(); + + assert_eq!(dropped_operation, format!(r#""{name}""#)); + } + } + + #[test] + fn resolution_outcomes_round_trip_with_contract_names() { + let cases = [ + (ResolutionOutcome::Ok, "ok"), + (ResolutionOutcome::Partial, "partial"), + (ResolutionOutcome::Error, "error"), + ]; + + assert_contract_names(&cases); + } + + #[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); + + assert!(trigger.is_err()); + assert!(outcome.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/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs new file mode 100644 index 00000000..03f57da4 --- /dev/null +++ b/src/telemetry/schema/resolution/extension.rs @@ -0,0 +1,787 @@ +//! Safe extension-resolution path vocabulary. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, + extension::{ + ExtensionKind, PublicExtensionCoordinate, PublicExtensionName, PublicExtensionSource, + }, + }, + package::PublicPackageCoordinate, +}; +use crate::telemetry::identity::{ + DimensionWriter, ExtensionDomain, ExtensionSubject, IdentifierWindowScope, IdentityDimension, +}; +use crate::telemetry::state::BoundRecordingObservation; + +/// Maximum root-to-leaf depth of a recorded resolution path. +const MAX_RESOLUTION_PATH_DEPTH: usize = 8; + +/// Maximum combined terminal-node count in a recorded resolution path. +const MAX_RESOLUTION_PATH_LEAVES: usize = 16; + +/// Maximum compact UTF-8 JSON size of a complete resolution path. +const MAX_RESOLUTION_PATH_ENCODED_BYTES: usize = 4 * 1024; + +/// Safe evidence node in a successful extension-resolution path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResolutionPathNode { + Package(PublicPackageCoordinate), + Extension(ExtensionNode), + All(AllNode), + Any(AnyNode), + Not(NotNode), + Opaque(OpaqueNode), +} + +impl ResolutionPathNode { + fn write_identity(&self, writer: &mut DimensionWriter<'_>) { + match self { + Self::Package(coordinate) => writer.variant("package", |writer| { + coordinate.write_identity_fields(writer); + }), + Self::Extension(node) => writer.variant("extension", |writer| { + writer.field(node.extension_type.as_str().as_bytes()); + writer.field(node.source.as_str().as_bytes()); + writer.field(node.name.as_str().as_bytes()); + }), + Self::All(node) => writer.variant("all", |writer| { + writer.sequence(&node.children, |writer, child| { + child.write_identity(writer); + }); + }), + Self::Any(node) => writer.variant("any", |writer| { + node.child.write_identity(writer); + }), + Self::Not(_) => writer.variant("not", |_| {}), + Self::Opaque(node) => writer.variant("opaque", |writer| { + writer.field(node.reason.as_str().as_bytes()); + }), + } + } + + fn validate_depth(&self, depth: usize) -> Result<(), ResolutionPathError> { + if depth > MAX_RESOLUTION_PATH_DEPTH { + return Err(ResolutionPathError::DepthExceeded { + observed: depth, + maximum: MAX_RESOLUTION_PATH_DEPTH, + }); + } + + match self { + Self::All(node) => { + let child_depth = depth + .checked_add(1) + .expect("BUG: resolution path depth is bounded before descending"); + for child in &node.children { + child.validate_depth(child_depth)?; + } + } + Self::Any(node) => { + let child_depth = depth + .checked_add(1) + .expect("BUG: resolution path depth is bounded before descending"); + node.child.validate_depth(child_depth)?; + } + Self::Package(_) | Self::Extension(_) | Self::Not(_) | Self::Opaque(_) => {} + } + + Ok(()) + } + + fn count_leaves(&self, leaf_count: &mut usize) -> Result<(), ResolutionPathError> { + match self { + Self::All(node) => { + for child in &node.children { + child.count_leaves(leaf_count)?; + } + } + Self::Any(node) => node.child.count_leaves(leaf_count)?, + Self::Package(_) | Self::Extension(_) | Self::Not(_) | Self::Opaque(_) => { + let observed = leaf_count + .checked_add(1) + .expect("BUG: resolution path leaf count is bounded before incrementing"); + + if observed > MAX_RESOLUTION_PATH_LEAVES { + return Err(ResolutionPathError::LeafCountExceeded { + observed, + maximum: MAX_RESOLUTION_PATH_LEAVES, + }); + } + + *leaf_count = observed; + } + } + + Ok(()) + } +} + +/// Complete evidence path for one successful extension resolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +pub(in crate::telemetry) struct ResolutionPath(Vec); + +impl ResolutionPath { + /// Derive the subject for this path and its resolved public target. + #[must_use] + fn derive_subject( + &self, + scope: &IdentifierWindowScope<'_>, + target: &PublicExtensionCoordinate, + ) -> ExtensionSubject { + scope.derive(&ExtensionSubjectDimension { target, path: self }) + } + + fn write_identity(&self, writer: &mut DimensionWriter<'_>) { + writer.sequence(&self.0, |writer, node| node.write_identity(writer)); + } +} + +/// Version 1 record of one public extension and a safe path that selected it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct ExtensionResolutionV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + target: PublicExtensionCoordinate, + path: ResolutionPath, + extension_subject: ExtensionSubject, +} + +impl ExtensionResolutionV1 { + /// Create a record for one public extension and its safe resolution path. + #[must_use] + pub(in crate::telemetry) fn new( + observation: &BoundRecordingObservation<'_>, + target: PublicExtensionCoordinate, + path: ResolutionPath, + ) -> Self { + let extension_subject = path.derive_subject(observation.identifier_window_scope(), &target); + + Self { + version: SchemaVersion::V1, + kind: RowKind::ExtensionResolution, + event_id: EventId::new(), + day: observation.day(), + symposium: SymposiumVersion::current(), + target, + path, + extension_subject, + } + } +} + +struct ExtensionSubjectDimension<'a> { + target: &'a PublicExtensionCoordinate, + path: &'a ResolutionPath, +} + +impl IdentityDimension for ExtensionSubjectDimension<'_> { + type Domain = ExtensionDomain; + + /// Write the version 1 `extension_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.target.kind().as_str().as_bytes()); + writer.field(self.target.source().as_str().as_bytes()); + writer.field(self.target.name().as_str().as_bytes()); + self.path.write_identity(writer); + } +} + +impl TryFrom> for ResolutionPath { + type Error = ResolutionPathError; + + fn try_from(nodes: Vec) -> Result { + if nodes.is_empty() { + return Err(ResolutionPathError::Empty); + } + + for node in &nodes { + node.validate_depth(1)?; + } + + let mut leaf_count = 0; + for node in &nodes { + node.count_leaves(&mut leaf_count)?; + } + + let encoded_size = serde_json::to_vec(&nodes) + .expect("BUG: resolution path nodes must have an infallible JSON representation") + .len(); + if encoded_size > MAX_RESOLUTION_PATH_ENCODED_BYTES { + return Err(ResolutionPathError::EncodedSizeExceeded { + observed: encoded_size, + maximum: MAX_RESOLUTION_PATH_ENCODED_BYTES, + }); + } + + Ok(Self(nodes)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolutionPathError { + Empty, + DepthExceeded { observed: usize, maximum: usize }, + LeafCountExceeded { observed: usize, maximum: usize }, + EncodedSizeExceeded { observed: usize, maximum: usize }, +} + +impl fmt::Display for ResolutionPathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("resolution path must contain at least one node"), + Self::DepthExceeded { observed, maximum } => write!( + formatter, + "resolution path depth {observed} exceeds maximum {maximum}" + ), + Self::LeafCountExceeded { observed, maximum } => write!( + formatter, + "resolution path leaf count {observed} exceeds maximum {maximum}" + ), + Self::EncodedSizeExceeded { observed, maximum } => write!( + formatter, + "resolution path encoded size {observed} bytes exceeds maximum {maximum} bytes" + ), + } + } +} + +impl std::error::Error for ResolutionPathError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionNode { + extension_type: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, +} + +impl From for ExtensionNode { + fn from(coordinate: PublicExtensionCoordinate) -> Self { + Self { + extension_type: coordinate.kind(), + source: coordinate.source(), + name: coordinate.name().clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawAllNode")] +struct AllNode { + children: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAllNode { + children: Vec, +} + +impl TryFrom for AllNode { + type Error = EmptyAllNode; + + fn try_from(raw: RawAllNode) -> Result { + if raw.children.is_empty() { + return Err(EmptyAllNode); + } + + Ok(Self { + children: raw.children, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EmptyAllNode; + +impl fmt::Display for EmptyAllNode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("all resolution node must contain at least one child") + } +} + +impl std::error::Error for EmptyAllNode {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AnyNode { + child: Box, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NotNode {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpaqueNode { + reason: OpaqueResolutionReason, +} + +/// Fixed explanation for resolution evidence that is unsafe to name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum OpaqueResolutionReason { + PrivateSource, + NonPackagePredicate, + Limit, +} + +impl OpaqueResolutionReason { + const fn as_str(self) -> &'static str { + match self { + Self::PrivateSource => "private_source", + Self::NonPackagePredicate => "non_package_predicate", + Self::Limit => "limit", + } + } +} + +#[cfg(test)] +mod tests { + use super::super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, + assert_contract_names_with_labels, classify_row, recorded_data_example_block, + recording_observation, + }; + use super::*; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; + + fn documented_path_node_examples() -> impl Iterator { + let example_block = recorded_data_example_block("### `extension_resolution`", "```json"); + + example_block.lines().filter(|line| !line.is_empty()) + } + + fn resolution_path_with_depth(depth: usize) -> String { + assert!(depth > 0); + + let mut node = r#"{"type":"not"}"#.to_owned(); + for _ in 1..depth { + node = format!(r#"{{"type":"any","child":{node}}}"#); + } + + format!("[{node}]") + } + + fn resolution_path_with_all_depth(depth: usize) -> String { + assert!(depth > 0); + + let mut node = r#"{"type":"not"}"#.to_owned(); + for _ in 1..depth { + node = format!(r#"{{"type":"all","children":[{node}]}}"#); + } + + format!("[{node}]") + } + + fn resolution_path_with_leaves(leaves: usize) -> String { + let nodes = std::iter::repeat_n(r#"{"type":"not"}"#, leaves) + .collect::>() + .join(","); + + format!("[{nodes}]") + } + + fn package_resolution_path_with_encoded_size(encoded_size: usize) -> String { + const PREFIX: &str = + r#"[{"type":"package","ecosystem":"cargo","name":"a","version":"1.2.3+"#; + const SUFFIX: &str = r#""}]"#; + + let metadata_size = encoded_size + .checked_sub(PREFIX.len() + SUFFIX.len()) + .unwrap(); + let json = format!("{PREFIX}{}{SUFFIX}", "a".repeat(metadata_size)); + + assert_eq!(json.len(), encoded_size); + json + } + + fn resolution_path_with_leaves_nested_under_all_and_any( + left_leaves: usize, + right_leaves: usize, + ) -> String { + let left_children = std::iter::repeat_n(serde_json::json!({ "type": "not" }), left_leaves) + .collect::>(); + let right_children = + std::iter::repeat_n(serde_json::json!({ "type": "not" }), right_leaves) + .collect::>(); + + serde_json::json!([{ + "type": "all", + "children": [ + { "type": "all", "children": left_children }, + { + "type": "any", + "child": { "type": "all", "children": right_children } + } + ] + }]) + .to_string() + } + + fn validate_resolution_path(json: &str) -> Result { + let nodes = serde_json::from_str::>(json) + .expect("BUG: generated test path must contain valid resolution nodes"); + + ResolutionPath::try_from(nodes) + } + + fn append_expected_field(output: &mut Vec, value: &str) { + let length = u64::try_from(value.len()).unwrap(); + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value.as_bytes()); + } + + fn public_target() -> PublicExtensionCoordinate { + PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "example-debugging", + ) + .unwrap() + } + + fn resolution_path_with_every_node_variant() -> ResolutionPath { + serde_json::from_str( + r#"[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"extension","extension_type":"plugin","source":"crates-io","name":"example-tools"},{"type":"all","children":[{"type":"any","child":{"type":"opaque","reason":"private_source"}},{"type":"not"}]}]"#, + ) + .unwrap() + } + + #[test] + fn documented_resolution_path_nodes_round_trip_in_contract_shape() { + for json in documented_path_node_examples() { + let node = serde_json::from_str::(json).unwrap(); + let encoded = serde_json::to_string(&node).unwrap(); + + assert_eq!(encoded, json); + } + } + + #[test] + fn opaque_resolution_reasons_round_trip_with_identity_labels() { + let cases = [ + (OpaqueResolutionReason::PrivateSource, "private_source"), + ( + OpaqueResolutionReason::NonPackagePredicate, + "non_package_predicate", + ), + (OpaqueResolutionReason::Limit, "limit"), + ]; + + assert_contract_names_with_labels(&cases, OpaqueResolutionReason::as_str); + } + + #[test] + fn non_empty_resolution_path_round_trips_as_an_array() { + let json = r#"[{"type":"not"}]"#; + + let path = serde_json::from_str::(json).unwrap(); + let encoded = serde_json::to_string(&path).unwrap(); + + assert_eq!(encoded, json); + } + + #[test] + fn extension_subject_dimension_places_target_before_counted_path() { + let target = public_target(); + let path = serde_json::from_str::(r#"[{"type":"not"}]"#).unwrap(); + let dimension = ExtensionSubjectDimension { + target: &target, + path: &path, + }; + + let encoded = encode_dimension_for_test(&dimension); + let expected = [ + 5_u64.to_be_bytes().as_slice(), + b"skill", + 25_u64.to_be_bytes().as_slice(), + b"symposium-recommendations", + 17_u64.to_be_bytes().as_slice(), + b"example-debugging", + 1_u64.to_be_bytes().as_slice(), + 3_u64.to_be_bytes().as_slice(), + b"not", + ] + .concat(); + + assert_eq!(encoded, expected); + } + + #[test] + fn extension_subject_dimension_encodes_every_path_node_variant() { + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + let dimension = ExtensionSubjectDimension { + target: &target, + path: &path, + }; + + let encoded = encode_dimension_for_test(&dimension); + let mut expected = Vec::new(); + append_expected_field(&mut expected, "skill"); + append_expected_field(&mut expected, "symposium-recommendations"); + append_expected_field(&mut expected, "example-debugging"); + expected.extend_from_slice(&3_u64.to_be_bytes()); + append_expected_field(&mut expected, "package"); + append_expected_field(&mut expected, "cargo"); + append_expected_field(&mut expected, "example-runtime"); + append_expected_field(&mut expected, "1.2.3"); + append_expected_field(&mut expected, "extension"); + append_expected_field(&mut expected, "plugin"); + append_expected_field(&mut expected, "crates-io"); + append_expected_field(&mut expected, "example-tools"); + append_expected_field(&mut expected, "all"); + expected.extend_from_slice(&2_u64.to_be_bytes()); + append_expected_field(&mut expected, "any"); + append_expected_field(&mut expected, "opaque"); + append_expected_field(&mut expected, "private_source"); + append_expected_field(&mut expected, "not"); + + assert_eq!(encoded, expected); + } + + #[test] + fn extension_subject_derivation_matches_independent_vector() { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, public target, and complete recursive path. The + // complete digest is + // 63872efd4737ec84179b4e8b0662c1212e9e3295e1940387c4a4e2cca0a9090e. + let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); + + let subject = path.derive_subject(observation.identifier_window_scope(), &target); + + assert_eq!(subject, expected_subject); + } + + #[test] + fn new_extension_resolution_derives_subject_from_its_target_and_path() { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + let day = observation.day(); + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); + + let row = ExtensionResolutionV1::new(&observation, target, path); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::ExtensionResolution); + 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.target, public_target()); + assert_eq!(row.path, resolution_path_with_every_node_variant()); + assert_eq!(row.extension_subject, expected_subject); + } + + #[test] + fn nested_extension_resolution_round_trips_through_the_classifier() { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + let row = ExtensionResolutionV1::new( + &observation, + public_target(), + resolution_path_with_every_node_variant(), + ); + let json = serde_json::to_string(&row).unwrap(); + + let RowClassification::Supported(TelemetryRow::ExtensionResolution(decoded)) = + classify_row(&json) + else { + panic!("nested extension_resolution row was not classified as supported"); + }; + + assert_eq!(decoded, row); + } + + #[test] + fn empty_resolution_path_is_rejected_at_both_boundaries() { + let constructed = ResolutionPath::try_from(Vec::new()); + let deserialized = serde_json::from_str::("[]"); + + assert_eq!(constructed.unwrap_err(), ResolutionPathError::Empty); + assert!( + deserialized + .unwrap_err() + .to_string() + .contains("resolution path must contain at least one node") + ); + } + + #[test] + fn resolution_path_accepts_depth_eight_and_rejects_depth_nine() { + let at_limit = resolution_path_with_depth(8); + let beyond_limit = resolution_path_with_depth(9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::DepthExceeded { + observed: 9, + maximum: 8, + } + ); + } + + #[test] + fn resolution_path_counts_depth_through_all_nodes() { + let at_limit = resolution_path_with_all_depth(8); + let beyond_limit = resolution_path_with_all_depth(9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::DepthExceeded { + observed: 9, + maximum: 8, + } + ); + } + + #[test] + fn resolution_path_accepts_sixteen_leaves_and_rejects_seventeen() { + let at_limit = resolution_path_with_leaves(16); + let beyond_limit = resolution_path_with_leaves(17); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::LeafCountExceeded { + observed: 17, + maximum: 16, + } + ); + } + + #[test] + fn resolution_path_counts_only_terminal_leaves_across_nested_all_and_any_nodes() { + let at_limit = resolution_path_with_leaves_nested_under_all_and_any(8, 8); + let beyond_limit = resolution_path_with_leaves_nested_under_all_and_any(8, 9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::LeafCountExceeded { + observed: 17, + maximum: 16, + } + ); + } + + #[test] + fn resolution_path_accepts_4096_bytes_and_rejects_4097() { + let at_limit = package_resolution_path_with_encoded_size(4_096); + let beyond_limit = package_resolution_path_with_encoded_size(4_097); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::EncodedSizeExceeded { + observed: 4_097, + maximum: 4_096, + } + ); + } + + #[test] + fn extension_node_is_built_from_a_validated_coordinate() { + let coordinate = PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "example-debugging", + ) + .unwrap(); + + let node = ResolutionPathNode::Extension(coordinate.into()); + let encoded = serde_json::to_string(&node).unwrap(); + + assert_eq!( + encoded, + r#"{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}"# + ); + } + + #[test] + fn resolution_path_nodes_reject_unknown_nested_fields() { + let json = r#"{"type":"any","child":{"type":"not","predicate":"private"}}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn resolution_path_nodes_require_their_contract_fields() { + let json = r#"{"type":"extension","extension_type":"skill","name":"example-debugging"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn all_resolution_node_requires_at_least_one_child() { + let json = r#"{"type":"all","children":[]}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn resolution_path_nodes_reject_unknown_contract_vocabulary() { + let unknown_type = serde_json::from_str::(r#"{"type":"custom"}"#); + let unknown_reason = serde_json::from_str::( + r#"{"type":"opaque","reason":"private_predicate"}"#, + ); + + assert!(unknown_type.is_err()); + assert!(unknown_reason.is_err()); + } + + #[test] + fn resolution_path_nodes_validate_public_coordinates() { + let invalid_package = serde_json::from_str::( + r#"{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2"}"#, + ); + let invalid_extension = serde_json::from_str::( + r#"{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"private/skill"}"#, + ); + + assert!(invalid_package.is_err()); + assert!(invalid_extension.is_err()); + } +} diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs new file mode 100644 index 00000000..409dac15 --- /dev/null +++ b/src/telemetry/schema/resolution/package.rs @@ -0,0 +1,589 @@ +//! Public package coordinates used by resolution telemetry. + +use std::{fmt, str::FromStr}; + +use semver::Version; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +use super::super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, + name::{InitialByteRule, validated_string_newtype}, +}; +use crate::telemetry::identity::{ + DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, +}; +use crate::telemetry::state::BoundRecordingObservation; + +const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; + +/// 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, +} + +impl PackageEcosystem { + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Cargo => "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, +} + +validated_string_newtype! { + /// Public package name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicPackageName { + error = PublicPackageNameError; + maximum_bytes = MAX_PUBLIC_PACKAGE_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphabetic; + invalid_initial = NonAlphabeticFirstCharacter; + noun = "public package name"; + as_str_doc = "Return the validated package name."; + } +} + +/// Exact semantic version attached to a public package coordinate. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::telemetry) struct ExactPackageVersion(Version); + +impl ExactPackageVersion { + /// Return the validated semantic version. + #[must_use] + pub(in crate::telemetry) fn as_version(&self) -> &Version { + &self.0 + } +} + +impl From for ExactPackageVersion { + fn from(version: Version) -> Self { + Self(version) + } +} + +impl FromStr for ExactPackageVersion { + type Err = InvalidExactPackageVersion; + + fn from_str(value: &str) -> Result { + Version::parse(value) + .map(Self) + .map_err(|_| InvalidExactPackageVersion) + } +} + +impl TryFrom for ExactPackageVersion { + type Error = InvalidExactPackageVersion; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl fmt::Display for ExactPackageVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl Serialize for ExactPackageVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ExactPackageVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .try_into() + .map_err(D::Error::custom) + } +} + +/// Error returned when a package version is not an exact semantic version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct InvalidExactPackageVersion; + +impl fmt::Display for InvalidExactPackageVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("package version must be an exact semantic version") + } +} + +impl std::error::Error for InvalidExactPackageVersion {} + +/// Public package coordinate safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicPackageCoordinate { + ecosystem: PackageEcosystem, + name: PublicPackageName, + version: ExactPackageVersion, +} + +impl PublicPackageCoordinate { + /// Combine validated components into one public coordinate. + /// + /// The name and version must come from the package manager's resolved + /// package identity. In particular, `name` must not be a dependency alias + /// or the spelling from an unresolved request. + #[must_use] + pub(in crate::telemetry) fn new( + ecosystem: PackageEcosystem, + name: PublicPackageName, + version: ExactPackageVersion, + ) -> Self { + Self { + ecosystem, + name, + version, + } + } + + /// Validate raw resolved components and combine them into one coordinate. + /// + /// # Errors + /// + /// Returns an error when the package name is outside the version 1 grammar + /// or the version is not an exact semantic version. + pub(in crate::telemetry) fn try_new( + ecosystem: PackageEcosystem, + name: &str, + version: &str, + ) -> Result { + Ok(Self::new(ecosystem, name.parse()?, version.parse()?)) + } + + /// Return the public ecosystem. + #[must_use] + pub(in crate::telemetry) fn ecosystem(&self) -> PackageEcosystem { + self.ecosystem + } + + /// Return the validated package name. + #[must_use] + pub(in crate::telemetry) fn name(&self) -> &PublicPackageName { + &self.name + } + + /// Return the exact package version. + #[must_use] + pub(in crate::telemetry) fn version(&self) -> &ExactPackageVersion { + &self.version + } + + /// Write package coordinate fields in version 1 identity order. + pub(super) fn write_identity_fields(&self, writer: &mut DimensionWriter<'_>) { + let version = self.version.to_string(); + writer.field(self.ecosystem.as_str().as_bytes()); + writer.field(self.name.as_str().as_bytes()); + writer.field(version.as_bytes()); + } +} + +impl IdentityDimension for PublicPackageCoordinate { + type Domain = PackageDomain; + + /// Write the version 1 `package_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + self.write_identity_fields(writer); + } +} + +/// Error returned when a public package coordinate has an invalid component. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum InvalidPublicPackageCoordinate { + Name(PublicPackageNameError), + Version(InvalidExactPackageVersion), +} + +impl From for InvalidPublicPackageCoordinate { + fn from(error: PublicPackageNameError) -> Self { + Self::Name(error) + } +} + +impl From for InvalidPublicPackageCoordinate { + fn from(error: InvalidExactPackageVersion) -> Self { + Self::Version(error) + } +} + +impl fmt::Display for InvalidPublicPackageCoordinate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Name(error) => write!(formatter, "invalid public package name: {error}"), + Self::Version(error) => write!(formatter, "invalid public package version: {error}"), + } + } +} + +impl std::error::Error for InvalidPublicPackageCoordinate { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Name(error) => Some(error), + Self::Version(error) => Some(error), + } + } +} + +/// Version 1 record of one eligible public package used during resolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PackageResolutionV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + package: PublicPackageCoordinate, + extension_match: ExtensionMatch, + package_subject: PackageSubject, +} + +impl PackageResolutionV1 { + /// Create a record for one eligible public resolution-input package. + #[must_use] + pub(in crate::telemetry) fn new( + observation: &BoundRecordingObservation<'_>, + package: PublicPackageCoordinate, + extension_match: ExtensionMatch, + ) -> Self { + let package_subject = observation.identifier_window_scope().derive(&package); + + Self { + version: SchemaVersion::V1, + kind: RowKind::PackageResolution, + event_id: EventId::new(), + day: observation.day(), + symposium: SymposiumVersion::current(), + package, + extension_match, + package_subject, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, + recording_observation, + }; + use super::*; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; + + fn package_name(value: &str) -> PublicPackageName { + value.parse().unwrap() + } + + fn package_version(value: &str) -> ExactPackageVersion { + value.parse().unwrap() + } + + fn package_resolution() -> PackageResolutionV1 { + package_resolution_for("example-runtime") + } + + fn package_resolution_for(package_name: &str) -> PackageResolutionV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + + PackageResolutionV1::new( + &observation, + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, package_name, "1.2.3") + .unwrap(), + ExtensionMatch::Public, + ) + } + + #[test] + fn package_ecosystems_round_trip_with_contract_names() { + let cases = [(PackageEcosystem::Cargo, "cargo")]; + + assert_contract_names_with_labels(&cases, PackageEcosystem::as_str); + } + + #[test] + fn extension_matches_round_trip_with_contract_names() { + let cases = [ + (ExtensionMatch::Public, "public"), + (ExtensionMatch::UnnamedOnly, "unnamed_only"), + (ExtensionMatch::None, "none"), + ]; + + assert_contract_names(&cases); + } + + #[test] + fn package_vocabulary_rejects_unknown_contract_names() { + let unknown = r#""future_value""#; + + let ecosystem = serde_json::from_str::(unknown); + let extension_match = serde_json::from_str::(unknown); + + assert!(ecosystem.is_err()); + assert!(extension_match.is_err()); + } + + #[test] + fn public_package_names_accept_the_contract_grammar() { + let cases = ["a", "A1", "example-runtime", "example_runtime"]; + + for value in cases { + let name = value.parse::().unwrap(); + let json = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(name.as_str(), value); + assert_eq!(json, format!(r#""{value}""#)); + assert_eq!(decoded, name); + } + + let maximum_length = format!("a{}", "0".repeat(63)); + assert_eq!( + maximum_length + .parse::() + .unwrap() + .as_str(), + maximum_length + ); + } + + #[test] + fn public_package_names_reject_invalid_length() { + let too_long = format!("a{}", "0".repeat(64)); + + let empty = "".parse::(); + let oversized = too_long.parse::(); + + assert_eq!(empty, Err(PublicPackageNameError::Empty)); + assert_eq!(oversized, Err(PublicPackageNameError::TooLong)); + } + + #[test] + fn public_package_names_require_an_ascii_letter_first() { + let cases = ["1crate", "-crate", "_crate", "écrate"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(PublicPackageNameError::NonAlphabeticFirstCharacter) + ); + } + } + + #[test] + fn public_package_names_reject_unsupported_characters() { + let cases = ["crate.name", "crate/name", "crate name", "craté"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(PublicPackageNameError::UnsupportedCharacter) + ); + } + } + + #[test] + fn exact_package_versions_round_trip_without_losing_semver_parts() { + let cases = ["1.2.3", "1.2.3-alpha.1+build.5"]; + + for value in cases { + let version = value.parse::().unwrap(); + let json = serde_json::to_string(&version).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(version.to_string(), value); + assert_eq!(json, format!(r#""{value}""#)); + assert_eq!(decoded, version); + } + } + + #[test] + fn exact_package_versions_reject_missing_ranges_and_wildcards() { + let cases = ["", "*", "^1.2.3", "1.2", "01.2.3", "1.2.3.4"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(InvalidExactPackageVersion) + ); + } + } + + #[test] + fn public_package_coordinate_validates_raw_components() { + let coordinate = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "1.2.3") + .unwrap(); + let invalid_name = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "private/package", "1.2.3"); + let invalid_version = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "*"); + + assert_eq!(coordinate.name().as_str(), "example-runtime"); + assert_eq!(coordinate.version().to_string(), "1.2.3"); + assert_eq!( + invalid_name, + Err(InvalidPublicPackageCoordinate::Name( + PublicPackageNameError::UnsupportedCharacter + )) + ); + assert_eq!( + invalid_version, + Err(InvalidPublicPackageCoordinate::Version( + InvalidExactPackageVersion + )) + ); + } + + #[test] + fn package_subject_dimension_uses_contract_field_order() { + let coordinate = PublicPackageCoordinate::try_new( + PackageEcosystem::Cargo, + "example-runtime", + "1.2.3-alpha.1+build.5", + ) + .unwrap(); + let ecosystem_length = 5_u64.to_be_bytes(); + let name_length = 15_u64.to_be_bytes(); + let version_length = 21_u64.to_be_bytes(); + let expected = [ + ecosystem_length.as_slice(), + b"cargo".as_slice(), + name_length.as_slice(), + b"example-runtime".as_slice(), + version_length.as_slice(), + b"1.2.3-alpha.1+build.5".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&coordinate); + + assert_eq!(encoded, expected); + } + + #[test] + fn public_package_coordinate_round_trips_in_contract_order() { + let coordinate = PublicPackageCoordinate::new( + PackageEcosystem::Cargo, + package_name("Example-runtime"), + package_version("1.2.3-alpha.1+build.5"), + ); + + let json = serde_json::to_string(&coordinate).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + r#"{"ecosystem":"cargo","name":"Example-runtime","version":"1.2.3-alpha.1+build.5"}"# + ); + assert_eq!(decoded, coordinate); + assert_eq!(coordinate.ecosystem(), PackageEcosystem::Cargo); + assert_eq!(coordinate.name().as_str(), "Example-runtime"); + assert_eq!( + coordinate.version().as_version(), + &Version::parse("1.2.3-alpha.1+build.5").unwrap() + ); + } + + #[test] + fn public_package_coordinate_rejects_unknown_fields() { + let json = r#"{"ecosystem":"cargo","name":"example-runtime","version":"1.2.3","source":"registry"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_package_coordinate_validates_nested_name_and_version() { + let invalid_name = r#"{"ecosystem":"cargo","name":"private/package","version":"1.2.3"}"#; + let invalid_version = r#"{"ecosystem":"cargo","name":"example-runtime","version":"*"}"#; + + let name_result = serde_json::from_str::(invalid_name); + let version_result = serde_json::from_str::(invalid_version); + + assert!(name_result.is_err()); + assert!(version_result.is_err()); + } + + #[test] + fn new_package_resolution_derives_subject_from_its_coordinate() { + let expected_day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, ecosystem, published name, and exact version. The + // complete digest is a7907f7a5ae0de9ae55469f276ba73b2fe68e9b97c4bdd1867dd0685acd297ee. + let expected_subject = "pkg_a7907f7a5ae0de9ae55469f276ba73b2".parse().unwrap(); + + let row = package_resolution(); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::PackageResolution); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, expected_day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.package.ecosystem(), PackageEcosystem::Cargo); + assert_eq!(row.package.name().as_str(), "example-runtime"); + assert_eq!(row.package.version().to_string(), "1.2.3"); + assert_eq!(row.extension_match, ExtensionMatch::Public); + assert_eq!(row.package_subject, expected_subject); + } + + #[test] + fn package_subject_changes_with_the_source_coordinate() { + let first = package_resolution_for("example-runtime"); + let second = package_resolution_for("example-tools"); + + assert_ne!(first.package_subject, second.package_subject); + } + + #[test] + fn package_resolution_rejects_future_version() { + let json = serde_json::to_string(&package_resolution()).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 package_resolution_rejects_unknown_fields() { + let json = serde_json::to_string(&package_resolution()).unwrap(); + let unknown = json.replacen(r#""package""#, r#""future_field":true,"package""#, 1); + + let result = serde_json::from_str::(&unknown); + + assert!(result.is_err()); + } + + #[test] + fn package_resolution_requires_every_field() { + let json = serde_json::to_string(&package_resolution()).unwrap(); + let missing = json.replacen(r#","extension_match":"public""#, "", 1); + + let result = serde_json::from_str::(&missing); + + assert!(result.is_err()); + } +} diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs new file mode 100644 index 00000000..3c464914 --- /dev/null +++ b/src/telemetry/state/lifecycle.rs @@ -0,0 +1,1091 @@ +//! Identity-window and return-cohort transitions in private telemetry state. + +use std::fmt; + +use super::{IdentityState, TelemetryStateV1}; +use crate::telemetry::{ + identity::{IdentifierWindowScope, IdentityKey, ReturnCohortScope}, + schema::{CohortDay, UtcDay, UtcSecond}, +}; + +/// 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 one recording operation at its captured completion timestamp. + /// + /// This selects and, when needed, advances the identifier window without + /// changing the return cohort. One operation should reuse the returned + /// observation for every row it emits, so a batch cannot cross identity + /// windows partway through. + /// + /// 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 transition belong to one private-state replacement. + /// That replacement must complete before the observation is bound or its + /// selected anchor is used to derive an identifier. + /// + /// # Errors + /// + /// Returns an error if the timestamp's UTC day precedes the stored + /// identifier-window anchor. A conforming storage caller filters this case + /// through its durable day policy; the check protects against an incorrect + /// caller or inconsistent state. State does not change on failure. + pub(in crate::telemetry) fn observe_recording( + &mut self, + completed_at: UtcSecond, + ) -> Result { + let observation = self.select_recording(completed_at)?; + self.identity.identifier_window_anchor = observation.identifier_window.anchor(); + Ok(observation) + } + + /// Bind a completed recording transition to the unchanged private state. + /// + /// Storage calls this only after atomically persisting the state changed by + /// [`Self::observe_recording`]. The selected anchor is checked before its + /// identity scope is exposed. Storage must still bind immediately after + /// persistence while holding the same telemetry lock; the anchor does not + /// identify a private-state instance by itself. When private-state + /// persistence is implemented, its successful write token will become an + /// additional required binding input so this ordering is structural. + /// + /// # Errors + /// + /// Returns an error when the stored identifier-window anchor differs from + /// the anchor selected by `observation`. + pub(in crate::telemetry) fn bind_recording_observation( + &self, + observation: RecordingObservation, + ) -> Result, RecordingObservationBindingError> { + let selected_anchor = observation.identifier_window.anchor(); + let current_anchor = self.identity.identifier_window_anchor; + if selected_anchor != current_anchor { + return Err(RecordingObservationBindingError { + selected_anchor, + current_anchor, + }); + } + + Ok(BoundRecordingObservation { + completed_at: observation.completed_at, + identifier_window: observation.identifier_window, + identifier_window_scope: self.identifier_window_scope(), + }) + } + + /// Observe a session at its captured completion timestamp. + /// + /// 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 the timestamp's UTC 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(in crate::telemetry) fn observe_session( + &mut self, + completed_at: UtcSecond, + ) -> Result { + let recording = self.select_recording(completed_at)?; + let effective_day = recording.completed_at.day(); + let return_cohort = self.select_return_cohort(effective_day)?; + + self.identity.identifier_window_anchor = recording.identifier_window.anchor(); + self.identity.return_cohort_anchor = Some(return_cohort.anchor()); + + Ok(SessionObservation { + recording, + return_cohort, + }) + } + + /// Bind a completed session transition to the unchanged private state. + /// + /// Storage calls this only after atomically persisting the state changed by + /// [`Self::observe_session`]. Both selected anchors are checked before any + /// identity scope is exposed, so an observation whose anchors no longer + /// match current state is rejected. Storage must still bind immediately + /// after persistence while holding the same telemetry lock; anchors do not + /// identify a private-state instance by themselves. When private-state + /// persistence is implemented, its successful write token will become an + /// additional required binding input so this ordering is structural. + /// + /// # Errors + /// + /// Returns an error when either stored anchor differs from the anchor + /// selected by `observation`. + pub(in crate::telemetry) fn bind_session_observation( + &self, + observation: SessionObservation, + ) -> Result, SessionObservationBindingError> { + let recording = self.bind_recording_observation(observation.recording)?; + + let selected_return_cohort = observation.return_cohort.anchor(); + let current_return_cohort = self.identity.return_cohort_anchor; + if current_return_cohort != Some(selected_return_cohort) { + return Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor: selected_return_cohort, + current_anchor: current_return_cohort, + }); + } + + let return_cohort_scope = self + .return_cohort_scope() + .expect("BUG: the checked return-cohort anchor must be present"); + + Ok(BoundSessionObservation { + recording, + return_cohort: observation.return_cohort, + return_cohort_scope, + }) + } + + /// Select a recording operation without mutating private state. + fn select_recording( + &self, + completed_at: UtcSecond, + ) -> Result { + let identifier_window = self.select_identifier_window(completed_at.day())?; + Ok(RecordingObservation { + completed_at, + identifier_window, + }) + } + + /// 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(RecordingObservationError { + 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-window selection for one recording operation. +#[must_use = "recording identity state must be persisted before identifiers are emitted"] +#[derive(Debug, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservation { + completed_at: UtcSecond, + pub(super) identifier_window: IdentifierWindowUpdate, +} + +/// A persisted recording transition bound to its identifier-window scope. +#[must_use = "a bound recording observation supplies timestamp and identity context"] +pub(in crate::telemetry) struct BoundRecordingObservation<'a> { + completed_at: UtcSecond, + identifier_window: IdentifierWindowUpdate, + identifier_window_scope: IdentifierWindowScope<'a>, +} + +impl BoundRecordingObservation<'_> { + /// Return when Symposium completed the observed recording operation. + #[must_use] + pub(in crate::telemetry) fn completed_at(&self) -> UtcSecond { + self.completed_at + } + + /// Return the UTC day derived from the operation's completion timestamp. + #[must_use] + pub(in crate::telemetry) fn day(&self) -> UtcDay { + self.completed_at.day() + } + + /// Return identity material bound to the selected identifier window. + #[must_use] + pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { + &self.identifier_window_scope + } +} + +/// A recording observation whose selected window no longer matches state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservationBindingError { + selected_anchor: UtcDay, + current_anchor: UtcDay, +} + +impl fmt::Display for RecordingObservationBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "recording selected identifier-window anchor {}, but current state uses {}", + self.selected_anchor, self.current_anchor + ) + } +} + +impl std::error::Error for RecordingObservationBindingError {} + +/// A recording operation earlier than its stored identifier-window anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservationError { + observed_day: UtcDay, + window_anchor: UtcDay, +} + +impl fmt::Display for RecordingObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "observed day {} precedes the identifier-window anchor {}", + self.observed_day, self.window_anchor + ) + } +} + +impl std::error::Error for RecordingObservationError {} + +/// Identity and return-cohort selections for one observed session. +#[must_use = "session identity state must be persisted before identifiers are emitted"] +#[derive(Debug, PartialEq, Eq)] +pub(in crate::telemetry) struct SessionObservation { + recording: RecordingObservation, + pub(super) return_cohort: ReturnCohortUpdate, +} + +/// A persisted session transition bound to both of its identity scopes. +#[must_use = "a bound session observation supplies the session-start identity fields"] +pub(in crate::telemetry) struct BoundSessionObservation<'a> { + recording: BoundRecordingObservation<'a>, + return_cohort: ReturnCohortUpdate, + return_cohort_scope: ReturnCohortScope<'a>, +} + +impl BoundSessionObservation<'_> { + /// Return when Symposium completed the observed session-start handling. + #[must_use] + pub(in crate::telemetry) fn completed_at(&self) -> UtcSecond { + self.recording.completed_at() + } + + /// Return the recording context shared by every row from this session-start + /// operation. + pub(in crate::telemetry) fn recording(&self) -> &BoundRecordingObservation<'_> { + &self.recording + } + + /// Return identity material bound to the selected identifier window. + #[must_use] + pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { + self.recording.identifier_window_scope() + } + + /// Return identity material bound to the selected return cohort. + #[must_use] + pub(in crate::telemetry) fn return_cohort_scope(&self) -> &ReturnCohortScope<'_> { + &self.return_cohort_scope + } + + /// Return the observed day within the selected return cohort. + #[must_use] + pub(in crate::telemetry) fn cohort_day(&self) -> CohortDay { + self.return_cohort.day() + } +} + +/// A session transition that no longer matches the current private state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum SessionObservationBindingError { + IdentifierWindowChanged(RecordingObservationBindingError), + ReturnCohortChanged { + selected_anchor: UtcDay, + current_anchor: Option, + }, +} + +impl fmt::Display for SessionObservationBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdentifierWindowChanged(error) => fmt::Display::fmt(error, formatter), + Self::ReturnCohortChanged { + selected_anchor, + current_anchor: Some(current_anchor), + } => write!( + formatter, + "session selected return-cohort anchor {selected_anchor}, but current state uses {current_anchor}" + ), + Self::ReturnCohortChanged { + selected_anchor, + current_anchor: None, + } => write!( + formatter, + "session selected return-cohort anchor {selected_anchor}, but current state has no return cohort" + ), + } + } +} + +impl std::error::Error for SessionObservationBindingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IdentifierWindowChanged(error) => Some(error), + Self::ReturnCohortChanged { .. } => None, + } + } +} + +impl From for SessionObservationBindingError { + fn from(error: RecordingObservationBindingError) -> Self { + Self::IdentifierWindowChanged(error) + } +} + +/// 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(in crate::telemetry) enum SessionObservationError { + BeforeIdentifierWindow(RecordingObservationError), + BeforeReturnCohort { + observed_day: UtcDay, + cohort_anchor: UtcDay, + }, +} + +impl From for SessionObservationError { + fn from(error: RecordingObservationError) -> Self { + Self::BeforeIdentifierWindow(error) + } +} + +impl fmt::Display for SessionObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BeforeIdentifierWindow(error) => fmt::Display::fmt(error, formatter), + 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 { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BeforeIdentifierWindow(error) => Some(error), + Self::BeforeReturnCohort { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use chrono::{NaiveDate, TimeZone, Utc}; + + use super::*; + use crate::telemetry::identity::{ + DimensionWriter, IdentityDimension, RetentionDimension, SessionDomain, + }; + use crate::telemetry::schema::UtcSecond; + + 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; + + struct TestWindowDimension; + + impl IdentityDimension for TestWindowDimension { + type Domain = SessionDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(b"test-session"); + } + } + + fn day(year: i32, month: u32, day: u32) -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) + } + + fn completion_time(year: i32, month: u32, day: u32) -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(year, month, day, 12, 0, 0).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 recording_on_day_thirty_advances_only_the_identifier_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 10, 10); + let observed_day = completed_at.day(); + + let observation = state.observe_recording(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + serialized, + state_with_anchors(KEY, "2026-10-10", "2026-08-11") + ); + } + + #[test] + fn recording_without_a_return_cohort_does_not_start_one() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 10, 10); + + let observation = state.observe_recording(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert!(state.identity.return_cohort_anchor.is_none()); + assert_eq!( + serialized, + state_without_return_cohort_at(KEY, "2026-10-10") + ); + } + + #[test] + fn recording_observation_binds_its_timestamp_day_and_window_scope() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let old_subject = state.identifier_window_scope().derive(&TestWindowDimension); + let completed_at = completion_time(2026, 10, 10); + + let observation = state.observe_recording(completed_at).unwrap(); + let observation = state.bind_recording_observation(observation).unwrap(); + let new_subject = observation + .identifier_window_scope() + .derive(&TestWindowDimension); + + assert_ne!(new_subject, old_subject); + assert_eq!(observation.completed_at(), completed_at); + assert_eq!(observation.day(), completed_at.day()); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + } + + #[test] + fn recording_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 result = state.observe_recording(completion_time(2026, 9, 9)); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + result, + Err(RecordingObservationError { + observed_day: day(2026, 9, 9), + window_anchor: day(2026, 9, 10), + }) + ); + assert_eq!(serialized, source); + } + + #[test] + fn recording_binding_rejects_an_observation_from_an_older_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state + .observe_recording(completion_time(2026, 9, 11)) + .unwrap(); + let _current_observation = state + .observe_recording(completion_time(2026, 10, 25)) + .unwrap(); + + let result = state.bind_recording_observation(older_observation); + + assert_eq!( + result.err(), + Some(RecordingObservationBindingError { + selected_anchor: day(2026, 9, 10), + current_anchor: day(2026, 10, 25), + }) + ); + } + + #[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 completed_at in [completion_time(2026, 9, 10), completion_time(2026, 10, 9)] { + let observation = state.observe_session(completed_at).unwrap(); + + assert_eq!( + observation.recording.identifier_window, + IdentifierWindowUpdate::Current { anchor } + ); + assert_eq!(observation.recording.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 completed_at = completion_time(2026, 10, 10); + let observed_day = completed_at.day(); + + let observation = state.observe_session(completed_at).unwrap(); + + assert_eq!( + observation.recording.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 completed_at = completion_time(2026, 10, 25); + let observed_day = completed_at.day(); + + let observation = state.observe_session(completed_at).unwrap(); + + assert_eq!( + observation.recording.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 observed_session_binds_both_scopes_to_the_selected_anchors() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let old_session = state.identifier_window_scope().derive(&TestWindowDimension); + let old_retention = state + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 10, 25, 16, 30, 12).unwrap()); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + let new_session = observation + .identifier_window_scope() + .derive(&TestWindowDimension); + let new_retention = observation + .return_cohort_scope() + .derive(&RetentionDimension); + + assert_ne!(new_session, old_session); + assert_ne!(new_retention, old_retention); + assert_eq!(observation.completed_at(), completed_at); + assert_eq!(observation.cohort_day(), CohortDay::D0); + assert!(matches!( + observation.recording.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert!(matches!( + observation.return_cohort, + ReturnCohortUpdate::Started { .. } + )); + } + + #[test] + fn bound_session_exposes_its_recording_context() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 9, 11); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + let recording = observation.recording(); + + assert_eq!(recording.completed_at(), completed_at); + assert_eq!(recording.day(), completed_at.day()); + assert_eq!( + recording + .identifier_window_scope() + .derive(&TestWindowDimension), + observation + .identifier_window_scope() + .derive(&TestWindowDimension) + ); + } + + #[test] + fn binding_rejects_an_observation_from_an_older_identifier_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state.observe_session(completion_time(2026, 9, 11)).unwrap(); + let current_day = day(2026, 10, 25); + let _current_observation = state + .observe_session(completion_time(2026, 10, 25)) + .unwrap(); + + let result = state.bind_session_observation(older_observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::IdentifierWindowChanged( + RecordingObservationBindingError { + selected_anchor, + current_anchor, + } + )) if selected_anchor == day(2026, 9, 10) && current_anchor == current_day + )); + } + + #[test] + fn binding_rejects_an_observation_from_an_older_return_cohort() { + let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state.observe_session(completion_time(2026, 9, 10)).unwrap(); + let current_cohort = day(2026, 9, 11); + let _current_observation = state.observe_session(completion_time(2026, 9, 11)).unwrap(); + + let result = state.bind_session_observation(older_observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor, + current_anchor: Some(current_anchor), + }) if selected_anchor == day(2026, 8, 11) && current_anchor == current_cohort + )); + } + + #[test] + fn binding_rejects_an_observation_after_identifier_reset() { + 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(completion_time(2026, 9, 11)).unwrap(); + state + .reset_identifiers_with::(day(2026, 9, 10), |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Ok(()) + }) + .unwrap(); + + let result = state.bind_session_observation(observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor, + current_anchor: None, + }) if selected_anchor == day(2026, 9, 10) + )); + } + + #[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(completion_time(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.recording.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(completion_time(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 completed_at = completion_time(2026, 9, 10); + let observed_day = completed_at.day(); + + let observation = state.observe_session(completed_at).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 completed_at = completion_time(2026, 10, 25); + let observed_day = completed_at.day(); + + let observation = state.observe_session(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); + assert_eq!( + observation.recording.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, completed_at, expected_day) in [ + ("2026-08-11", completion_time(2026, 8, 11), 0_i64), + ("2026-08-11", completion_time(2026, 8, 12), 1), + ("2026-09-01", completion_time(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(completed_at).unwrap(); + + assert_eq!( + observation.return_cohort.day(), + CohortDay::try_from(expected_day).unwrap() + ); + assert!(matches!( + observation.recording.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 completed_at = completion_time(2026, 9, 11); + + let observation = state.observe_session(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); + assert_eq!( + observation.recording.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 completed_at = completion_time(2026, 9, 11); + let observed_day = completed_at.day(); + + let observation = state.observe_session(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); + assert_eq!( + observation.recording.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(completion_time(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..cce6d0d0 --- /dev/null +++ b/src/telemetry/state/mod.rs @@ -0,0 +1,328 @@ +//! 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::{IdentifierWindowScope, IdentityKey, ReturnCohortScope, state_key_hex}, + schema::UtcDay, +}; + +mod lifecycle; + +pub(in crate::telemetry) use lifecycle::{BoundRecordingObservation, BoundSessionObservation}; + +/// 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)] +pub(super) 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, + }, + } + } + + /// Bind the stored key to the active identifier-window anchor. + /// + /// Call this only after applying any lifecycle transition for the current + /// operation, so every derived identifier uses the state that will be + /// persisted before its row is appended. + #[must_use] + fn identifier_window_scope(&self) -> IdentifierWindowScope<'_> { + IdentifierWindowScope::new( + &self.identity.key, + self.identity.identifier_window_anchor.to_string(), + ) + } + + /// Bind the stored key to the active return-cohort anchor, when present. + /// + /// A newly enabled or reset recorder has no return cohort until its first + /// session observation. Call this after applying that observation so a D31 + /// rollover uses the newly selected anchor. + #[must_use] + fn return_cohort_scope(&self) -> Option> { + let anchor = self.identity.return_cohort_anchor?; + Some(ReturnCohortScope::new( + &self.identity.key, + anchor.to_string(), + )) + } +} + +/// 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::{identity::RetentionDimension, 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!(state.return_cohort_scope().is_none()); + assert_eq!(serialized, source); + } + + #[test] + fn return_cohort_scope_uses_the_return_cohort_anchor() { + let first: TelemetryStateV1 = + toml::from_str(&state_with_anchors(KEY, "2026-09-10", "2026-08-11")).unwrap(); + let second: TelemetryStateV1 = + toml::from_str(&state_with_anchors(KEY, "2026-09-10", "2026-08-12")).unwrap(); + + let first_subject = first + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + let second_subject = second + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + + assert_ne!(first_subject, second_subject); + } + + #[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}" + ); + } +}