From 4762d8a74a4e93dd4c55056d18ec9203de4d854d Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Wed, 2 Sep 2026 11:13:47 +0300 Subject: [PATCH 01/30] docs: prepare telemetry for upload Co-authored-by: Codex --- md/rfds/telemetry-recording/README.md | 27 ++++++++++++++++--- .../contract/recorded-data.md | 6 +++-- .../reference/configuration.md | 2 +- .../reference/telemetry-command.md | 6 ++--- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 6d596dfb..12ddc1f2 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -301,9 +301,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,7 +319,13 @@ 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 @@ -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. @@ -359,6 +368,7 @@ This design accepts the following costs and limits: - 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 +433,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 +501,17 @@ 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 permanently closes earlier files; clock rollback cannot reopen them, 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..d33b4f40 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -368,6 +368,8 @@ 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. +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 +380,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 and return-cohort anchors, 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 and starts a new retention cohort without moving the high-water mark backward. `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..47b3115d 100644 --- a/md/rfds/telemetry-recording/reference/configuration.md +++ b/md/rfds/telemetry-recording/reference/configuration.md @@ -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 and cohort anchors, 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..79db711e 100644 --- a/md/rfds/telemetry-recording/reference/telemetry-command.md +++ b/md/rfds/telemetry-recording/reference/telemetry-command.md @@ -152,7 +152,7 @@ $ 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` @@ -180,9 +180,9 @@ 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 and return-cohort anchors, 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. +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. None of these operations moves the latest-opened-day high-water mark backward. 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. From e0c24fc11d2a1645be1062241a01de24d70ae375 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Wed, 2 Sep 2026 23:39:59 +0300 Subject: [PATCH 02/30] Move telemetry into its own module directory Make room for new telemetry pieces without changing behavior. --- src/{telemetry.rs => telemetry/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{telemetry.rs => telemetry/mod.rs} (100%) diff --git a/src/telemetry.rs b/src/telemetry/mod.rs similarity index 100% rename from src/telemetry.rs rename to src/telemetry/mod.rs From d6af4d6b7753cffb9915645f6984ba85447198a8 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 3 Sep 2026 00:29:44 +0300 Subject: [PATCH 03/30] Add telemetry row identifiers Use UUIDs so malformed identifiers are rejected when data is read. --- Cargo.lock | 6 +++-- Cargo.toml | 1 + src/telemetry/mod.rs | 2 ++ src/telemetry/schema.rs | 58 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/telemetry/schema.rs diff --git a/Cargo.lock b/Cargo.lock index af2134d1..c0a8268d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2459,6 +2459,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", ] [[package]] @@ -2997,12 +2998,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..716f0440 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ 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"] } [dev-dependencies] diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs index 8bfe1fc3..36c84182 100644 --- a/src/telemetry/mod.rs +++ b/src/telemetry/mod.rs @@ -11,6 +11,8 @@ //! 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 schema; + use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs new file mode 100644 index 00000000..4e4ac57c --- /dev/null +++ b/src/telemetry/schema.rs @@ -0,0 +1,58 @@ +//! 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.") +)] +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 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()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } +} From 1de63544952c2ce4ce45ca118e998d4a42824aa4 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 3 Sep 2026 01:44:07 +0300 Subject: [PATCH 04/30] Define telemetry dates and versions Validate stored dates and versions and keep timestamps to whole seconds. --- src/telemetry/schema.rs | 242 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 241 insertions(+), 1 deletion(-) diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index 4e4ac57c..111d6d68 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -3,7 +3,9 @@ not(test), expect(dead_code, reason = "the new schema is built before storage uses it.") )] -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; +use semver::Version; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use uuid::Uuid; /// Random identifier for one telemetry row. @@ -18,6 +20,121 @@ impl EventId { } } +/// UTC calendar day used to partition telemetry rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct UtcDay(NaiveDate); + +impl Serialize for UtcDay { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&self.0.format("%Y-%m-%d")) + } +} + +impl<'de> Deserialize<'de> for UtcDay { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let date = NaiveDate::parse_from_str(&value, "%Y-%m-%d").map_err(D::Error::custom)?; + + if date.format("%Y-%m-%d").to_string() != value { + return Err(D::Error::custom("expected a UTC day in YYYY-MM-DD form")); + } + Ok(Self(date)) + } +} + +/// 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(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); + +impl SymposiumVersion { + /// Return the version of the running Symposium binary. + pub(super) fn current() -> Self { + Self( + Version::parse(env!("CARGO_PKG_VERSION")) + .expect("BUG: Cargo package version must be valid semantic versioning"), + ) + } +} + +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) + } +} + #[cfg(test)] mod tests { use super::*; @@ -55,4 +172,127 @@ mod tests { 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_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_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()); + } } From 19dc861fe71240497137f7ec1ebf5c6b1b77a0fd Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 3 Sep 2026 02:30:48 +0300 Subject: [PATCH 05/30] Define the storage limit telemetry row Add shared row metadata and reject schema drift in version 1. --- src/telemetry/schema.rs | 242 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index 111d6d68..7529ab4e 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -3,6 +3,8 @@ not(test), expect(dead_code, reason = "the new schema is built before storage uses it.") )] +use std::num::NonZeroU64; + use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; use semver::Version; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; @@ -20,6 +22,61 @@ impl EventId { } } +/// 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, + Command, + StorageLimit, + ExtensionInvocationMetrics, +} + +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) +} + +fn deserialize_storage_limit_kind<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let kind = RowKind::deserialize(deserializer)?; + + if kind != RowKind::StorageLimit { + return Err(D::Error::custom("expected storage_limit row kind")); + } + + Ok(kind) +} + /// UTC calendar day used to partition telemetry rows. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(super) struct UtcDay(NaiveDate); @@ -135,10 +192,60 @@ impl<'de> Deserialize<'de> for SymposiumVersion { } } +/// 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, + #[serde(deserialize_with = "deserialize_storage_limit_kind")] + 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. + 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, +} + #[cfg(test)] mod tests { use super::*; + const STORAGE_LIMIT_EXAMPLE: &str = r#"{ + "v": 1, + "kind": "storage_limit", + "event_id": "8430f7f3-7ec5-4ca5-9c65-8f6e83eaa3de", + "day": "2026-08-03", + "symposium": "0.4.0", + "dropped_operation": "manual_sync" + }"#; + #[test] fn new_event_id_is_uuid_v4() { let event_id = EventId::new(); @@ -173,6 +280,64 @@ mod tests { 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::Command, "command"), + (RowKind::StorageLimit, "storage_limit"), + ( + RowKind::ExtensionInvocationMetrics, + "extension_invocation_metrics", + ), + ]; + + for (kind, name) in cases { + let json = serde_json::to_string(&kind).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, kind); + } + } + + #[test] + fn row_kind_rejects_unknown_name() { + let result = serde_json::from_str::(r#""future_kind""#); + + assert!(result.is_err()); + } + #[test] fn utc_day_serializes_as_calendar_date() { let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); @@ -295,4 +460,81 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn storage_limit_example_round_trips() { + let row = serde_json::from_str::(STORAGE_LIMIT_EXAMPLE).unwrap(); + + let actual = serde_json::to_value(row).unwrap(); + let expected = serde_json::from_str::(STORAGE_LIMIT_EXAMPLE).unwrap(); + + assert_eq!(actual, expected); + } + + #[test] + fn new_storage_limit_uses_fixed_common_fields() { + let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + let row = StorageLimitV1::new(day, DroppedOperation::ManualSync); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::StorageLimit); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.dropped_operation, DroppedOperation::ManualSync); + } + + #[test] + fn dropped_operations_round_trip_with_contract_names() { + let cases = [ + (DroppedOperation::SessionStart, "session_start"), + (DroppedOperation::ManualSync, "manual_sync"), + (DroppedOperation::Use, "use"), + (DroppedOperation::Remove, "remove"), + (DroppedOperation::Init, "init"), + (DroppedOperation::Configuration, "configuration"), + (DroppedOperation::Command, "command"), + ]; + + for (operation, name) in cases { + let json = serde_json::to_string(&operation).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, operation); + } + } + + #[test] + fn storage_limit_rejects_wrong_version() { + let json = STORAGE_LIMIT_EXAMPLE.replacen(r#""v": 1"#, r#""v": 2"#, 1); + + let result = serde_json::from_str::(&json); + + assert!(result.is_err()); + } + + #[test] + fn storage_limit_rejects_wrong_kind() { + let json = + STORAGE_LIMIT_EXAMPLE.replacen(r#""kind": "storage_limit""#, r#""kind": "command""#, 1); + + let result = serde_json::from_str::(&json); + + assert!(result.is_err()); + } + + #[test] + fn storage_limit_rejects_unknown_field() { + let json = STORAGE_LIMIT_EXAMPLE.replacen( + r#""dropped_operation""#, + r#""at": "2026-08-03T10:02:11Z", "dropped_operation""#, + 1, + ); + + let result = serde_json::from_str::(&json); + + assert!(result.is_err()); + } } From 0e3f15c30014320728d55e00e1463bc499bcc288 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 3 Sep 2026 03:37:19 +0300 Subject: [PATCH 06/30] Distinguish unknown telemetry schemas Classify recognized but invalid rows separately from malformed input. --- .../reference/telemetry-command.md | 5 +- src/telemetry/schema.rs | 200 +++++++++++++----- 2 files changed, 155 insertions(+), 50 deletions(-) diff --git a/md/rfds/telemetry-recording/reference/telemetry-command.md b/md/rfds/telemetry-recording/reference/telemetry-command.md index 79db711e..645f2d4a 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` diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index 7529ab4e..6fbd46a7 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -3,7 +3,7 @@ not(test), expect(dead_code, reason = "the new schema is built before storage uses it.") )] -use std::num::NonZeroU64; +use std::{num::NonZeroU64, sync::LazyLock}; use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; use semver::Version; @@ -43,9 +43,43 @@ pub(super) enum RowKind { ExtensionResolution, HookMetrics, PluginHookMetrics, + ExtensionInvocationMetrics, Command, StorageLimit, - ExtensionInvocationMetrics, +} + +/// 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 { + StorageLimit(StorageLimitV1), +} + +impl Serialize for TelemetryRow { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + 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 @@ -64,19 +98,6 @@ where Ok(version) } -fn deserialize_storage_limit_kind<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let kind = RowKind::deserialize(deserializer)?; - - if kind != RowKind::StorageLimit { - return Err(D::Error::custom("expected storage_limit row kind")); - } - - Ok(kind) -} - /// UTC calendar day used to partition telemetry rows. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(super) struct UtcDay(NaiveDate); @@ -96,15 +117,27 @@ impl<'de> Deserialize<'de> for UtcDay { D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; - let date = NaiveDate::parse_from_str(&value, "%Y-%m-%d").map_err(D::Error::custom)?; - if date.format("%Y-%m-%d").to_string() != value { + 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) +} + /// RFC 3339 UTC timestamp with no subsecond precision. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(super) struct UtcSecond(DateTime); @@ -163,13 +196,15 @@ impl<'de> Deserialize<'de> for UtcSecond { #[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( - Version::parse(env!("CARGO_PKG_VERSION")) - .expect("BUG: Cargo package version must be valid semantic versioning"), - ) + Self(CURRENT_SYMPOSIUM_VERSION.clone()) } } @@ -192,13 +227,15 @@ impl<'de> Deserialize<'de> for SymposiumVersion { } } +// 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, - #[serde(deserialize_with = "deserialize_storage_limit_kind")] kind: RowKind, event_id: EventId, day: UtcDay, @@ -208,6 +245,7 @@ pub(super) struct StorageLimitV1 { 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, @@ -233,18 +271,46 @@ pub(super) enum DroppedOperation { Command, } +/// Classify a physical JSONL line and return typed data only for a known schema. +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) { + ("storage_limit", 1) => match serde_json::from_str(line) { + Ok(row) => RowClassification::Supported(TelemetryRow::StorageLimit(row)), + Err(_) => RowClassification::Invalid, + }, + _ => RowClassification::UnknownSchema, + } +} + #[cfg(test)] mod tests { use super::*; - const STORAGE_LIMIT_EXAMPLE: &str = r#"{ - "v": 1, - "kind": "storage_limit", - "event_id": "8430f7f3-7ec5-4ca5-9c65-8f6e83eaa3de", - "day": "2026-08-03", - "symposium": "0.4.0", - "dropped_operation": "manual_sync" - }"#; + const RECORDED_DATA: &str = + include_str!("../../md/rfds/telemetry-recording/contract/recorded-data.md"); + + fn example_row(requested_kind: &str) -> &'static str { + let (_, after_fence) = RECORDED_DATA + .split_once("```jsonl") + .expect("recorded-data contract must contain a JSONL example block"); + let (example_block, _) = after_fence + .split_once("```") + .expect("recorded-data JSONL example block must have a closing fence"); + + example_block + .lines() + .filter_map(|line| { + serde_json::from_str::(line) + .ok() + .map(|envelope| (line, envelope)) + }) + .find_map(|(line, envelope)| (envelope.kind == requested_kind).then_some(line)) + .unwrap_or_else(|| panic!("missing {requested_kind} example in recorded-data contract")) + } #[test] fn new_event_id_is_uuid_v4() { @@ -314,12 +380,12 @@ mod tests { (RowKind::ExtensionResolution, "extension_resolution"), (RowKind::HookMetrics, "hook_metrics"), (RowKind::PluginHookMetrics, "plugin_hook_metrics"), - (RowKind::Command, "command"), - (RowKind::StorageLimit, "storage_limit"), ( RowKind::ExtensionInvocationMetrics, "extension_invocation_metrics", ), + (RowKind::Command, "command"), + (RowKind::StorageLimit, "storage_limit"), ]; for (kind, name) in cases { @@ -364,6 +430,18 @@ mod tests { 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_second_constructor_removes_subsecond_precision() { let timestamp = DateTime::parse_from_rfc3339("2026-08-03T09:14:02.987Z") @@ -463,10 +541,14 @@ mod tests { #[test] fn storage_limit_example_round_trips() { - let row = serde_json::from_str::(STORAGE_LIMIT_EXAMPLE).unwrap(); + let example = example_row("storage_limit"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("storage_limit contract example was not classified as supported"); + }; let actual = serde_json::to_value(row).unwrap(); - let expected = serde_json::from_str::(STORAGE_LIMIT_EXAMPLE).unwrap(); + let expected = serde_json::from_str::(example).unwrap(); assert_eq!(actual, expected); } @@ -507,34 +589,56 @@ mod tests { } #[test] - fn storage_limit_rejects_wrong_version() { - let json = STORAGE_LIMIT_EXAMPLE.replacen(r#""v": 1"#, r#""v": 2"#, 1); + fn unsupported_storage_limit_versions_are_unknown_schema() { + let example = example_row("storage_limit"); - let result = serde_json::from_str::(&json); + for version in [0, 2] { + let json = example.replacen(r#""v":1"#, &format!(r#""v":{version}"#), 1); - assert!(result.is_err()); + assert_eq!(classify_row(&json), RowClassification::UnknownSchema); + } } #[test] - fn storage_limit_rejects_wrong_kind() { - let json = - STORAGE_LIMIT_EXAMPLE.replacen(r#""kind": "storage_limit""#, r#""kind": "command""#, 1); + fn unknown_row_kind_is_unknown_schema() { + let json = r#"{"v":1,"kind":"future_kind","future_field":true}"#; - let result = serde_json::from_str::(&json); + let classification = classify_row(json); - assert!(result.is_err()); + assert_eq!(classification, RowClassification::UnknownSchema); } #[test] - fn storage_limit_rejects_unknown_field() { - let json = STORAGE_LIMIT_EXAMPLE.replacen( + 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""#, + r#""at":"2026-08-03T10:02:11Z","dropped_operation""#, 1, ); - let result = serde_json::from_str::(&json); + let classification = classify_row(&json); - assert!(result.is_err()); + 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}" + ); + } } } From ea709f2fd4d54338d01b0714315740aabf7f91e3 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Wed, 9 Sep 2026 21:33:30 +0300 Subject: [PATCH 07/30] Add typed telemetry identifiers --- src/telemetry/identity.rs | 105 ++++++++++++++++++++++++++++++++++++++ src/telemetry/mod.rs | 1 + 2 files changed, 106 insertions(+) create mode 100644 src/telemetry/identity.rs diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs new file mode 100644 index 00000000..90cb382c --- /dev/null +++ b/src/telemetry/identity.rs @@ -0,0 +1,105 @@ +//! 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::marker::PhantomData; + +const IDENTIFIER_BYTES: usize = 16; + +/// A 128-bit telemetry identifier belonging to domain `D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) struct ScopedId { + bytes: [u8; IDENTIFIER_BYTES], + domain: PhantomData, +} + +impl ScopedId { + const fn from_bytes(bytes: [u8; IDENTIFIER_BYTES]) -> Self { + Self { + bytes, + domain: PhantomData, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum SessionIdDomain {} + +pub(super) type SessionId = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum RetentionDomain {} + +pub(super) type RetentionSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum AgentDomain {} + +pub(super) type AgentSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum PackageDomain {} + +pub(super) type PackageSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum ExtensionDomain {} + +pub(super) type ExtensionSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum HookDomain {} + +pub(super) type HookSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum PluginDomain {} + +pub(super) type PluginSubject = ScopedId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum CommandDomain {} + +pub(super) type CommandSubject = ScopedId; + +#[cfg(test)] +mod tests { + use std::{any::TypeId, collections::HashSet, mem::size_of}; + + use super::*; + + enum TestDomain {} + + #[test] + fn domain_marker_adds_no_storage_to_identifier() { + let bytes = [0x5a; IDENTIFIER_BYTES]; + + let identifier = ScopedId::::from_bytes(bytes); + + assert_eq!(identifier.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()); + } +} diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs index 36c84182..9c7756e5 100644 --- a/src/telemetry/mod.rs +++ b/src/telemetry/mod.rs @@ -11,6 +11,7 @@ //! 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; use std::fs::{self, OpenOptions}; From dd0acae7af77a91e585645fc00fe4ac3c1fc137a Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 06:39:52 +0300 Subject: [PATCH 08/30] Define the telemetry identifier wire format Give each scoped telemetry identifier a fixed prefix and canonical 128-bit hexadecimal representation. Parse the same representation strictly and use it for JSON serialization. Keep identifier domains separate at the type level, and check the published examples so code and the telemetry contract cannot drift. --- src/telemetry/identity.rs | 405 +++++++++++++++++++++++++++++++++++--- 1 file changed, 378 insertions(+), 27 deletions(-) diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 90cb382c..eb1c387e 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -7,18 +7,34 @@ ) )] -use std::marker::PhantomData; +use std::{ + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, + str::FromStr, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; const IDENTIFIER_BYTES: usize = 16; +const ENCODED_DIGITS: usize = IDENTIFIER_BYTES * 2; /// A 128-bit telemetry identifier belonging to domain `D`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// +/// 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, @@ -27,45 +43,194 @@ impl ScopedId { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum SessionIdDomain {} +// Written out rather than derived: a derive puts the same bound on `D`, so an +// identifier would only gain each trait when its zero-sized marker declared it. +impl Clone for ScopedId { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for ScopedId {} + +impl PartialEq for ScopedId { + fn eq(&self, other: &Self) -> bool { + self.bytes == other.bytes + } +} + +impl Eq for ScopedId {} + +impl PartialOrd for ScopedId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ScopedId { + fn cmp(&self, other: &Self) -> Ordering { + self.bytes.cmp(&other.bytes) + } +} + +impl Hash for ScopedId { + fn hash(&self, state: &mut H) { + self.bytes.hash(state); + } +} + +/// Marker supplying a [`ScopedId`] domain's wire prefix. +/// +/// Private, which seals it: the prefix set is a published contract surface, not +/// an extension point. +trait ScopedIdDomain { + const PREFIX: &'static str; +} + +/// 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 {} -pub(super) type SessionId = ScopedId; +// 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)?; + for byte in self.bytes { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } +} -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum RetentionDomain {} +impl FromStr for ScopedId +where + D: ScopedIdDomain, +{ + type Err = ParseScopedIdError; -pub(super) type RetentionSubject = ScopedId; + fn from_str(value: &str) -> Result { + let encoded = value + .strip_prefix(D::PREFIX) + .ok_or(ParseScopedIdError::IncorrectPrefix)?; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum AgentDomain {} + if encoded.len() != ENCODED_DIGITS { + return Err(ParseScopedIdError::IncorrectLength); + } -pub(super) type AgentSubject = ScopedId; + // The length check leaves no remainder, so every digit reaches a pair. + let (digit_pairs, _) = encoded.as_bytes().as_chunks::<2>(); -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum PackageDomain {} + let mut bytes = [0; IDENTIFIER_BYTES]; + for (&[high, low], output) in digit_pairs.iter().zip(&mut bytes) { + let high = decode_lower_hex(high).ok_or(ParseScopedIdError::InvalidHex)?; + let low = decode_lower_hex(low).ok_or(ParseScopedIdError::InvalidHex)?; + *output = (high << 4) | low; + } -pub(super) type PackageSubject = ScopedId; + Ok(Self::from_bytes(bytes)) + } +} -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum ExtensionDomain {} +impl Serialize for ScopedId +where + D: ScopedIdDomain, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} -pub(super) type ExtensionSubject = ScopedId; +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) + } +} -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum HookDomain {} +fn decode_lower_hex(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} -pub(super) type HookSubject = ScopedId; +/// Declares each identifier domain, its contract prefix, and its alias. +/// +/// A macro because a function cannot introduce types, and the prefix is the +/// hand-written part worth handing to the tests as a table. +macro_rules! scoped_id_domains { + ($($domain:ident => $prefix:literal as $alias:ident,)+) => { + $( + pub(super) enum $domain {} -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum PluginDomain {} + impl ScopedIdDomain for $domain { + const PREFIX: &'static str = $prefix; + } -pub(super) type PluginSubject = ScopedId; + pub(super) type $alias = ScopedId<$domain>; + )+ -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(super) enum CommandDomain {} + #[cfg(test)] + const PREFIX_PARSERS: &[(&str, fn(&str) -> bool)] = &[ + $(($prefix, |value| value.parse::<$alias>().is_ok())),+ + ]; + }; +} -pub(super) type CommandSubject = ScopedId; +scoped_id_domains! { + SessionDomain => "sess_" as SessionId, + RetentionDomain => "ret_" as RetentionSubject, + AgentDomain => "agt_" as AgentSubject, + PackageDomain => "pkg_" as PackageSubject, + ExtensionDomain => "ext_" as ExtensionSubject, + HookDomain => "hok_" as HookSubject, + PluginDomain => "plg_" as PluginSubject, + CommandDomain => "cmd_" as CommandSubject, +} #[cfg(test)] mod tests { @@ -73,15 +238,43 @@ mod tests { use super::*; + const RECORDED_DATA: &str = + include_str!("../../md/rfds/telemetry-recording/contract/recorded-data.md"); + + const TEST_HEX: &str = "00112233445566778899aabbccddeeff"; + + const TEST_BYTES: [u8; IDENTIFIER_BYTES] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, + ]; + enum TestDomain {} + impl ScopedIdDomain for TestDomain { + const PREFIX: &'static str = "test_"; + } + + /// 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 domain_marker_adds_no_storage_to_identifier() { let bytes = [0x5a; IDENTIFIER_BYTES]; let identifier = ScopedId::::from_bytes(bytes); - assert_eq!(identifier.bytes, bytes); + assert_eq!(identifier, ScopedId::::from_bytes(bytes)); assert_eq!(size_of::>(), IDENTIFIER_BYTES); } @@ -102,4 +295,162 @@ mod tests { 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 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)); + } } From fa8513caf0f823b493cc16b345f393c1cfe08b98 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 08:20:26 +0300 Subject: [PATCH 09/30] Derive telemetry identifiers from framed inputs Separate identifier windows from domain-scoped dimensions so callers cannot exchange them or mix identifiers from different domains. Length-prefix each input field and pin the HMAC domain constants in the recording contract. This keeps component boundaries unambiguous and makes accidental identifier rotation visible in tests. --- Cargo.lock | 1 + Cargo.toml | 1 + md/rfds/telemetry-recording/README.md | 8 +- .../contract/recorded-data.md | 36 +++ src/telemetry/identity.rs | 292 +++++++++++++++++- 5 files changed, 320 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0a8268d..f83eb3f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,6 +2438,7 @@ dependencies = [ "dirs", "expect-test", "flate2", + "hmac", "home", "indoc", "regex", diff --git a/Cargo.toml b/Cargo.toml index 716f0440..ccc0bab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ 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" [dev-dependencies] diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 12ddc1f2..68dedbcd 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -175,13 +175,9 @@ 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. It represents one installation for one package, agent, or command dimension, never the installation globally. +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. 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. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index d33b4f40..a984a625 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -49,6 +49,42 @@ Normal 30-day rollover changes the window input without replacing the key. Renew This file is separate from the inspectable `/telemetry/` data directory and has owner-only permissions where the platform supports them. The key is private state, not anonymized telemetry. It is not written into events, printed by telemetry commands, or derived from your machine. Someone who has the key can recompute candidate identifiers. +The implementation prevents accidental formatting or serialization of the key. It does not promise to scrub every in-memory copy: the security boundary is the private state file and keeping the key out of telemetry and diagnostics. + +### Derivation format + +Version 1 uses the first 128 bits of HMAC-SHA-256. Each variable-length window or dimension value has an eight-byte unsigned big-endian byte length followed by its bytes: + +```text +frame(value) = u64_be(byte_length(value)) || value + +HMAC( + key, + "telemetry::v1\0" + || frame(window) + || frame(dimension field 1) + || frame(dimension field 2) + || ... +) +``` + +The window is the canonical byte form of the relevant anchor in `telemetry-state.toml`. Dimension fields use the exact UTF-8 bytes of their stable labels and validated strings, without case folding or Unicode normalization. Length framing keeps field boundaries unambiguous even when a value contains a NUL byte. Domains with no dimension fields end after the framed window. + +The domain strings, wire prefixes, and ordered dimension fields are frozen for consent version 1: + +| Identifier | HMAC domain | Wire prefix | Ordered dimension fields | +| --- | --- | --- | --- | +| `session_id` | `session_id` | `sess_` | Agent, vendor session id. | +| `retention_subject` | `retention_subject` | `ret_` | None; the return-cohort anchor is the window. | +| `agent_subject` | `agent_subject` | `agt_` | Agent. | +| `package_subject` | `package_subject` | `pkg_` | Package ecosystem, name, exact version. | +| `extension_subject` | `extension_subject` | `ext_` | Target type, source, name, then the complete safe resolution path. | +| `hook_subject` | `hook_subject` | `hok_` | Agent, hook surface. | +| `plugin_subject` | `plugin_subject` | `plg_` | Public source, plugin name. | +| `command_subject` | `command_subject` | `cmd_` | Command type, then its typed coordinate fields in event order. | + +Structured values such as an extension path use the same framing recursively. A sequence starts with its eight-byte unsigned big-endian item count. Each variant starts with its framed type label, followed by its fields in the order used by the corresponding event schema. Identity code owns this encoding; telemetry producers pass typed coordinates rather than concatenating strings. + ### What identifiers can link Symposium derives each identifier for one narrow purpose: diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index eb1c387e..526252bc 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -15,10 +15,68 @@ use std::{ 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. +struct IdentityKey([u8; IDENTITY_KEY_BYTES]); + +impl IdentityKey { + #[must_use] + const fn from_bytes(bytes: [u8; IDENTITY_KEY_BYTES]) -> Self { + Self(bytes) + } +} + +/// Canonical bytes for an identifier-window or return-cohort anchor. +/// +/// Keeping this distinct from a dimension makes their order in the HMAC input +/// impossible to swap accidentally. +struct IdentityWindow<'a>(&'a [u8]); + +#[cfg(test)] +impl<'a> IdentityWindow<'a> { + #[must_use] + const fn from_bytes(bytes: &'a [u8]) -> Self { + Self(bytes) + } +} + +/// Canonically framed dimension fields belonging to domain `D`. +/// +/// Domain-specific constructors will own field selection and order. Telemetry +/// producers never concatenate dimension strings themselves. +struct ScopedDimension { + encoded_fields: Vec, + domain: PhantomData, +} + +#[cfg(test)] +impl ScopedDimension { + #[must_use] + fn from_fields<'a>(fields: impl IntoIterator) -> Self { + let mut encoded_fields = Vec::new(); + for field in fields { + write_frame(field, |bytes| encoded_fields.extend_from_slice(bytes)); + } + + Self { + encoded_fields, + domain: PhantomData, + } + } +} /// A 128-bit telemetry identifier belonging to domain `D`. /// @@ -79,12 +137,53 @@ impl Hash for ScopedId { } } -/// Marker supplying a [`ScopedId`] domain's wire prefix. +/// Marker supplying a [`ScopedId`] domain's frozen derivation and wire labels. /// -/// Private, which seals it: the prefix set is a published contract surface, not -/// an extension point. +/// Private, which seals it: both constants are published contract surfaces, +/// not extension points. Changing either requires a new consent version. trait ScopedIdDomain { const PREFIX: &'static str; + const HMAC_DOMAIN: &'static str; +} + +/// Derives purpose-scoped telemetry identifiers from one secret key. +struct IdentityDeriver { + key: IdentityKey, +} + +impl IdentityDeriver { + #[must_use] + const fn new(key: IdentityKey) -> Self { + Self { key } + } + + /// Derive an identifier from a canonical window and domain-specific fields. + #[must_use] + fn derive(&self, window: &IdentityWindow<'_>, dimension: &ScopedDimension) -> ScopedId + where + D: ScopedIdDomain, + { + let mut hmac = HmacSha256::new_from_slice(&self.key.0) + .expect("BUG: HMAC-SHA-256 must accept a 32-byte key"); + hmac.update(b"telemetry:"); + hmac.update(D::HMAC_DOMAIN.as_bytes()); + hmac.update(b":v1\0"); + write_frame(window.0, |bytes| hmac.update(bytes)); + hmac.update(&dimension.encoded_fields); + + let digest = hmac.finalize().into_bytes(); + let mut bytes = [0; IDENTIFIER_BYTES]; + bytes.copy_from_slice(&digest[..IDENTIFIER_BYTES]); + ScopedId::from_bytes(bytes) + } +} + +/// Write one unambiguous variable-length value to an HMAC input or buffer. +fn write_frame(value: &[u8], mut write: impl FnMut(&[u8])) { + let length = u64::try_from(value.len()) + .expect("BUG: a slice length must fit the telemetry frame format"); + write(&length.to_be_bytes()); + write(value); } /// Reason a stored scoped identifier is not canonical. @@ -203,12 +302,20 @@ fn decode_lower_hex(value: u8) -> Option { /// 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 => $prefix:literal as $alias:ident,)+) => { + ( + $( + $domain:ident => $alias:ident { + hmac_domain: $hmac_domain:literal, + wire_prefix: $prefix:literal, + } + )+ + ) => { $( pub(super) enum $domain {} impl ScopedIdDomain for $domain { const PREFIX: &'static str = $prefix; + const HMAC_DOMAIN: &'static str = $hmac_domain; } pub(super) type $alias = ScopedId<$domain>; @@ -218,18 +325,47 @@ macro_rules! scoped_id_domains { const PREFIX_PARSERS: &[(&str, fn(&str) -> bool)] = &[ $(($prefix, |value| value.parse::<$alias>().is_ok())),+ ]; + + #[cfg(test)] + const DOMAIN_CONTRACTS: &[(&str, &str)] = &[ + $(($hmac_domain, $prefix)),+ + ]; }; } scoped_id_domains! { - SessionDomain => "sess_" as SessionId, - RetentionDomain => "ret_" as RetentionSubject, - AgentDomain => "agt_" as AgentSubject, - PackageDomain => "pkg_" as PackageSubject, - ExtensionDomain => "ext_" as ExtensionSubject, - HookDomain => "hok_" as HookSubject, - PluginDomain => "plg_" as PluginSubject, - CommandDomain => "cmd_" as CommandSubject, + SessionDomain => SessionId { + hmac_domain: "session_id", + wire_prefix: "sess_", + } + RetentionDomain => RetentionSubject { + hmac_domain: "retention_subject", + wire_prefix: "ret_", + } + AgentDomain => AgentSubject { + hmac_domain: "agent_subject", + wire_prefix: "agt_", + } + PackageDomain => PackageSubject { + hmac_domain: "package_subject", + wire_prefix: "pkg_", + } + ExtensionDomain => ExtensionSubject { + hmac_domain: "extension_subject", + wire_prefix: "ext_", + } + HookDomain => HookSubject { + hmac_domain: "hook_subject", + wire_prefix: "hok_", + } + PluginDomain => PluginSubject { + hmac_domain: "plugin_subject", + wire_prefix: "plg_", + } + CommandDomain => CommandSubject { + hmac_domain: "command_subject", + wire_prefix: "cmd_", + } } #[cfg(test)] @@ -252,6 +388,7 @@ mod tests { impl ScopedIdDomain for TestDomain { const PREFIX: &'static str = "test_"; + const HMAC_DOMAIN: &'static str = "test_subject"; } /// Reads the first identifier the contract spells with `prefix`. @@ -268,6 +405,115 @@ mod tests { &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_derivation_matches_contract_vector() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let deriver = IdentityDeriver::new(key); + let window = IdentityWindow::from_bytes(b"window-1"); + let dimension = ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + let identifier = deriver.derive(&window, &dimension); + + // Cross-checked with .NET's HMACSHA256 over the contract's header and + // two unsigned 64-bit big-endian length-prefixed values. The complete + // digest is ec1c11acdcca37eb4ab17f381c80df5246b0a66438f757eaa2a52195da40afbd. + assert_eq!( + identifier.to_string(), + "sess_ec1c11acdcca37eb4ab17f381c80df52" + ); + } + + #[test] + fn identical_derivation_inputs_are_stable() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let deriver = IdentityDeriver::new(key); + let window = IdentityWindow::from_bytes(b"window-1"); + let dimension = ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + let first = deriver.derive(&window, &dimension); + let second = deriver.derive(&window, &dimension); + + assert_eq!(first, second); + } + + #[test] + fn length_framing_separates_nul_at_different_boundaries() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let first_window = IdentityWindow::from_bytes(b"a\0b"); + let first_dimension = ScopedDimension::::from_fields([b"c".as_slice()]); + let second_window = IdentityWindow::from_bytes(b"a"); + let second_dimension = ScopedDimension::::from_fields([b"b\0c".as_slice()]); + + let first = deriver.derive(&first_window, &first_dimension); + let second = deriver.derive(&second_window, &second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn length_framing_separates_dimension_field_boundaries() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let window = IdentityWindow::from_bytes(b"window-1"); + let first_dimension = ScopedDimension::::from_fields([ + b"cargo".as_slice(), + b"foo1".as_slice(), + b"2.3.4".as_slice(), + ]); + let second_dimension = ScopedDimension::::from_fields([ + b"cargo".as_slice(), + b"foo".as_slice(), + b"12.3.4".as_slice(), + ]); + + let first = deriver.derive(&window, &first_dimension); + let second = deriver.derive(&window, &second_dimension); + + assert_ne!(first, second); + } + + #[test] + fn changing_any_derivation_scope_changes_the_identifier() { + let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); + let other_deriver = + IdentityDeriver::new(IdentityKey::from_bytes([0x24; IDENTITY_KEY_BYTES])); + let first_window = IdentityWindow::from_bytes(b"window-1"); + let second_window = IdentityWindow::from_bytes(b"window-2"); + let session_dimension = + ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + let other_session_dimension = + ScopedDimension::::from_fields([b"dimension-2".as_slice()]); + let command_dimension = + ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + + // Different domain markers produce different identifier types, so use + // their shared byte representation for this one collection. + let identifiers = [ + deriver.derive(&first_window, &session_dimension).bytes, + other_deriver + .derive(&first_window, &session_dimension) + .bytes, + deriver.derive(&first_window, &command_dimension).bytes, + deriver.derive(&second_window, &session_dimension).bytes, + deriver + .derive(&first_window, &other_session_dimension) + .bytes, + ]; + + let unique_identifiers = identifiers.into_iter().collect::>(); + + assert_eq!(unique_identifiers.len(), identifiers.len()); + } + #[test] fn domain_marker_adds_no_storage_to_identifier() { let bytes = [0x5a; IDENTIFIER_BYTES]; @@ -306,6 +552,28 @@ mod tests { assert_eq!(prefixes.len(), PREFIX_PARSERS.len()); } + #[test] + fn hmac_domains_are_distinct() { + let domains = DOMAIN_CONTRACTS + .iter() + .map(|(domain, _)| *domain) + .collect::>(); + + assert_eq!(domains.len(), DOMAIN_CONTRACTS.len()); + } + + #[test] + fn derivation_constants_match_the_recorded_data_contract() { + for (domain, prefix) in DOMAIN_CONTRACTS { + let contract_row = format!("| `{domain}` | `{domain}` | `{prefix}` |"); + + assert!( + RECORDED_DATA.contains(&contract_row), + "recorded-data contract does not contain {contract_row}" + ); + } + } + #[test] fn each_domain_accepts_only_its_own_prefix() { for (prefix, parses) in PREFIX_PARSERS { From b78a97d8add52c2c38d1c00faafeba3b806363f1 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 08:51:58 +0300 Subject: [PATCH 10/30] Generate telemetry identity keys securely Create each 256-bit identity key from the operating system random source and return failures to the caller. Keep generation independently testable so the complete buffer and error path are covered without relying on probabilistic assertions. --- Cargo.lock | 1 + Cargo.toml | 1 + src/telemetry/identity.rs | 51 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f83eb3f1..af3af85b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,6 +2438,7 @@ dependencies = [ "dirs", "expect-test", "flate2", + "getrandom 0.4.2", "hmac", "home", "indoc", diff --git a/Cargo.toml b/Cargo.toml index ccc0bab4..3d5a72c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ 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/src/telemetry/identity.rs b/src/telemetry/identity.rs index 526252bc..47e20e6c 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -37,6 +37,21 @@ impl IdentityKey { 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. + fn generate() -> Result { + Self::generate_with(getrandom::fill) + } + + fn generate_with(fill: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Result { + let mut bytes = [0; IDENTITY_KEY_BYTES]; + fill(&mut bytes)?; + Ok(Self::from_bytes(bytes)) + } } /// Canonical bytes for an identifier-window or return-cohort anchor. @@ -370,7 +385,11 @@ scoped_id_domains! { #[cfg(test)] mod tests { - use std::{any::TypeId, collections::HashSet, mem::size_of}; + use std::{ + any::TypeId, + collections::HashSet, + mem::{size_of, size_of_val}, + }; use super::*; @@ -415,6 +434,36 @@ mod tests { 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]); From 7d8fd74abf471570a12b72fce2515a065a300103 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 09:41:41 +0300 Subject: [PATCH 11/30] Define private telemetry identity state Keep identity material in strict, versioned TOML before adding file persistence. Share lowercase hexadecimal encoding with scoped identifiers so keys and identifiers follow the same rules. Reject unknown fields, future versions, malformed keys, and noncanonical anchor dates. Pin storage-limit JSON field order in its contract test. --- src/telemetry/identity.rs | 118 +++++++++++++++++++++---- src/telemetry/mod.rs | 1 + src/telemetry/schema.rs | 7 +- src/telemetry/state.rs | 179 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 src/telemetry/state.rs diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 47e20e6c..40338459 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -30,7 +30,7 @@ type HmacSha256 = Hmac; /// 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. -struct IdentityKey([u8; IDENTITY_KEY_BYTES]); +pub(super) struct IdentityKey([u8; IDENTITY_KEY_BYTES]); impl IdentityKey { #[must_use] @@ -243,10 +243,7 @@ where { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(D::PREFIX)?; - for byte in self.bytes { - write!(formatter, "{byte:02x}")?; - } - Ok(()) + write_lower_hex(&self.bytes, formatter) } } @@ -261,19 +258,10 @@ where .strip_prefix(D::PREFIX) .ok_or(ParseScopedIdError::IncorrectPrefix)?; - if encoded.len() != ENCODED_DIGITS { - return Err(ParseScopedIdError::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; IDENTIFIER_BYTES]; - for (&[high, low], output) in digit_pairs.iter().zip(&mut bytes) { - let high = decode_lower_hex(high).ok_or(ParseScopedIdError::InvalidHex)?; - let low = decode_lower_hex(low).ok_or(ParseScopedIdError::InvalidHex)?; - *output = (high << 4) | low; - } + 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)) } @@ -304,7 +292,46 @@ where } } -fn decode_lower_hex(value: u8) -> Option { +/// 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), @@ -312,6 +339,59 @@ fn decode_lower_hex(value: u8) -> Option { } } +/// Serde adapter for the identity key stored in `telemetry-state.toml`. +/// +/// Keeping this adapter here avoids giving [`IdentityKey`] general-purpose +/// formatting or serialization traits that could expose it elsewhere. Writing +/// the key does materialize it as a hexadecimal string, which is not scrubbed: +/// see the boundary documented on [`IdentityKey`]. +pub(super) mod state_key_hex { + use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + + use super::{ + DecodeLowerHexError, IDENTITY_KEY_BYTES, IdentityKey, decode_lower_hex_array, + write_lower_hex, + }; + + const ENCODED_KEY_DIGITS: usize = IDENTITY_KEY_BYTES * 2; + + pub(in crate::telemetry) fn serialize( + key: &IdentityKey, + serializer: S, + ) -> Result + where + S: Serializer, + { + let mut encoded = String::with_capacity(ENCODED_KEY_DIGITS); + write_lower_hex(&key.0, &mut encoded).expect("BUG: writing to a String cannot fail"); + + serializer.serialize_str(&encoded) + } + + pub(in crate::telemetry) fn deserialize<'de, D>( + deserializer: D, + ) -> Result + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + + let bytes = + decode_lower_hex_array::(&encoded).map_err( + |error| match error { + DecodeLowerHexError::IncorrectLength => D::Error::custom(format_args!( + "identity key must contain exactly {ENCODED_KEY_DIGITS} hexadecimal digits" + )), + DecodeLowerHexError::InvalidDigit => { + D::Error::custom("identity key must use lowercase hexadecimal digits") + } + }, + )?; + + Ok(IdentityKey::from_bytes(bytes)) + } +} + /// Declares each identifier domain, its contract prefix, and its alias. /// /// A macro because a function cannot introduce types, and the prefix is the diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs index 9c7756e5..88413323 100644 --- a/src/telemetry/mod.rs +++ b/src/telemetry/mod.rs @@ -13,6 +13,7 @@ mod identity; mod schema; +mod state; use std::fs::{self, OpenOptions}; use std::io::Write as _; diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index 6fbd46a7..e52dce50 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -547,10 +547,9 @@ mod tests { panic!("storage_limit contract example was not classified as supported"); }; - let actual = serde_json::to_value(row).unwrap(); - let expected = serde_json::from_str::(example).unwrap(); - - assert_eq!(actual, expected); + // 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] diff --git a/src/telemetry/state.rs b/src/telemetry/state.rs new file mode 100644 index 00000000..098d06b7 --- /dev/null +++ b/src/telemetry/state.rs @@ -0,0 +1,179 @@ +//! In-memory representation of private telemetry state. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "the state schema is built before persistence uses it." + ) +)] + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +use super::{ + identity::{IdentityKey, state_key_hex}, + schema::UtcDay, +}; + +/// 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. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct TelemetryStateV1 { + version: StateVersion, + identity: IdentityState, +} + +/// 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, + return_cohort_anchor: UtcDay, +} + +#[cfg(test)] +mod tests { + use super::TelemetryStateV1; + + const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn state_with_key(key: &str) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"2026-09-10\"\nreturn-cohort-anchor = \"2026-08-11\"\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_key(KEY); + + let state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!(serialized, source); + } + + #[test] + fn future_state_version_is_rejected() { + let source = state_with_key(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_key(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_key(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_key(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_key(&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_key(KEY).replacen("2026-09-10", "2026-9-10", 1); + + let message = rejection_message(&source); + + assert!( + message.contains("UTC day"), + "unexpected rejection reason: {message}" + ); + } +} From e84dd1f8191f9d7cbfc526952e516fd6bec41e1d Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 11:00:05 +0300 Subject: [PATCH 12/30] Add telemetry identity state construction Generate fresh state from one UTC day while leaving the return-cohort anchor absent until a session is observed. Use a deterministic constructor to pin the exact initial TOML in tests. Keep identity resets monotonic with the latest-opened-day high-water mark and document that rule consistently across the RFD. --- md/rfds/telemetry-recording/README.md | 8 +- .../contract/recorded-data.md | 10 +- .../reference/configuration.md | 4 +- .../reference/telemetry-command.md | 8 +- src/telemetry/identity.rs | 6 +- src/telemetry/schema.rs | 10 +- src/telemetry/state.rs | 111 ++++++++++++++++-- 7 files changed, 130 insertions(+), 27 deletions(-) diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 68dedbcd..e32dd9c0 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -179,9 +179,11 @@ Identifiers use the first 128 bits of HMAC-SHA-256 over a frozen domain, locally 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. -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. +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. -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. +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, 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. + +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. @@ -197,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. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index a984a625..1ec3821b 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -43,9 +43,11 @@ 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. +Normal 30-day 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. @@ -416,13 +418,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, the latest opened UTC day, 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, current anchors, and latest-opened-day high-water mark. `telemetry reset-identifiers` rotates future identifiers and starts a new retention cohort without moving the high-water mark backward. `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 47b3115d..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, current identifier-window and cohort anchors, 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. +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 645f2d4a..9d066ec6 100644 --- a/md/rfds/telemetry-recording/reference/telemetry-command.md +++ b/md/rfds/telemetry-recording/reference/telemetry-command.md @@ -110,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` @@ -157,7 +157,7 @@ Deleted 12 telemetry data file(s) from ~/.symposium/telemetry/. ## `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 @@ -181,9 +181,9 @@ 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, 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. +`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. None of these operations moves the latest-opened-day high-water mark backward. +Normal 30-day rollover changes the window anchor 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. 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/telemetry/identity.rs b/src/telemetry/identity.rs index 40338459..92e1e09c 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -43,11 +43,13 @@ impl IdentityKey { /// # Errors /// /// Returns an error when the operating system cannot provide random bytes. - fn generate() -> Result { + pub(super) fn generate() -> Result { Self::generate_with(getrandom::fill) } - fn generate_with(fill: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Result { + 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)) diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index e52dce50..26d1429d 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -102,6 +102,14 @@ where #[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) + } +} + impl Serialize for UtcDay { fn serialize(&self, serializer: S) -> Result where @@ -154,7 +162,7 @@ impl UtcSecond { /// Return the UTC calendar day containing this timestamp. pub(super) fn day(&self) -> UtcDay { - UtcDay(self.0.date_naive()) + UtcDay::from_date(self.0.date_naive()) } } diff --git a/src/telemetry/state.rs b/src/telemetry/state.rs index 098d06b7..285dc467 100644 --- a/src/telemetry/state.rs +++ b/src/telemetry/state.rs @@ -48,6 +48,11 @@ impl<'de> Deserialize<'de> for StateVersion { } /// Version 1 of the complete private telemetry state file. +/// +/// A field may be absent only when absence represents a real lifecycle state, +/// in which case its type records that explicitly. Once this version ships, +/// adding a required field needs a migration or a new state version; a default +/// must not silently turn malformed state into valid state. #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct TelemetryStateV1 { @@ -55,6 +60,33 @@ struct TelemetryStateV1 { identity: IdentityState, } +impl TelemetryStateV1 { + /// Create private state anchored to the day recording first needs identity. + /// + /// The return cohort remains absent until a session is observed. + /// + /// # Errors + /// + /// Returns an error when the operating system cannot generate a secret key. + fn new(identifier_window_anchor: UtcDay) -> Result { + let key = IdentityKey::generate()?; + Ok(Self::with_key(identifier_window_anchor, key)) + } + + /// Construct state from identity material that has already been generated. + #[must_use] + fn with_key(identifier_window_anchor: UtcDay, key: IdentityKey) -> Self { + Self { + version: StateVersion, + identity: IdentityState { + key, + identifier_window_anchor, + return_cohort_anchor: None, + }, + } + } +} + /// Stable identity material and the dates that define its rotation windows. #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -62,21 +94,39 @@ struct IdentityState { #[serde(with = "state_key_hex")] key: IdentityKey, identifier_window_anchor: UtcDay, - return_cohort_anchor: UtcDay, + #[serde(skip_serializing_if = "Option::is_none")] + return_cohort_anchor: Option, } #[cfg(test)] mod tests { - use super::TelemetryStateV1; + use std::convert::Infallible; + + use chrono::NaiveDate; + + use super::{IdentityKey, TelemetryStateV1, 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 state_with_key(key: &str) -> String { + fn day() -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 9, 10).unwrap()) + } + + fn state_with_return_cohort(key: &str) -> String { format!( "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"2026-09-10\"\nreturn-cohort-anchor = \"2026-08-11\"\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 @@ -91,7 +141,7 @@ mod tests { #[test] fn version_one_state_round_trips_in_canonical_form() { - let source = state_with_key(KEY); + let source = state_with_return_cohort(KEY); let state: TelemetryStateV1 = toml::from_str(&source).unwrap(); let serialized = toml::to_string_pretty(&state).unwrap(); @@ -99,9 +149,45 @@ mod tests { assert_eq!(serialized, source); } + #[test] + fn new_state_starts_an_identity_window_without_a_return_cohort() { + let day = day(); + + 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(), key); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_without_return_cohort(GENERATED_KEY); + assert_eq!(serialized, expected); + } + + #[test] + fn state_without_an_observed_session_has_no_return_cohort() { + let source = state_without_return_cohort(KEY); + + let state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(state.identity.return_cohort_anchor.is_none()); + assert_eq!(serialized, source); + } + #[test] fn future_state_version_is_rejected() { - let source = state_with_key(KEY).replacen("version = 1", "version = 2", 1); + let source = state_with_return_cohort(KEY).replacen("version = 1", "version = 2", 1); let message = rejection_message(&source); @@ -113,8 +199,11 @@ mod tests { #[test] fn unknown_top_level_field_is_rejected() { - let source = - state_with_key(KEY).replacen("\n[identity]", "\nunexpected = true\n\n[identity]", 1); + let source = state_with_return_cohort(KEY).replacen( + "\n[identity]", + "\nunexpected = true\n\n[identity]", + 1, + ); let message = rejection_message(&source); @@ -126,7 +215,7 @@ mod tests { #[test] fn unknown_identity_field_is_rejected() { - let mut source = state_with_key(KEY); + let mut source = state_with_return_cohort(KEY); source.push_str("unexpected = true\n"); let message = rejection_message(&source); @@ -143,7 +232,7 @@ mod tests { let one_long = format!("{KEY}0"); for key in ["", one_short, &one_long] { - let message = rejection_message(&state_with_key(key)); + let message = rejection_message(&state_with_return_cohort(key)); assert!( message.contains("exactly 64 hexadecimal digits"), @@ -156,7 +245,7 @@ mod tests { #[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_key(&key)); + let message = rejection_message(&state_with_return_cohort(&key)); assert!( message.contains("lowercase hexadecimal digits"), @@ -167,7 +256,7 @@ mod tests { #[test] fn identity_anchor_must_be_a_canonical_utc_day() { - let source = state_with_key(KEY).replacen("2026-09-10", "2026-9-10", 1); + let source = state_with_return_cohort(KEY).replacen("2026-09-10", "2026-9-10", 1); let message = rejection_message(&source); From 40f02ed16d6c2a05e24a326d0c3b8a37b33d2fac Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 12:51:31 +0300 Subject: [PATCH 13/30] Define telemetry return cohorts Represent cohort days as a bounded D0-D30 value and advance the private return-cohort anchor as sessions are observed. Persist state before recording D0 so partial failures keep identifiers stable and undercount returns. --- md/rfds/telemetry-recording/README.md | 7 +- .../contract/recorded-data.md | 4 +- src/telemetry/schema.rs | 149 +++++++++++++++++- src/telemetry/state.rs | 132 +++++++++++++++- 4 files changed, 281 insertions(+), 11 deletions(-) diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index e32dd9c0..66d1e737 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. @@ -329,6 +329,8 @@ Raw inspection remains byte-preserving. A separate typed reader returns only rec 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 applies any day advancement and cohort transition to the same in-memory state, atomically replaces private state, and only then 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 an unstable cohort 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. @@ -507,7 +509,8 @@ Verify: - Concurrent complete lines, old-or-new snapshots, whole-operation drops, and cap/marker accounting. - D30/D31 cleanup, raw inspection, validated reads, and abandoned state/snapshot temporary cleanup. -- Day advancement permanently closes earlier files; clock rollback cannot reopen them, and forward-correction drops are non-disruptive. +- Day advancement and 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. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index 1ec3821b..b70284db 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -136,7 +136,7 @@ This row records a completed registered Symposium session-start hook. GitHub Copilot does not currently supply a session id. OpenCode and Goose do not currently call Symposium through a registered session-start hook, so they do not produce this event. -These rows, not `hook_metrics` rows whose `hook` is `session_start`, are authoritative for observed-session and return measurements. For each `retention_subject`, the first row establishes D0. D1, D7, or D30 is present when at least one later session-start row has that `cohort_day`, regardless of agent or vendor session id. Multiple rows on the same cohort day count once. +These rows, not `hook_metrics` rows whose `hook` is `session_start`, are authoritative for observed-session and return measurements. A stored D0 row admits its `retention_subject` cohort to analysis. D1, D7, or D30 is present when at least one later session-start row has that `cohort_day`, regardless of agent or vendor session id. A later row without a stored D0 for the same subject is ignored. Multiple rows on the same cohort day count once. The aggregate hook rows measure only session-start hook reliability and latency. @@ -406,6 +406,8 @@ Low-volume events are appended as JSON lines in `events-YYYY-MM-DD.jsonl` under The lock in the telemetry directory also guards sibling private state. A recorder makes one non-waiting lock attempt. It may drop a complete buffered event batch or aggregate observation rather than delay your hook or command. Recording failures never change the user operation's result. +Under that lock, session recording rejects a day before the latest-opened-day high-water mark. It applies any day advancement and cohort transition to one in-memory state, atomically replaces private state, and only then appends the `session_start` row. If that append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating an unstable cohort 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 diff --git a/src/telemetry/schema.rs b/src/telemetry/schema.rs index 26d1429d..eb89530d 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema.rs @@ -3,7 +3,7 @@ not(test), expect(dead_code, reason = "the new schema is built before storage uses it.") )] -use std::{num::NonZeroU64, sync::LazyLock}; +use std::{fmt, num::NonZeroU64, sync::LazyLock}; use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; use semver::Version; @@ -108,6 +108,18 @@ impl UtcDay { 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 { @@ -115,7 +127,7 @@ impl Serialize for UtcDay { where S: Serializer, { - serializer.collect_str(&self.0.format("%Y-%m-%d")) + serializer.collect_str(self) } } @@ -146,6 +158,74 @@ fn has_utc_day_shape(value: &str) -> bool { && 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); @@ -421,6 +501,15 @@ mod tests { 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()); @@ -450,6 +539,62 @@ mod tests { } } + #[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") diff --git a/src/telemetry/state.rs b/src/telemetry/state.rs index 285dc467..51168d03 100644 --- a/src/telemetry/state.rs +++ b/src/telemetry/state.rs @@ -7,11 +7,13 @@ ) )] +use std::fmt; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::{ identity::{IdentityKey, state_key_hex}, - schema::UtcDay, + schema::{CohortDay, UtcDay}, }; /// The initial schema version of `telemetry-state.toml`. @@ -85,8 +87,69 @@ impl TelemetryStateV1 { }, } } + + /// Observe a session on a day accepted by the monotonic clock policy. + /// + /// The first observed session establishes 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 cohort transition belong to the same private-state + /// replacement, which must complete before the `session_start` row is + /// appended. + /// + /// # Errors + /// + /// Returns an error if `effective_day` precedes the stored cohort anchor. + /// A conforming storage caller filters this case first; this check protects + /// the state invariant against an incorrect caller or inconsistent state. + fn observe_session( + &mut self, + effective_day: UtcDay, + ) -> Result { + let Some(anchor) = self.identity.return_cohort_anchor else { + self.identity.return_cohort_anchor = Some(effective_day); + return Ok(CohortDay::D0); + }; + + let elapsed_days = effective_day.days_since(anchor); + if elapsed_days < 0 { + return Err(SessionDayBeforeCohortAnchor { + observed_day: effective_day, + cohort_anchor: anchor, + }); + } + + if elapsed_days > i64::from(CohortDay::D30.get()) { + self.identity.return_cohort_anchor = Some(effective_day); + return Ok(CohortDay::D0); + } + + Ok(CohortDay::try_from(elapsed_days) + .expect("BUG: a session between D0 and D30 must have a valid cohort day")) + } +} + +/// An observed session day earlier than its stored return-cohort anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SessionDayBeforeCohortAnchor { + observed_day: UtcDay, + cohort_anchor: UtcDay, } +impl fmt::Display for SessionDayBeforeCohortAnchor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "observed session day {} precedes the return-cohort anchor {}", + self.observed_day, self.cohort_anchor + ) + } +} + +impl std::error::Error for SessionDayBeforeCohortAnchor {} + /// Stable identity material and the dates that define its rotation windows. #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -104,15 +167,15 @@ mod tests { use chrono::NaiveDate; - use super::{IdentityKey, TelemetryStateV1, UtcDay}; + use super::{CohortDay, IdentityKey, TelemetryStateV1, 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() -> UtcDay { - UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 9, 10).unwrap()) + 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 { @@ -151,7 +214,7 @@ mod tests { #[test] fn new_state_starts_an_identity_window_without_a_return_cohort() { - let day = day(); + let day = day(2026, 9, 10); let state = TelemetryStateV1::new(day).unwrap(); @@ -167,7 +230,7 @@ mod tests { }) .unwrap(); - let state = TelemetryStateV1::with_key(day(), key); + 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); @@ -185,6 +248,63 @@ mod tests { assert_eq!(serialized, source); } + #[test] + fn first_observed_session_starts_d0() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 10); + + let cohort_day = state.observe_session(observed_day).unwrap(); + + assert_eq!(cohort_day, CohortDay::D0); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn observations_through_d30_keep_the_existing_cohort() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let anchor = day(2026, 8, 11); + + for (observed_day, expected_day) in [ + (day(2026, 8, 11), 0_i64), + (day(2026, 8, 12), 1), + (day(2026, 9, 10), 30), + ] { + let cohort_day = state.observe_session(observed_day).unwrap(); + + assert_eq!(cohort_day, CohortDay::try_from(expected_day).unwrap()); + assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); + } + } + + #[test] + fn first_observation_after_d30_starts_a_new_cohort() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 11); + + let cohort_day = state.observe_session(observed_day).unwrap(); + + assert_eq!(cohort_day, CohortDay::D0); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn observation_before_the_cohort_anchor_is_rejected_without_mutation() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let original_anchor = state.identity.return_cohort_anchor; + + let error = state.observe_session(day(2026, 8, 10)).unwrap_err(); + + assert_eq!( + error.to_string(), + "observed session day 2026-08-10 precedes the return-cohort anchor 2026-08-11" + ); + assert_eq!(state.identity.return_cohort_anchor, original_anchor); + } + #[test] fn future_state_version_is_rejected() { let source = state_with_return_cohort(KEY).replacen("version = 1", "version = 2", 1); From a988390d0db57fb6d353c72096c5e550d47d19e6 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 14:14:50 +0300 Subject: [PATCH 14/30] Advance telemetry identity windows with session state Roll identifier windows over on the first observation at day 30 or later, while keeping the identity key stable. Select identifier and return-cohort anchors before mutating private state, so storage can persist one coherent session transition. --- md/rfds/telemetry-recording/README.md | 6 +- .../contract/recorded-data.md | 4 +- .../reference/telemetry-command.md | 4 +- src/telemetry/state.rs | 396 +++++++++++++++--- 4 files changed, 354 insertions(+), 56 deletions(-) diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 66d1e737..43cd772f 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -181,7 +181,7 @@ The dimension limits what an identifier can link. Identity code constructs it fr 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. -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, 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. +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. 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. @@ -329,7 +329,7 @@ Raw inspection remains byte-preserving. A separate typed reader returns only rec 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 applies any day advancement and cohort transition to the same in-memory state, atomically replaces private state, and only then 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 an unstable cohort identity. +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. @@ -509,7 +509,7 @@ Verify: - Concurrent complete lines, old-or-new snapshots, whole-operation drops, and cap/marker accounting. - D30/D31 cleanup, raw inspection, validated reads, and abandoned state/snapshot temporary cleanup. -- Day advancement and cohort transition share one locked state replacement before a session append. A failed D0 append cannot admit later rows as a return cohort. +- 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. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index b70284db..2fac2d0c 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -45,7 +45,7 @@ These are independent row-shape examples, not one coherent operation or batch. T 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 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. +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. @@ -406,7 +406,7 @@ Low-volume events are appended as JSON lines in `events-YYYY-MM-DD.jsonl` under The lock in the telemetry directory also guards sibling private state. A recorder makes one non-waiting lock attempt. It may drop a complete buffered event batch or aggregate observation rather than delay your hook or command. Recording failures never change the user operation's result. -Under that lock, session recording rejects a day before the latest-opened-day high-water mark. It applies any day advancement and cohort transition to one in-memory state, atomically replaces private state, and only then appends the `session_start` row. If that append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating an unstable cohort identity. +Under that lock, session recording rejects a day before the latest-opened-day high-water mark. It calculates the identifier-window and return-cohort transitions before mutating either anchor. It then applies both transitions and any high-water advancement to one in-memory state, atomically replaces private state once, and only then derives the row identifiers and appends the `session_start` row. If that append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating unstable identity. Private state keeps the latest opened UTC day as a high-water mark. Observing a later day permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than modifying a closed day. Raw inspection still preserves every stored line. Typed reading of a closed day returns only recognized rows that pass their versioned schema and file/day invariants, and reports malformed, invalid, and unknown-version lines separately. It rejects an oversized or incompletely read day as a whole rather than returning a partial validated result. diff --git a/md/rfds/telemetry-recording/reference/telemetry-command.md b/md/rfds/telemetry-recording/reference/telemetry-command.md index 9d066ec6..bcff1bd7 100644 --- a/md/rfds/telemetry-recording/reference/telemetry-command.md +++ b/md/rfds/telemetry-recording/reference/telemetry-command.md @@ -183,7 +183,9 @@ Each project skills parent may also contain a generated `.symposium/index-v1.jso `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, 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. +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/telemetry/state.rs b/src/telemetry/state.rs index 51168d03..72bf543a 100644 --- a/src/telemetry/state.rs +++ b/src/telemetry/state.rs @@ -16,6 +16,12 @@ use super::{ schema::{CohortDay, UtcDay}, }; +/// Exclusive length of an identifier window in UTC-day positions. +/// +/// Offsets 0 through 29 remain in the window; unlike the cohort's inclusive +/// [`CohortDay::D30`] bound, offset 30 starts a new window. +const IDENTIFIER_WINDOW_DAYS: i64 = 30; + /// The initial schema version of `telemetry-state.toml`. /// /// Exact rather than permissive, unlike a row's `SchemaVersion`: a row written by @@ -90,65 +96,187 @@ impl TelemetryStateV1 { /// Observe a session on a day accepted by the monotonic clock policy. /// - /// The first observed session establishes D0. An existing cohort keeps its - /// anchor through D30; the first later observation starts another D0. + /// 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 cohort transition belong to the same private-state - /// replacement, which must complete before the `session_start` row is - /// appended. + /// advancement and this complete session transition belong to one + /// private-state replacement. That replacement must complete before the + /// `session_start` row is appended or either returned anchor is used to + /// derive an identifier. /// /// # Errors /// - /// Returns an error if `effective_day` precedes the stored cohort anchor. - /// A conforming storage caller filters this case first; this check protects - /// the state invariant against an incorrect caller or inconsistent state. + /// Returns an error if `effective_day` precedes either stored anchor. A + /// conforming storage caller filters this case through its durable day + /// policy; these checks protect against an incorrect caller or inconsistent + /// state. Neither anchor changes when validation fails. fn observe_session( &mut self, effective_day: UtcDay, - ) -> Result { + ) -> Result { + let identifier_window = self.select_identifier_window(effective_day)?; + let return_cohort = self.select_return_cohort(effective_day)?; + + self.identity.identifier_window_anchor = identifier_window.anchor(); + self.identity.return_cohort_anchor = Some(return_cohort.anchor()); + + Ok(SessionObservation { + identifier_window, + return_cohort, + }) + } + + /// Select the identifier window without mutating private state. + fn select_identifier_window( + &self, + effective_day: UtcDay, + ) -> Result { + let anchor = self.identity.identifier_window_anchor; + let elapsed_days = effective_day.days_since(anchor); + + if elapsed_days < 0 { + return Err(SessionObservationError::BeforeIdentifierWindow { + observed_day: effective_day, + window_anchor: anchor, + }); + } + + if elapsed_days < IDENTIFIER_WINDOW_DAYS { + return Ok(IdentifierWindowUpdate::Current { anchor }); + } + + Ok(IdentifierWindowUpdate::Advanced { + anchor: effective_day, + }) + } + + /// Select the return cohort without mutating private state. + fn select_return_cohort( + &self, + effective_day: UtcDay, + ) -> Result { let Some(anchor) = self.identity.return_cohort_anchor else { - self.identity.return_cohort_anchor = Some(effective_day); - return Ok(CohortDay::D0); + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); }; let elapsed_days = effective_day.days_since(anchor); if elapsed_days < 0 { - return Err(SessionDayBeforeCohortAnchor { + return Err(SessionObservationError::BeforeReturnCohort { observed_day: effective_day, cohort_anchor: anchor, }); } if elapsed_days > i64::from(CohortDay::D30.get()) { - self.identity.return_cohort_anchor = Some(effective_day); - return Ok(CohortDay::D0); + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); + } + + let day = CohortDay::try_from(elapsed_days) + .expect("BUG: a session between D0 and D30 must have a valid cohort day"); + Ok(ReturnCohortUpdate::Current { anchor, day }) + } +} + +/// Identity and return-cohort selections for one observed session. +#[must_use = "session identity state must be persisted before identifiers are emitted"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SessionObservation { + identifier_window: IdentifierWindowUpdate, + return_cohort: ReturnCohortUpdate, +} + +/// Whether selecting an identifier window changed private state. +#[must_use = "an advanced identifier window must be persisted before use"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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] + fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor } | Self::Advanced { anchor } => anchor, } + } +} - Ok(CohortDay::try_from(elapsed_days) - .expect("BUG: a session between D0 and D30 must have a valid cohort day")) +/// 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)] +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] + fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor, .. } | Self::Started { anchor } => anchor, + } + } + + /// Return the observed day within the selected cohort. + #[must_use] + fn day(self) -> CohortDay { + match self { + Self::Current { day, .. } => day, + Self::Started { .. } => CohortDay::D0, + } } } -/// An observed session day earlier than its stored return-cohort anchor. +/// An observed session earlier than one of its stored identity anchors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct SessionDayBeforeCohortAnchor { - observed_day: UtcDay, - cohort_anchor: UtcDay, +enum SessionObservationError { + BeforeIdentifierWindow { + observed_day: UtcDay, + window_anchor: UtcDay, + }, + BeforeReturnCohort { + observed_day: UtcDay, + cohort_anchor: UtcDay, + }, } -impl fmt::Display for SessionDayBeforeCohortAnchor { +impl fmt::Display for SessionObservationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "observed session day {} precedes the return-cohort anchor {}", - self.observed_day, self.cohort_anchor - ) + match self { + Self::BeforeIdentifierWindow { + observed_day, + window_anchor, + } => write!( + formatter, + "observed day {observed_day} precedes the identifier-window anchor {window_anchor}" + ), + Self::BeforeReturnCohort { + observed_day, + cohort_anchor, + } => write!( + formatter, + "observed session day {observed_day} precedes the return-cohort anchor {cohort_anchor}" + ), + } } } -impl std::error::Error for SessionDayBeforeCohortAnchor {} +impl std::error::Error for SessionObservationError {} /// Stable identity material and the dates that define its rotation windows. #[derive(Serialize, Deserialize)] @@ -167,7 +295,10 @@ mod tests { use chrono::NaiveDate; - use super::{CohortDay, IdentityKey, TelemetryStateV1, UtcDay}; + use super::{ + CohortDay, IdentifierWindowUpdate, IdentityKey, ReturnCohortUpdate, TelemetryStateV1, + UtcDay, + }; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const GENERATED_KEY_BYTE: u8 = 0x42; @@ -179,8 +310,16 @@ mod tests { } 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 = \"2026-09-10\"\nreturn-cohort-anchor = \"2026-08-11\"\n" + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\nreturn-cohort-anchor = \"{return_cohort_anchor}\"\n" ) } @@ -248,61 +387,218 @@ mod tests { assert_eq!(serialized, source); } + #[test] + fn observations_on_days_zero_through_twenty_nine_keep_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let anchor = day(2026, 9, 10); + + for observed_day in [anchor, day(2026, 10, 9)] { + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { anchor } + ); + assert_eq!(observation.identifier_window.anchor(), anchor); + assert_eq!(state.identity.identifier_window_anchor, anchor); + } + } + + #[test] + fn observation_on_day_thirty_advances_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 10); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D30); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + } + + #[test] + fn observation_after_inactivity_anchors_both_lifecycles_to_the_observation() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn window_rollover_preserves_the_key_and_return_cohort() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(day(2026, 10, 10)).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert_eq!(serialized, expected); + } + + #[test] + fn observation_before_the_window_anchor_is_rejected_without_mutation() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + error.to_string(), + "observed day 2026-09-09 precedes the identifier-window anchor 2026-09-10" + ); + assert_eq!(serialized, source); + } + #[test] fn first_observed_session_starts_d0() { let source = state_without_return_cohort(KEY); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); let observed_day = day(2026, 9, 10); - let cohort_day = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(observed_day).unwrap(); - assert_eq!(cohort_day, CohortDay::D0); + assert_eq!(observation.return_cohort.day(), CohortDay::D0); assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); } #[test] - fn observations_through_d30_keep_the_existing_cohort() { - let source = state_with_return_cohort(KEY); + fn first_session_after_window_expiry_starts_d0_and_advances_the_window() { + let source = state_without_return_cohort(KEY); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn observations_through_d30_keep_the_existing_cohort() { let anchor = day(2026, 8, 11); - for (observed_day, expected_day) in [ - (day(2026, 8, 11), 0_i64), - (day(2026, 8, 12), 1), - (day(2026, 9, 10), 30), + for (window_anchor, observed_day, expected_day) in [ + ("2026-08-11", day(2026, 8, 11), 0_i64), + ("2026-08-11", day(2026, 8, 12), 1), + ("2026-09-01", day(2026, 9, 10), 30), ] { - let cohort_day = state.observe_session(observed_day).unwrap(); + let source = state_with_anchors(KEY, window_anchor, "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(observed_day).unwrap(); - assert_eq!(cohort_day, CohortDay::try_from(expected_day).unwrap()); + assert_eq!( + observation.return_cohort.day(), + CohortDay::try_from(expected_day).unwrap() + ); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Current { .. } + )); assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); } } #[test] - fn first_observation_after_d30_starts_a_new_cohort() { - let source = state_with_return_cohort(KEY); + fn cohort_rollover_preserves_the_identifier_window_and_key() { + let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); let observed_day = day(2026, 9, 11); - let cohort_day = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); - assert_eq!(cohort_day, CohortDay::D0); - assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { + anchor: day(2026, 9, 1) + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D0); + assert_eq!(serialized, expected); } #[test] - fn observation_before_the_cohort_anchor_is_rejected_without_mutation() { - let source = state_with_return_cohort(KEY); + 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 original_anchor = state.identity.return_cohort_anchor; + let observed_day = day(2026, 9, 11); - let error = state.observe_session(day(2026, 8, 10)).unwrap_err(); + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn invalid_cohort_day_does_not_partially_advance_the_window() { + let source = state_with_anchors(KEY, "2026-08-01", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); assert_eq!( error.to_string(), - "observed session day 2026-08-10 precedes the return-cohort anchor 2026-08-11" + "observed session day 2026-09-09 precedes the return-cohort anchor 2026-09-10" ); - assert_eq!(state.identity.return_cohort_anchor, original_anchor); + assert_eq!(serialized, source); } #[test] From 5e3c9e3d5bb89a3d6450fcbf5c543e242b2e6696 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 15:11:55 +0300 Subject: [PATCH 15/30] Add telemetry identifier reset Rotate the identity key, advance the window anchor, and clear the return cohort as one state replacement. Generate the new key before mutation so failures leave the previous state intact. Document reset's clock-clamping rules and future state obligations. --- src/telemetry/state.rs | 105 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/src/telemetry/state.rs b/src/telemetry/state.rs index 72bf543a..c54d5a1a 100644 --- a/src/telemetry/state.rs +++ b/src/telemetry/state.rs @@ -94,6 +94,55 @@ 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. + fn reset_identifiers( + &mut self, + identifier_window_anchor: UtcDay, + ) -> Result<(), getrandom::Error> { + self.reset_identifiers_with(identifier_window_anchor, getrandom::fill) + } + + /// Reset identifiers using a caller-provided source of key bytes. + /// + /// # Errors + /// + /// Returns the source error without changing state if key generation fails. + fn reset_identifiers_with( + &mut self, + identifier_window_anchor: UtcDay, + fill_key: impl FnOnce(&mut [u8]) -> Result<(), E>, + ) -> Result<(), E> { + let key = IdentityKey::generate_with(fill_key)?; + self.identity = IdentityState { + key, + identifier_window_anchor, + return_cohort_anchor: None, + }; + Ok(()) + } + /// Observe a session on a day accepted by the monotonic clock policy. /// /// This selects the identifier window and return cohort before mutating @@ -305,6 +354,9 @@ mod tests { /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; + #[derive(Debug, PartialEq, Eq)] + struct TestKeySourceError; + fn day(year: i32, month: u32, day: u32) -> UtcDay { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } @@ -324,8 +376,12 @@ mod tests { } 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 = \"2026-09-10\"\n" + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\n" ) } @@ -376,6 +432,53 @@ mod tests { assert_eq!(serialized, expected); } + #[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 state_without_an_observed_session_has_no_return_cohort() { let source = state_without_return_cohort(KEY); From fbee999de370f915155fd1917226f0bbea6daee2 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 17:17:50 +0300 Subject: [PATCH 16/30] Move telemetry state into a module directory Make room for focused state submodules while preserving the existing implementation unchanged. The telemetry parent continues to load state through the same private module boundary. --- src/telemetry/{state.rs => state/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/telemetry/{state.rs => state/mod.rs} (100%) diff --git a/src/telemetry/state.rs b/src/telemetry/state/mod.rs similarity index 100% rename from src/telemetry/state.rs rename to src/telemetry/state/mod.rs From e4260bc08b58833cb972d1b6661991ae8f2510f6 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 17:19:31 +0300 Subject: [PATCH 17/30] Separate telemetry state lifecycle Keep the serialized private-state model in the module root and move identifier reset, window, and cohort transitions into lifecycle.rs. Leave each responsibility's tests beside its implementation and record the boundary in the module structure guide. --- md/design/module-structure.md | 4 +- src/telemetry/state/lifecycle.rs | 555 +++++++++++++++++++++++++++++++ src/telemetry/state/mod.rs | 520 +---------------------------- 3 files changed, 564 insertions(+), 515 deletions(-) create mode 100644 src/telemetry/state/lifecycle.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 4925caa0..35a080fc 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 `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. + ### `report.rs` — structured report layer Provides user-facing output for all commands via a custom tracing layer. Commands emit `tracing::info!` or `tracing::debug!` events with a `report = %ReportEvent::Variant { ... }` field; the `ReportLayer` intercepts these and renders them based on mode: diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs new file mode 100644 index 00000000..96408b16 --- /dev/null +++ b/src/telemetry/state/lifecycle.rs @@ -0,0 +1,555 @@ +//! Identity-window and return-cohort transitions in private telemetry state. + +use std::fmt; + +use super::{IdentityState, TelemetryStateV1}; +use crate::telemetry::{ + identity::IdentityKey, + schema::{CohortDay, UtcDay}, +}; + +/// Exclusive length of an identifier window in UTC-day positions. +/// +/// Offsets 0 through 29 remain in the window; unlike the cohort's inclusive +/// [`CohortDay::D30`] bound, offset 30 starts a new window. +const IDENTIFIER_WINDOW_DAYS: i64 = 30; + +impl TelemetryStateV1 { + /// Rotate future identifiers and begin a new identifier window. + /// + /// `identifier_window_anchor` must be the later of the current UTC day and + /// the durable latest-opened-day high-water mark. Unlike a stale session + /// observation, an explicit reset is clamped to that high-water mark rather + /// than dropped. The next observed session starts a new return cohort at + /// D0. + /// + /// The selected anchor may precede the stored window anchor after a clock + /// rollback. That is intentional: rotating the key severs the previous + /// identity scope, so reset does not compare the new anchor with the old + /// one. + /// + /// The storage-level reset must preserve the durable high-water mark and + /// clear pending keyed session-count sets once those sibling state sections + /// are added. + /// + /// Key generation completes before any state changes, so a failure leaves + /// the existing key and anchors intact. + /// + /// # Errors + /// + /// Returns an error when the operating system cannot generate a secret key. + pub(super) fn reset_identifiers( + &mut self, + identifier_window_anchor: UtcDay, + ) -> Result<(), getrandom::Error> { + self.reset_identifiers_with(identifier_window_anchor, getrandom::fill) + } + + /// Reset identifiers using a caller-provided source of key bytes. + /// + /// # Errors + /// + /// Returns the source error without changing state if key generation fails. + fn reset_identifiers_with( + &mut self, + identifier_window_anchor: UtcDay, + fill_key: impl FnOnce(&mut [u8]) -> Result<(), E>, + ) -> Result<(), E> { + let key = IdentityKey::generate_with(fill_key)?; + self.identity = IdentityState { + key, + identifier_window_anchor, + return_cohort_anchor: None, + }; + Ok(()) + } + + /// Observe a session on a day accepted by the monotonic clock policy. + /// + /// This selects the identifier window and return cohort before mutating + /// either anchor. The first observed session establishes cohort D0. An + /// existing cohort keeps its anchor through D30; the first later + /// observation starts another D0. + /// + /// Storage must call this while holding the telemetry lock, after rejecting + /// a day before the latest-opened-day high-water mark. Any high-water + /// advancement and this complete session transition belong to one + /// private-state replacement. That replacement must complete before the + /// `session_start` row is appended or either returned anchor is used to + /// derive an identifier. + /// + /// # Errors + /// + /// Returns an error if `effective_day` precedes either stored anchor. A + /// conforming storage caller filters this case through its durable day + /// policy; these checks protect against an incorrect caller or inconsistent + /// state. Neither anchor changes when validation fails. + pub(super) fn observe_session( + &mut self, + effective_day: UtcDay, + ) -> Result { + let identifier_window = self.select_identifier_window(effective_day)?; + let return_cohort = self.select_return_cohort(effective_day)?; + + self.identity.identifier_window_anchor = identifier_window.anchor(); + self.identity.return_cohort_anchor = Some(return_cohort.anchor()); + + Ok(SessionObservation { + identifier_window, + return_cohort, + }) + } + + /// Select the identifier window without mutating private state. + fn select_identifier_window( + &self, + effective_day: UtcDay, + ) -> Result { + let anchor = self.identity.identifier_window_anchor; + let elapsed_days = effective_day.days_since(anchor); + + if elapsed_days < 0 { + return Err(SessionObservationError::BeforeIdentifierWindow { + observed_day: effective_day, + window_anchor: anchor, + }); + } + + if elapsed_days < IDENTIFIER_WINDOW_DAYS { + return Ok(IdentifierWindowUpdate::Current { anchor }); + } + + Ok(IdentifierWindowUpdate::Advanced { + anchor: effective_day, + }) + } + + /// Select the return cohort without mutating private state. + fn select_return_cohort( + &self, + effective_day: UtcDay, + ) -> Result { + let Some(anchor) = self.identity.return_cohort_anchor else { + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); + }; + + let elapsed_days = effective_day.days_since(anchor); + if elapsed_days < 0 { + return Err(SessionObservationError::BeforeReturnCohort { + observed_day: effective_day, + cohort_anchor: anchor, + }); + } + + if elapsed_days > i64::from(CohortDay::D30.get()) { + return Ok(ReturnCohortUpdate::Started { + anchor: effective_day, + }); + } + + let day = CohortDay::try_from(elapsed_days) + .expect("BUG: a session between D0 and D30 must have a valid cohort day"); + Ok(ReturnCohortUpdate::Current { anchor, day }) + } +} + +/// Identity and return-cohort selections for one observed session. +#[must_use = "session identity state must be persisted before identifiers are emitted"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SessionObservation { + pub(super) identifier_window: IdentifierWindowUpdate, + pub(super) return_cohort: ReturnCohortUpdate, +} + +/// Whether selecting an identifier window changed private state. +#[must_use = "an advanced identifier window must be persisted before use"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum IdentifierWindowUpdate { + /// The existing window remains active. + Current { anchor: UtcDay }, + /// A new observation-anchored window was started. + Advanced { anchor: UtcDay }, +} + +impl IdentifierWindowUpdate { + /// Return the anchor selected for identifier derivation. + #[must_use] + pub(super) fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor } | Self::Advanced { anchor } => anchor, + } + } +} + +/// Whether selecting a return cohort changed private state. +#[must_use = "a started return cohort must be persisted before use"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReturnCohortUpdate { + /// The existing cohort remains active at `day`. + Current { anchor: UtcDay, day: CohortDay }, + /// A new cohort was started at D0. + Started { anchor: UtcDay }, +} + +impl ReturnCohortUpdate { + /// Return the anchor selected for retention-subject derivation. + #[must_use] + pub(super) fn anchor(self) -> UtcDay { + match self { + Self::Current { anchor, .. } | Self::Started { anchor } => anchor, + } + } + + /// Return the observed day within the selected cohort. + #[must_use] + pub(super) fn day(self) -> CohortDay { + match self { + Self::Current { day, .. } => day, + Self::Started { .. } => CohortDay::D0, + } + } +} + +/// An observed session earlier than one of its stored identity anchors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SessionObservationError { + BeforeIdentifierWindow { + observed_day: UtcDay, + window_anchor: UtcDay, + }, + BeforeReturnCohort { + observed_day: UtcDay, + cohort_anchor: UtcDay, + }, +} + +impl fmt::Display for SessionObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BeforeIdentifierWindow { + observed_day, + window_anchor, + } => write!( + formatter, + "observed day {observed_day} precedes the identifier-window anchor {window_anchor}" + ), + Self::BeforeReturnCohort { + observed_day, + cohort_anchor, + } => write!( + formatter, + "observed session day {observed_day} precedes the return-cohort anchor {cohort_anchor}" + ), + } + } +} + +impl std::error::Error for SessionObservationError {} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use chrono::NaiveDate; + + use super::*; + + const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const GENERATED_KEY_BYTE: u8 = 0x42; + /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. + const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; + + #[derive(Debug, PartialEq, Eq)] + struct TestKeySourceError; + + fn day(year: i32, month: u32, day: u32) -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) + } + + fn state_with_return_cohort(key: &str) -> String { + state_with_anchors(key, "2026-09-10", "2026-08-11") + } + + fn state_with_anchors( + key: &str, + identifier_window_anchor: &str, + return_cohort_anchor: &str, + ) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\nreturn-cohort-anchor = \"{return_cohort_anchor}\"\n" + ) + } + + fn state_without_return_cohort(key: &str) -> String { + state_without_return_cohort_at(key, "2026-09-10") + } + + fn state_without_return_cohort_at(key: &str, identifier_window_anchor: &str) -> String { + format!( + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"{identifier_window_anchor}\"\n" + ) + } + + #[test] + fn identifier_reset_rotates_the_key_resets_the_window_and_clears_the_cohort() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let reset_day = day(2026, 10, 15); + + state + .reset_identifiers_with::(reset_day, |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Ok(()) + }) + .unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_without_return_cohort_at(GENERATED_KEY, "2026-10-15"); + assert_eq!(serialized, expected); + } + + #[test] + fn failed_identifier_reset_preserves_the_complete_state() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let result = state.reset_identifiers_with(day(2026, 10, 15), |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Err(TestKeySourceError) + }); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!(result, Err(TestKeySourceError)); + assert_eq!(serialized, source); + } + + #[test] + fn identifier_reset_can_use_operating_system_randomness() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let reset_day = day(2026, 10, 15); + + state.reset_identifiers(reset_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(!serialized.contains(KEY)); + assert_eq!(state.identity.identifier_window_anchor, reset_day); + assert!(state.identity.return_cohort_anchor.is_none()); + } + + #[test] + fn observations_on_days_zero_through_twenty_nine_keep_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let anchor = day(2026, 9, 10); + + for observed_day in [anchor, day(2026, 10, 9)] { + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { anchor } + ); + assert_eq!(observation.identifier_window.anchor(), anchor); + assert_eq!(state.identity.identifier_window_anchor, anchor); + } + } + + #[test] + fn observation_on_day_thirty_advances_the_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 10); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D30); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + } + + #[test] + fn observation_after_inactivity_anchors_both_lifecycles_to_the_observation() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(state.identity.identifier_window_anchor, observed_day); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn window_rollover_preserves_the_key_and_return_cohort() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(day(2026, 10, 10)).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert_eq!(serialized, expected); + } + + #[test] + fn observation_before_the_window_anchor_is_rejected_without_mutation() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + error.to_string(), + "observed day 2026-09-09 precedes the identifier-window anchor 2026-09-10" + ); + assert_eq!(serialized, source); + } + + #[test] + fn first_observed_session_starts_d0() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 10); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!(observation.return_cohort.day(), CohortDay::D0); + assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); + } + + #[test] + fn first_session_after_window_expiry_starts_d0_and_advances_the_window() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 10, 25); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn observations_through_d30_keep_the_existing_cohort() { + let anchor = day(2026, 8, 11); + + for (window_anchor, observed_day, expected_day) in [ + ("2026-08-11", day(2026, 8, 11), 0_i64), + ("2026-08-11", day(2026, 8, 12), 1), + ("2026-09-01", day(2026, 9, 10), 30), + ] { + let source = state_with_anchors(KEY, window_anchor, "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let observation = state.observe_session(observed_day).unwrap(); + + assert_eq!( + observation.return_cohort.day(), + CohortDay::try_from(expected_day).unwrap() + ); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Current { .. } + )); + assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); + } + } + + #[test] + fn cohort_rollover_preserves_the_identifier_window_and_key() { + let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 11); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Current { + anchor: day(2026, 9, 1) + } + ); + assert_eq!(observation.return_cohort.day(), CohortDay::D0); + assert_eq!(serialized, expected); + } + + #[test] + fn both_session_lifecycles_roll_over_in_one_transition() { + let source = state_with_anchors(KEY, "2026-08-12", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observed_day = day(2026, 9, 11); + + let observation = state.observe_session(observed_day).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + observation.return_cohort, + ReturnCohortUpdate::Started { + anchor: observed_day + } + ); + assert_eq!(serialized, expected); + } + + #[test] + fn invalid_cohort_day_does_not_partially_advance_the_window() { + let source = state_with_anchors(KEY, "2026-08-01", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + error.to_string(), + "observed session day 2026-09-09 precedes the return-cohort anchor 2026-09-10" + ); + assert_eq!(serialized, source); + } +} diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index c54d5a1a..8bcb0b2e 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -1,4 +1,4 @@ -//! In-memory representation of private telemetry state. +//! Private telemetry state and its lifecycle. #![cfg_attr( not(test), expect( @@ -7,20 +7,14 @@ ) )] -use std::fmt; - use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::{ identity::{IdentityKey, state_key_hex}, - schema::{CohortDay, UtcDay}, + schema::UtcDay, }; -/// Exclusive length of an identifier window in UTC-day positions. -/// -/// Offsets 0 through 29 remain in the window; unlike the cohort's inclusive -/// [`CohortDay::D30`] bound, offset 30 starts a new window. -const IDENTIFIER_WINDOW_DAYS: i64 = 30; +mod lifecycle; /// The initial schema version of `telemetry-state.toml`. /// @@ -93,240 +87,8 @@ 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. - fn reset_identifiers( - &mut self, - identifier_window_anchor: UtcDay, - ) -> Result<(), getrandom::Error> { - self.reset_identifiers_with(identifier_window_anchor, getrandom::fill) - } - - /// Reset identifiers using a caller-provided source of key bytes. - /// - /// # Errors - /// - /// Returns the source error without changing state if key generation fails. - fn reset_identifiers_with( - &mut self, - identifier_window_anchor: UtcDay, - fill_key: impl FnOnce(&mut [u8]) -> Result<(), E>, - ) -> Result<(), E> { - let key = IdentityKey::generate_with(fill_key)?; - self.identity = IdentityState { - key, - identifier_window_anchor, - return_cohort_anchor: None, - }; - Ok(()) - } - - /// Observe a session on a day accepted by the monotonic clock policy. - /// - /// This selects the identifier window and return cohort before mutating - /// either anchor. The first observed session establishes cohort D0. An - /// existing cohort keeps its anchor through D30; the first later - /// observation starts another D0. - /// - /// Storage must call this while holding the telemetry lock, after rejecting - /// a day before the latest-opened-day high-water mark. Any high-water - /// advancement and this complete session transition belong to one - /// private-state replacement. That replacement must complete before the - /// `session_start` row is appended or either returned anchor is used to - /// derive an identifier. - /// - /// # Errors - /// - /// Returns an error if `effective_day` precedes either stored anchor. A - /// conforming storage caller filters this case through its durable day - /// policy; these checks protect against an incorrect caller or inconsistent - /// state. Neither anchor changes when validation fails. - fn observe_session( - &mut self, - effective_day: UtcDay, - ) -> Result { - let identifier_window = self.select_identifier_window(effective_day)?; - let return_cohort = self.select_return_cohort(effective_day)?; - - self.identity.identifier_window_anchor = identifier_window.anchor(); - self.identity.return_cohort_anchor = Some(return_cohort.anchor()); - - Ok(SessionObservation { - identifier_window, - return_cohort, - }) - } - - /// Select the identifier window without mutating private state. - fn select_identifier_window( - &self, - effective_day: UtcDay, - ) -> Result { - let anchor = self.identity.identifier_window_anchor; - let elapsed_days = effective_day.days_since(anchor); - - if elapsed_days < 0 { - return Err(SessionObservationError::BeforeIdentifierWindow { - observed_day: effective_day, - window_anchor: anchor, - }); - } - - if elapsed_days < IDENTIFIER_WINDOW_DAYS { - return Ok(IdentifierWindowUpdate::Current { anchor }); - } - - Ok(IdentifierWindowUpdate::Advanced { - anchor: effective_day, - }) - } - - /// Select the return cohort without mutating private state. - fn select_return_cohort( - &self, - effective_day: UtcDay, - ) -> Result { - let Some(anchor) = self.identity.return_cohort_anchor else { - return Ok(ReturnCohortUpdate::Started { - anchor: effective_day, - }); - }; - - let elapsed_days = effective_day.days_since(anchor); - if elapsed_days < 0 { - return Err(SessionObservationError::BeforeReturnCohort { - observed_day: effective_day, - cohort_anchor: anchor, - }); - } - - if elapsed_days > i64::from(CohortDay::D30.get()) { - return Ok(ReturnCohortUpdate::Started { - anchor: effective_day, - }); - } - - let day = CohortDay::try_from(elapsed_days) - .expect("BUG: a session between D0 and D30 must have a valid cohort day"); - Ok(ReturnCohortUpdate::Current { anchor, day }) - } } -/// Identity and return-cohort selections for one observed session. -#[must_use = "session identity state must be persisted before identifiers are emitted"] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct SessionObservation { - identifier_window: IdentifierWindowUpdate, - return_cohort: ReturnCohortUpdate, -} - -/// Whether selecting an identifier window changed private state. -#[must_use = "an advanced identifier window must be persisted before use"] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -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] - 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)] -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] - fn anchor(self) -> UtcDay { - match self { - Self::Current { anchor, .. } | Self::Started { anchor } => anchor, - } - } - - /// Return the observed day within the selected cohort. - #[must_use] - 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)] -enum SessionObservationError { - BeforeIdentifierWindow { - observed_day: UtcDay, - window_anchor: UtcDay, - }, - BeforeReturnCohort { - observed_day: UtcDay, - cohort_anchor: UtcDay, - }, -} - -impl fmt::Display for SessionObservationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::BeforeIdentifierWindow { - observed_day, - window_anchor, - } => write!( - formatter, - "observed day {observed_day} precedes the identifier-window anchor {window_anchor}" - ), - Self::BeforeReturnCohort { - observed_day, - cohort_anchor, - } => write!( - formatter, - "observed session day {observed_day} precedes the return-cohort anchor {cohort_anchor}" - ), - } - } -} - -impl std::error::Error for SessionObservationError {} - /// Stable identity material and the dates that define its rotation windows. #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -344,19 +106,14 @@ mod tests { use chrono::NaiveDate; - use super::{ - CohortDay, IdentifierWindowUpdate, IdentityKey, ReturnCohortUpdate, TelemetryStateV1, - UtcDay, - }; + use super::{IdentityKey, TelemetryStateV1}; + use crate::telemetry::schema::UtcDay; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const GENERATED_KEY_BYTE: u8 = 0x42; /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; - #[derive(Debug, PartialEq, Eq)] - struct TestKeySourceError; - fn day(year: i32, month: u32, day: u32) -> UtcDay { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } @@ -376,12 +133,8 @@ mod tests { } 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" + "version = 1\n\n[identity]\nkey = \"{key}\"\nidentifier-window-anchor = \"2026-09-10\"\n" ) } @@ -432,53 +185,6 @@ mod tests { assert_eq!(serialized, expected); } - #[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 state_without_an_observed_session_has_no_return_cohort() { let source = state_without_return_cohort(KEY); @@ -490,220 +196,6 @@ mod tests { assert_eq!(serialized, source); } - #[test] - fn observations_on_days_zero_through_twenty_nine_keep_the_window() { - let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let anchor = day(2026, 9, 10); - - for observed_day in [anchor, day(2026, 10, 9)] { - let observation = state.observe_session(observed_day).unwrap(); - - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Current { anchor } - ); - assert_eq!(observation.identifier_window.anchor(), anchor); - assert_eq!(state.identity.identifier_window_anchor, anchor); - } - } - - #[test] - fn observation_on_day_thirty_advances_the_window() { - let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 10, 10); - - let observation = state.observe_session(observed_day).unwrap(); - - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Advanced { - anchor: observed_day - } - ); - assert_eq!(observation.return_cohort.day(), CohortDay::D30); - assert_eq!(state.identity.identifier_window_anchor, observed_day); - } - - #[test] - fn observation_after_inactivity_anchors_both_lifecycles_to_the_observation() { - let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 10, 25); - - let observation = state.observe_session(observed_day).unwrap(); - - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Advanced { - anchor: observed_day - } - ); - assert_eq!( - observation.return_cohort, - ReturnCohortUpdate::Started { - anchor: observed_day - } - ); - assert_eq!(state.identity.identifier_window_anchor, observed_day); - assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); - } - - #[test] - fn window_rollover_preserves_the_key_and_return_cohort() { - let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - - let observation = state.observe_session(day(2026, 10, 10)).unwrap(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); - assert!(matches!( - observation.identifier_window, - IdentifierWindowUpdate::Advanced { .. } - )); - assert_eq!(serialized, expected); - } - - #[test] - fn observation_before_the_window_anchor_is_rejected_without_mutation() { - let source = state_with_return_cohort(KEY); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - - let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - assert_eq!( - error.to_string(), - "observed day 2026-09-09 precedes the identifier-window anchor 2026-09-10" - ); - assert_eq!(serialized, source); - } - - #[test] - fn first_observed_session_starts_d0() { - let source = state_without_return_cohort(KEY); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 9, 10); - - let observation = state.observe_session(observed_day).unwrap(); - - assert_eq!(observation.return_cohort.day(), CohortDay::D0); - assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); - } - - #[test] - fn first_session_after_window_expiry_starts_d0_and_advances_the_window() { - let source = state_without_return_cohort(KEY); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 10, 25); - - let observation = state.observe_session(observed_day).unwrap(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Advanced { - anchor: observed_day - } - ); - assert_eq!( - observation.return_cohort, - ReturnCohortUpdate::Started { - anchor: observed_day - } - ); - assert_eq!(serialized, expected); - } - - #[test] - fn observations_through_d30_keep_the_existing_cohort() { - let anchor = day(2026, 8, 11); - - for (window_anchor, observed_day, expected_day) in [ - ("2026-08-11", day(2026, 8, 11), 0_i64), - ("2026-08-11", day(2026, 8, 12), 1), - ("2026-09-01", day(2026, 9, 10), 30), - ] { - let source = state_with_anchors(KEY, window_anchor, "2026-08-11"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - - let observation = state.observe_session(observed_day).unwrap(); - - assert_eq!( - observation.return_cohort.day(), - CohortDay::try_from(expected_day).unwrap() - ); - assert!(matches!( - observation.identifier_window, - IdentifierWindowUpdate::Current { .. } - )); - assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); - } - } - - #[test] - fn cohort_rollover_preserves_the_identifier_window_and_key() { - let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 9, 11); - - let observation = state.observe_session(observed_day).unwrap(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Current { - anchor: day(2026, 9, 1) - } - ); - assert_eq!(observation.return_cohort.day(), CohortDay::D0); - assert_eq!(serialized, expected); - } - - #[test] - fn both_session_lifecycles_roll_over_in_one_transition() { - let source = state_with_anchors(KEY, "2026-08-12", "2026-08-11"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observed_day = day(2026, 9, 11); - - let observation = state.observe_session(observed_day).unwrap(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); - assert_eq!( - observation.identifier_window, - IdentifierWindowUpdate::Advanced { - anchor: observed_day - } - ); - assert_eq!( - observation.return_cohort, - ReturnCohortUpdate::Started { - anchor: observed_day - } - ); - assert_eq!(serialized, expected); - } - - #[test] - fn invalid_cohort_day_does_not_partially_advance_the_window() { - let source = state_with_anchors(KEY, "2026-08-01", "2026-09-10"); - let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - - let error = state.observe_session(day(2026, 9, 9)).unwrap_err(); - let serialized = toml::to_string_pretty(&state).unwrap(); - - assert_eq!( - error.to_string(), - "observed session day 2026-09-09 precedes the return-cohort anchor 2026-09-10" - ); - assert_eq!(serialized, source); - } - #[test] fn future_state_version_is_rejected() { let source = state_with_return_cohort(KEY).replacen("version = 1", "version = 2", 1); From cfeba87d055d650b91070b05367f90c87720b11a Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 17:37:08 +0300 Subject: [PATCH 18/30] Move telemetry schema into a module directory Prepare the schema for event-family modules without changing its behavior. Adjust the contract fixture path for the file's new directory depth. --- src/telemetry/{schema.rs => schema/mod.rs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/telemetry/{schema.rs => schema/mod.rs} (99%) diff --git a/src/telemetry/schema.rs b/src/telemetry/schema/mod.rs similarity index 99% rename from src/telemetry/schema.rs rename to src/telemetry/schema/mod.rs index eb89530d..c0200fb1 100644 --- a/src/telemetry/schema.rs +++ b/src/telemetry/schema/mod.rs @@ -379,7 +379,7 @@ mod tests { use super::*; const RECORDED_DATA: &str = - include_str!("../../md/rfds/telemetry-recording/contract/recorded-data.md"); + include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); fn example_row(requested_kind: &str) -> &'static str { let (_, after_fence) = RECORDED_DATA From b46a62e894088fd677f2736602281642fa4f2dc2 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 18:00:35 +0300 Subject: [PATCH 19/30] Define agent telemetry values Use closed enums for hook agents, platforms, and session starts. Pin serialized names to the version-one contract and reject unknown values. --- src/telemetry/schema/agent.rs | 133 ++++++++++++++++++++++++++++++++++ src/telemetry/schema/mod.rs | 3 + 2 files changed, 136 insertions(+) create mode 100644 src/telemetry/schema/agent.rs diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs new file mode 100644 index 00000000..03e84676 --- /dev/null +++ b/src/telemetry/schema/agent.rs @@ -0,0 +1,133 @@ +//! Closed vocabulary shared by agent-originated telemetry rows. + +use serde::{Deserialize, Serialize}; + +/// Agent that invoked a registered Symposium hook. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum HookAgent { + Claude, + Codex, + Copilot, + Gemini, + Kiro, +} + +/// Operating-system class for the running Symposium build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum OperatingSystem { + Linux, + Macos, + Windows, + Other, +} + +/// Architecture class for the running Symposium build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum Architecture { + X86_64, + Aarch64, + Other, +} + +/// Agent-supplied classification of how a session began. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum SessionStartKind { + Fresh, + Resumed, + Unknown, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hook_agents_round_trip_with_contract_names() { + let cases = [ + (HookAgent::Claude, "claude"), + (HookAgent::Codex, "codex"), + (HookAgent::Copilot, "copilot"), + (HookAgent::Gemini, "gemini"), + (HookAgent::Kiro, "kiro"), + ]; + + for (agent, name) in cases { + let json = serde_json::to_string(&agent).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, agent); + } + } + + #[test] + fn operating_systems_round_trip_with_contract_names() { + let cases = [ + (OperatingSystem::Linux, "linux"), + (OperatingSystem::Macos, "macos"), + (OperatingSystem::Windows, "windows"), + (OperatingSystem::Other, "other"), + ]; + + for (operating_system, name) in cases { + let json = serde_json::to_string(&operating_system).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, operating_system); + } + } + + #[test] + fn architectures_round_trip_with_contract_names() { + let cases = [ + (Architecture::X86_64, "x86_64"), + (Architecture::Aarch64, "aarch64"), + (Architecture::Other, "other"), + ]; + + for (architecture, name) in cases { + let json = serde_json::to_string(&architecture).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, architecture); + } + } + + #[test] + fn session_start_kinds_round_trip_with_contract_names() { + let cases = [ + (SessionStartKind::Fresh, "fresh"), + (SessionStartKind::Resumed, "resumed"), + (SessionStartKind::Unknown, "unknown"), + ]; + + for (start_kind, name) in cases { + let json = serde_json::to_string(&start_kind).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, start_kind); + } + } + + #[test] + fn agent_vocabulary_rejects_unknown_contract_names() { + let unknown = r#""future_value""#; + + let 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!(hook_agent.is_err()); + assert!(operating_system.is_err()); + assert!(architecture.is_err()); + assert!(start_kind.is_err()); + } +} diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index c0200fb1..e27b59fc 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -3,6 +3,9 @@ not(test), expect(dead_code, reason = "the new schema is built before storage uses it.") )] + +mod agent; + use std::{fmt, num::NonZeroU64, sync::LazyLock}; use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; From 9db395de1925dd8a6c4d9ac1ee5f5af16eb8ea1f Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 19:32:55 +0300 Subject: [PATCH 20/30] Add session start telemetry rows Define the agent, platform, and start values recorded for a session. Read session-start rows through the versioned classifier and test them against the JSONL contract. Document that platform values describe the binary's compilation target rather than its physical host. --- md/design/module-structure.md | 2 +- md/rfds/telemetry-recording/README.md | 1 + src/telemetry/schema/agent.rs | 219 +++++++++++++++++++++++++- src/telemetry/schema/mod.rs | 62 +++++++- 4 files changed, 274 insertions(+), 10 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 35a080fc..9caedbd3 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -158,7 +158,7 @@ Manages `state.toml` in the config directory. Deserializes through `RawState` an 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 `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. +The replacement recording contract is being built behind private `schema`, `identity`, and `state` submodules before it is connected to production callers. Within `schema`, `mod.rs` owns shared row primitives and version dispatch, while `agent.rs` owns agent vocabulary and agent-originated rows. Within `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/md/rfds/telemetry-recording/README.md b/md/rfds/telemetry-recording/README.md index 43cd772f..7e6cf712 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -365,6 +365,7 @@ 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. diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 03e84676..68221abb 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -2,10 +2,19 @@ use serde::{Deserialize, Serialize}; +use super::{ + CohortDay, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, + deserialize_version_one, +}; +use crate::telemetry::identity::{RetentionSubject, SessionId}; + /// 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(super) enum HookAgent { +pub(in crate::telemetry) enum HookAgent { Claude, Codex, Copilot, @@ -16,35 +25,145 @@ pub(super) enum HookAgent { /// Operating-system class for the running Symposium build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(super) enum OperatingSystem { +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(super) enum Architecture { +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(super) enum SessionStartKind { +pub(in crate::telemetry) enum SessionStartKind { Fresh, Resumed, Unknown, } +/// Agent-supplied and derived fields for one completed session-start hook. +/// +/// These fields are repeated on [`SessionStartV1`] because flattening this +/// struct into the row would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct SessionStartFields { + pub(in crate::telemetry) agent: HookAgent, + pub(in crate::telemetry) os: OperatingSystem, + pub(in crate::telemetry) arch: Architecture, + pub(in crate::telemetry) start: SessionStartKind, + pub(in crate::telemetry) session_id: Option, + pub(in crate::telemetry) retention_subject: RetentionSubject, + pub(in crate::telemetry) cohort_day: CohortDay, +} + +/// Version 1 record of a completed registered session-start hook. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct SessionStartV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + agent: HookAgent, + os: OperatingSystem, + arch: Architecture, + start: SessionStartKind, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + retention_subject: RetentionSubject, + cohort_day: CohortDay, +} + +impl SessionStartV1 { + /// Create a record for a completed registered session-start hook. + #[must_use] + pub(in crate::telemetry) fn new(at: UtcSecond, fields: SessionStartFields) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::SessionStart, + event_id: EventId::new(), + day: at.day(), + at, + symposium: SymposiumVersion::current(), + agent: fields.agent, + os: fields.os, + arch: fields.arch, + start: fields.start, + session_id: fields.session_id, + retention_subject: fields.retention_subject, + cohort_day: fields.cohort_day, + } + } +} + #[cfg(test)] mod tests { + use chrono::{TimeZone, Utc}; + + use super::super::{RowClassification, TelemetryRow, classify_row}; use super::*; + fn session_start_fields(session_id: Option) -> SessionStartFields { + SessionStartFields { + agent: HookAgent::Claude, + os: OperatingSystem::Linux, + arch: Architecture::X86_64, + start: SessionStartKind::Fresh, + session_id, + retention_subject: "ret_74ddf26f80ad8b58de7f03e6c632e654".parse().unwrap(), + cohort_day: CohortDay::D0, + } + } + + fn session_start_time() -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 9, 14, 2).unwrap()) + } + #[test] fn hook_agents_round_trip_with_contract_names() { let cases = [ @@ -82,6 +201,34 @@ mod tests { } } + #[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 = [ @@ -99,6 +246,31 @@ mod tests { } } + #[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 = [ @@ -130,4 +302,43 @@ mod tests { assert!(architecture.is_err()); assert!(start_kind.is_err()); } + + #[test] + fn new_session_start_uses_fixed_common_fields_and_timestamp_day() { + let at = session_start_time(); + let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); + let fields = session_start_fields(Some(session_id)); + + let row = SessionStartV1::new(at, fields); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::SessionStart); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, at.day()); + assert_eq!(row.at, at); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.agent, fields.agent); + assert_eq!(row.os, fields.os); + assert_eq!(row.arch, fields.arch); + assert_eq!(row.start, fields.start); + assert_eq!(row.session_id, fields.session_id); + assert_eq!(row.retention_subject, fields.retention_subject); + assert_eq!(row.cohort_day, fields.cohort_day); + } + + #[test] + fn session_start_without_session_id_classifies_and_round_trips() { + let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + + let json = serde_json::to_string(&row).unwrap(); + let value = serde_json::from_str::(&json).unwrap(); + let RowClassification::Supported(TelemetryRow::SessionStart(decoded)) = classify_row(&json) + else { + panic!("session_start without a session id was not classified as supported"); + }; + + assert_eq!(value.get("session_id"), None); + assert!(decoded.session_id.is_none()); + assert_eq!(serde_json::to_string(&decoded).unwrap(), json); + } } diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index e27b59fc..c44ed45a 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -10,9 +10,14 @@ use std::{fmt, num::NonZeroU64, sync::LazyLock}; use chrono::{DateTime, NaiveDate, SecondsFormat, Timelike, Utc}; use semver::Version; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{DeserializeOwned, Error as _}, +}; use uuid::Uuid; +use agent::SessionStartV1; + /// Random identifier for one telemetry row. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -63,6 +68,7 @@ pub(super) enum RowClassification { /// Telemetry row understood by this version of Symposium. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum TelemetryRow { + SessionStart(SessionStartV1), StorageLimit(StorageLimitV1), } @@ -72,6 +78,7 @@ impl Serialize for TelemetryRow { S: Serializer, { match self { + Self::SessionStart(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -363,20 +370,32 @@ pub(super) enum DroppedOperation { } /// 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) { - ("storage_limit", 1) => match serde_json::from_str(line) { - Ok(row) => RowClassification::Supported(TelemetryRow::StorageLimit(row)), - Err(_) => RowClassification::Invalid, - }, + ("session_start", 1) => deserialize_supported_row(line, TelemetryRow::SessionStart), + ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } } +fn deserialize_supported_row(line: &str, wrap: fn(T) -> TelemetryRow) -> RowClassification +where + T: DeserializeOwned, +{ + match serde_json::from_str(line) { + Ok(row) => RowClassification::Supported(wrap(row)), + Err(_) => RowClassification::Invalid, + } +} + #[cfg(test)] mod tests { use super::*; @@ -708,6 +727,39 @@ mod tests { 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 new_storage_limit_uses_fixed_common_fields() { let day = UtcDay(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); From 8e1c821d63298ab9cdc87045d99975a5ed533c72 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 20:12:49 +0300 Subject: [PATCH 21/30] Define configuration telemetry agents Keep the seven-agent configuration list separate from the smaller hook agent list. Convert every project agent explicitly so future agent support requires a deliberate telemetry update. Pin each version-one serialized name, including the opencode spelling. --- src/telemetry/schema/agent.rs | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 68221abb..343d6c2f 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -6,7 +6,38 @@ use super::{ CohortDay, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, deserialize_version_one, }; -use crate::telemetry::identity::{RetentionSubject, SessionId}; +use crate::{ + agents::Agent, + telemetry::identity::{RetentionSubject, SessionId}, +}; + +/// Agent included in the daily configuration snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum SupportedAgent { + Claude, + Codex, + Copilot, + Gemini, + Kiro, + #[serde(rename = "opencode")] + OpenCode, + Goose, +} + +impl From for SupportedAgent { + fn from(agent: Agent) -> Self { + match agent { + Agent::Claude => Self::Claude, + Agent::Codex => Self::Codex, + Agent::Copilot => Self::Copilot, + Agent::Gemini => Self::Gemini, + Agent::Kiro => Self::Kiro, + Agent::OpenCode => Self::OpenCode, + Agent::Goose => Self::Goose, + } + } +} /// Agent that invoked a registered Symposium hook. /// @@ -183,6 +214,44 @@ mod tests { } } + #[test] + fn supported_agents_round_trip_with_contract_names() { + let cases = [ + (SupportedAgent::Claude, "claude"), + (SupportedAgent::Codex, "codex"), + (SupportedAgent::Copilot, "copilot"), + (SupportedAgent::Gemini, "gemini"), + (SupportedAgent::Kiro, "kiro"), + (SupportedAgent::OpenCode, "opencode"), + (SupportedAgent::Goose, "goose"), + ]; + + for (agent, name) in cases { + let json = serde_json::to_string(&agent).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, agent); + } + } + + #[test] + fn project_agents_convert_to_supported_telemetry_agents() { + let cases = [ + (Agent::Claude, SupportedAgent::Claude), + (Agent::Codex, SupportedAgent::Codex), + (Agent::Copilot, SupportedAgent::Copilot), + (Agent::Gemini, SupportedAgent::Gemini), + (Agent::Kiro, SupportedAgent::Kiro), + (Agent::OpenCode, SupportedAgent::OpenCode), + (Agent::Goose, SupportedAgent::Goose), + ]; + + for (agent, expected) in cases { + assert_eq!(SupportedAgent::from(agent), expected); + } + } + #[test] fn operating_systems_round_trip_with_contract_names() { let cases = [ @@ -292,11 +361,13 @@ mod tests { 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()); From 79cc0b2115371a621eb9d66be868c0328c152903 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 20:40:17 +0300 Subject: [PATCH 22/30] Define agent configuration rows Add the version-one row for one entry in the daily agent configuration snapshot. Keep fixed fields inside the constructor and test every value without wiring storage or production recording yet. --- src/telemetry/schema/agent.rs | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 343d6c2f..8532b52b 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -8,7 +8,7 @@ use super::{ }; use crate::{ agents::Agent, - telemetry::identity::{RetentionSubject, SessionId}, + telemetry::identity::{AgentSubject, RetentionSubject, SessionId}, }; /// Agent included in the daily configuration snapshot. @@ -172,9 +172,52 @@ impl SessionStartV1 { } } +/// Version 1 daily observation of one supported agent's configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct AgentConfigurationV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + agent: SupportedAgent, + configured: bool, + os: OperatingSystem, + arch: Architecture, + agent_subject: AgentSubject, +} + +impl AgentConfigurationV1 { + /// Create one agent entry in a daily configuration snapshot. + #[must_use] + pub(in crate::telemetry) fn new( + day: UtcDay, + agent: SupportedAgent, + configured: bool, + os: OperatingSystem, + arch: Architecture, + agent_subject: AgentSubject, + ) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::AgentConfiguration, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + agent, + configured, + os, + arch, + agent_subject, + } + } +} + #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; + use chrono::{NaiveDate, TimeZone, Utc}; use super::super::{RowClassification, TelemetryRow, classify_row}; use super::*; @@ -397,6 +440,32 @@ mod tests { assert_eq!(row.cohort_day, fields.cohort_day); } + #[test] + fn new_agent_configuration_uses_fixed_common_fields() { + let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let agent_subject = "agt_9255770e1679cb789796a9f9e86325c5".parse().unwrap(); + + let row = AgentConfigurationV1::new( + day, + SupportedAgent::Claude, + true, + OperatingSystem::Linux, + Architecture::X86_64, + agent_subject, + ); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::AgentConfiguration); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.agent, SupportedAgent::Claude); + assert!(row.configured); + assert_eq!(row.os, OperatingSystem::Linux); + assert_eq!(row.arch, Architecture::X86_64); + assert_eq!(row.agent_subject, agent_subject); + } + #[test] fn session_start_without_session_id_classifies_and_round_trips() { let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); From 6bc19c14c085128d4f3435afa54edd26d5b9a81e Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Thu, 10 Sep 2026 20:50:59 +0300 Subject: [PATCH 23/30] Read agent configuration rows Recognize version-one agent configuration rows through the shared envelope classifier. Keep future versions separate from invalid rows and pin serialization to the JSONL contract example. --- src/telemetry/schema/mod.rs | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index c44ed45a..56f96982 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -16,7 +16,7 @@ use serde::{ }; use uuid::Uuid; -use agent::SessionStartV1; +use agent::{AgentConfigurationV1, SessionStartV1}; /// Random identifier for one telemetry row. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -69,6 +69,7 @@ pub(super) enum RowClassification { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum TelemetryRow { SessionStart(SessionStartV1), + AgentConfiguration(AgentConfigurationV1), StorageLimit(StorageLimitV1), } @@ -79,6 +80,7 @@ impl Serialize for TelemetryRow { { match self { Self::SessionStart(row) => row.serialize(serializer), + Self::AgentConfiguration(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -381,6 +383,9 @@ pub(super) fn classify_row(line: &str) -> RowClassification { 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) + } ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } @@ -760,6 +765,37 @@ mod tests { 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 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()); From d577413f3280381c6b1999efaf19803565931100 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 09:55:59 +0300 Subject: [PATCH 24/30] Keep agent telemetry names aligned Link hook agents to the broader supported-agent vocabulary so rows that refer to the same agent cannot drift apart. Group per-agent configuration values at construction sites, making the configured flag explicit and preparing for the daily batch builder. --- src/telemetry/schema/agent.rs | 76 +++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 8532b52b..bab813b3 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -53,6 +53,18 @@ pub(in crate::telemetry) enum HookAgent { Kiro, } +impl From for SupportedAgent { + fn from(agent: HookAgent) -> Self { + match agent { + HookAgent::Claude => Self::Claude, + HookAgent::Codex => Self::Codex, + HookAgent::Copilot => Self::Copilot, + HookAgent::Gemini => Self::Gemini, + HookAgent::Kiro => Self::Kiro, + } + } +} + /// Operating-system class for the running Symposium build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -172,6 +184,17 @@ impl SessionStartV1 { } } +/// Fields that vary for each entry in a daily agent configuration snapshot. +/// +/// These fields are repeated on [`AgentConfigurationV1`] because flattening +/// this struct into the row would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct AgentConfigurationFields { + pub(in crate::telemetry) agent: SupportedAgent, + pub(in crate::telemetry) configured: bool, + pub(in crate::telemetry) agent_subject: AgentSubject, +} + /// Version 1 daily observation of one supported agent's configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -194,11 +217,9 @@ impl AgentConfigurationV1 { #[must_use] pub(in crate::telemetry) fn new( day: UtcDay, - agent: SupportedAgent, - configured: bool, os: OperatingSystem, arch: Architecture, - agent_subject: AgentSubject, + fields: AgentConfigurationFields, ) -> Self { Self { version: SchemaVersion::V1, @@ -206,11 +227,11 @@ impl AgentConfigurationV1 { event_id: EventId::new(), day, symposium: SymposiumVersion::current(), - agent, - configured, + agent: fields.agent, + configured: fields.configured, os, arch, - agent_subject, + agent_subject: fields.agent_subject, } } } @@ -257,6 +278,24 @@ mod tests { } } + #[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 = [ @@ -279,19 +318,12 @@ mod tests { } #[test] - fn project_agents_convert_to_supported_telemetry_agents() { - let cases = [ - (Agent::Claude, SupportedAgent::Claude), - (Agent::Codex, SupportedAgent::Codex), - (Agent::Copilot, SupportedAgent::Copilot), - (Agent::Gemini, SupportedAgent::Gemini), - (Agent::Kiro, SupportedAgent::Kiro), - (Agent::OpenCode, SupportedAgent::OpenCode), - (Agent::Goose, SupportedAgent::Goose), - ]; + 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()); - for (agent, expected) in cases { - assert_eq!(SupportedAgent::from(agent), expected); + assert_eq!(telemetry_name, config_name); } } @@ -447,11 +479,13 @@ mod tests { let row = AgentConfigurationV1::new( day, - SupportedAgent::Claude, - true, OperatingSystem::Linux, Architecture::X86_64, - agent_subject, + AgentConfigurationFields { + agent: SupportedAgent::Claude, + configured: true, + agent_subject, + }, ); assert_eq!(row.version, SchemaVersion::V1); From 27e981e3156345507e46b99f6f4f32efbb8e7352 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 10:26:22 +0300 Subject: [PATCH 25/30] Define resolution telemetry vocabulary Add the fixed triggers, outcomes, and unnamed-package reasons used by resolution summaries. Keep reason counters private, increment them through checked operations, and align dropped resolution batches with storage-limit reporting. --- src/telemetry/schema/mod.rs | 1 + src/telemetry/schema/resolution.rs | 287 +++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/telemetry/schema/resolution.rs diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 56f96982..c0dcef81 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -5,6 +5,7 @@ )] mod agent; +mod resolution; use std::{fmt, num::NonZeroU64, sync::LazyLock}; diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs new file mode 100644 index 00000000..58396635 --- /dev/null +++ b/src/telemetry/schema/resolution.rs @@ -0,0 +1,287 @@ +//! Vocabulary for resolution telemetry. + +use serde::{Deserialize, Serialize}; + +use super::DroppedOperation; + +/// 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, or return `None` on overflow. + #[must_use = "counter overflow must drop the containing telemetry batch"] + pub(in crate::telemetry) fn checked_record( + &mut self, + reason: UnnamedPackageReason, + ) -> Option<()> { + 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)?; + Some(()) + } + + /// 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn example_reasons() -> UnnamedPackageReasons { + UnnamedPackageReasons { + private_registry: 1, + git: 2, + path: 3, + workspace: 4, + unknown_source: 5, + invalid_coordinate: 6, + } + } + + #[test] + fn resolution_triggers_round_trip_and_match_storage_names() { + let cases = [ + (ResolutionTrigger::SessionStart, "session_start"), + (ResolutionTrigger::ManualSync, "manual_sync"), + (ResolutionTrigger::Use, "use"), + (ResolutionTrigger::Remove, "remove"), + ]; + + for (trigger, name) in cases { + let json = serde_json::to_string(&trigger).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + let dropped_operation = + serde_json::to_string(&DroppedOperation::from(trigger)).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, trigger); + assert_eq!(dropped_operation, json); + } + } + + #[test] + fn resolution_outcomes_round_trip_with_contract_names() { + let cases = [ + (ResolutionOutcome::Ok, "ok"), + (ResolutionOutcome::Partial, "partial"), + (ResolutionOutcome::Error, "error"), + ]; + + for (outcome, name) in cases { + let json = serde_json::to_string(&outcome).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, outcome); + } + } + + #[test] + fn 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, Some(())); + 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, None); + 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); + } +} From 32890dd3533df2b3a212b0a30d11aca824dbace7 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 11:00:34 +0300 Subject: [PATCH 26/30] Validate resolution summary telemetry Build resolution summary rows from their reason counters so the stored unnamed-package total cannot drift from its breakdown. Apply the same validation when reading JSON, and reject overflow or missing fields before invalid data reaches analysis. --- src/telemetry/schema/mod.rs | 10 + src/telemetry/schema/resolution.rs | 387 ++++++++++++++++++++++++++++- 2 files changed, 389 insertions(+), 8 deletions(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index c0dcef81..e914caf4 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -404,6 +404,7 @@ where #[cfg(test)] mod tests { + use super::resolution::ResolutionSummaryV1; use super::*; const RECORDED_DATA: &str = @@ -777,6 +778,15 @@ mod tests { assert_eq!(serde_json::to_string(&row).unwrap(), example); } + #[test] + fn resolution_summary_example_round_trips() { + let example = example_row("resolution_summary"); + + let row = serde_json::from_str::(example).unwrap(); + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + #[test] fn unsupported_agent_configuration_version_is_unknown_schema() { let example = example_row("agent_configuration"); diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 58396635..74b13e91 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -1,8 +1,14 @@ -//! Vocabulary for resolution telemetry. +//! Schema types for resolution telemetry. + +use std::fmt; use serde::{Deserialize, Serialize}; -use super::DroppedOperation; +use super::{ + DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, + deserialize_version_one, +}; +use crate::telemetry::identity::SessionId; /// Operation that caused a full resolution and sync. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -61,12 +67,17 @@ pub(in crate::telemetry) struct UnnamedPackageReasons { } impl UnnamedPackageReasons { - /// Increment exactly one reason counter, or return `None` on overflow. + /// 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, - ) -> Option<()> { + ) -> Result<(), ResolutionSummaryError> { let counter = match reason { UnnamedPackageReason::PrivateRegistry => &mut self.private_registry, UnnamedPackageReason::Git => &mut self.git, @@ -76,8 +87,10 @@ impl UnnamedPackageReasons { UnnamedPackageReason::InvalidCoordinate => &mut self.invalid_coordinate, }; - *counter = counter.checked_add(1)?; - Some(()) + *counter = counter + .checked_add(1) + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + Ok(()) } /// Return the total number of unnamed packages, or `None` on overflow. @@ -96,8 +109,180 @@ impl UnnamedPackageReasons { } } +/// Fields supplied by a completed full resolution and sync. +/// +/// These fields are repeated on [`ResolutionSummaryV1`] because flattening +/// this constructor input would weaken strict unknown-field rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct ResolutionSummaryFields { + pub(in crate::telemetry) trigger: ResolutionTrigger, + pub(in crate::telemetry) outcome: ResolutionOutcome, + pub(in crate::telemetry) duration_ms: u64, + pub(in crate::telemetry) public_packages: u64, + pub(in crate::telemetry) unnamed_package_reasons: UnnamedPackageReasons, + pub(in crate::telemetry) plugins: u64, + pub(in crate::telemetry) skills: u64, + pub(in crate::telemetry) installed: u64, + pub(in crate::telemetry) updated: u64, + pub(in crate::telemetry) reaped: u64, + pub(in crate::telemetry) session_id: Option, +} + +/// Version 1 summary of one completed full resolution and sync. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawResolutionSummaryV1")] +pub(in crate::telemetry) struct ResolutionSummaryV1 { + #[serde(rename = "v")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + trigger: ResolutionTrigger, + outcome: ResolutionOutcome, + duration_ms: u64, + public_packages: u64, + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + plugins: u64, + skills: u64, + installed: u64, + updated: u64, + reaped: u64, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, +} + +impl ResolutionSummaryV1 { + /// Create a summary for one completed full resolution and sync. + /// + /// # Errors + /// + /// Returns [`ResolutionSummaryError::UnnamedPackageCountOverflow`] when + /// the unnamed-package reason counters cannot be represented by `u64`. + pub(in crate::telemetry) fn new( + day: UtcDay, + fields: ResolutionSummaryFields, + ) -> Result { + let unnamed_packages = fields + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + Ok(Self { + version: SchemaVersion::V1, + kind: RowKind::ResolutionSummary, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + trigger: fields.trigger, + outcome: fields.outcome, + duration_ms: fields.duration_ms, + public_packages: fields.public_packages, + unnamed_packages, + unnamed_package_reasons: fields.unnamed_package_reasons, + plugins: fields.plugins, + skills: fields.skills, + installed: fields.installed, + updated: fields.updated, + reaped: fields.reaped, + session_id: fields.session_id, + }) + } +} + +/// Invalid relationship between fields in a resolution summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum ResolutionSummaryError { + UnnamedPackageCountOverflow, + UnnamedPackageCountMismatch { stored: u64, derived: u64 }, +} + +impl fmt::Display for ResolutionSummaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnnamedPackageCountOverflow => { + formatter.write_str("unnamed package reason counters overflow u64") + } + Self::UnnamedPackageCountMismatch { stored, derived } => write!( + formatter, + "stored unnamed package count {stored} does not match derived reason count {derived}" + ), + } + } +} + +impl std::error::Error for ResolutionSummaryError {} + +/// Strict wire representation validated before becoming a resolution summary. +/// +/// Serde's `try_from` deserializes this type rather than the outer row, so its +/// version and unknown-field checks are deliberately declared here. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawResolutionSummaryV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + trigger: ResolutionTrigger, + outcome: ResolutionOutcome, + duration_ms: u64, + public_packages: u64, + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + plugins: u64, + skills: u64, + installed: u64, + updated: u64, + reaped: u64, + session_id: Option, +} + +impl TryFrom for ResolutionSummaryV1 { + type Error = ResolutionSummaryError; + + fn try_from(raw: RawResolutionSummaryV1) -> Result { + let derived = raw + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + if raw.unnamed_packages != derived { + return Err(ResolutionSummaryError::UnnamedPackageCountMismatch { + stored: raw.unnamed_packages, + derived, + }); + } + + Ok(Self { + version: raw.version, + kind: raw.kind, + event_id: raw.event_id, + day: raw.day, + symposium: raw.symposium, + trigger: raw.trigger, + outcome: raw.outcome, + duration_ms: raw.duration_ms, + public_packages: raw.public_packages, + unnamed_packages: raw.unnamed_packages, + unnamed_package_reasons: raw.unnamed_package_reasons, + plugins: raw.plugins, + skills: raw.skills, + installed: raw.installed, + updated: raw.updated, + reaped: raw.reaped, + session_id: raw.session_id, + }) + } +} + #[cfg(test)] mod tests { + use chrono::NaiveDate; + use super::*; fn example_reasons() -> UnnamedPackageReasons { @@ -111,6 +296,189 @@ mod tests { } } + fn summary_day() -> UtcDay { + UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()) + } + + fn summary_fields(session_id: Option) -> ResolutionSummaryFields { + ResolutionSummaryFields { + trigger: ResolutionTrigger::SessionStart, + outcome: ResolutionOutcome::Ok, + duration_ms: 142, + public_packages: 2, + unnamed_package_reasons: UnnamedPackageReasons { + private_registry: 1, + ..UnnamedPackageReasons::default() + }, + plugins: 1, + skills: 1, + installed: 2, + updated: 0, + reaped: 0, + session_id, + } + } + + fn raw_summary( + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + ) -> RawResolutionSummaryV1 { + RawResolutionSummaryV1 { + version: SchemaVersion::V1, + kind: RowKind::ResolutionSummary, + event_id: EventId::new(), + day: summary_day(), + symposium: SymposiumVersion::current(), + trigger: ResolutionTrigger::SessionStart, + outcome: ResolutionOutcome::Ok, + duration_ms: 142, + public_packages: 2, + unnamed_packages, + unnamed_package_reasons, + plugins: 1, + skills: 1, + installed: 2, + updated: 0, + reaped: 0, + session_id: None, + } + } + + #[test] + fn new_resolution_summary_derives_fixed_fields_and_unnamed_total() { + let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); + let fields = summary_fields(Some(session_id)); + + let row = ResolutionSummaryV1::new(summary_day(), fields).unwrap(); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::ResolutionSummary); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, summary_day()); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.trigger, fields.trigger); + assert_eq!(row.outcome, fields.outcome); + assert_eq!(row.duration_ms, fields.duration_ms); + assert_eq!(row.public_packages, fields.public_packages); + assert_eq!(row.unnamed_packages, 1); + assert_eq!(row.unnamed_package_reasons, fields.unnamed_package_reasons); + assert_eq!(row.plugins, fields.plugins); + assert_eq!(row.skills, fields.skills); + assert_eq!(row.installed, fields.installed); + assert_eq!(row.updated, fields.updated); + assert_eq!(row.reaped, fields.reaped); + assert_eq!(row.session_id, fields.session_id); + } + + #[test] + fn new_resolution_summary_reports_reason_counter_overflow() { + let mut fields = summary_fields(None); + fields.unnamed_package_reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + git: 1, + ..UnnamedPackageReasons::default() + }; + + let result = ResolutionSummaryV1::new(summary_day(), fields); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + } + + #[test] + fn direct_resolution_summary_deserialization_rejects_future_version() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let future = json.replacen(r#""v":1"#, r#""v":2"#, 1); + + let result = serde_json::from_str::(&future); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_rejects_unknown_fields() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let unknown = json.replacen(r#""trigger""#, r#""future_field":true,"trigger""#, 1); + + let result = serde_json::from_str::(&unknown); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_requires_every_top_level_field() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let missing = json.replacen(r#","plugins":1"#, "", 1); + + let result = serde_json::from_str::(&missing); + + assert!(result.is_err()); + } + + #[test] + fn resolution_summary_json_rejects_mismatched_unnamed_count() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let json = serde_json::to_string(&row).unwrap(); + let mismatched = json.replacen(r#""unnamed_packages":1"#, r#""unnamed_packages":2"#, 1); + + let error = serde_json::from_str::(&mismatched).unwrap_err(); + + assert!( + error + .to_string() + .contains("stored unnamed package count 2 does not match derived reason count 1") + ); + } + + #[test] + fn resolution_summary_rejects_mismatched_unnamed_count() { + let raw = raw_summary(20, example_reasons()); + + let result = ResolutionSummaryV1::try_from(raw); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountMismatch { + stored: 20, + derived: 21, + }) + ); + } + + #[test] + fn resolution_summary_validation_reports_reason_counter_overflow() { + let reasons = UnnamedPackageReasons { + private_registry: u64::MAX, + git: 1, + ..UnnamedPackageReasons::default() + }; + let raw = raw_summary(u64::MAX, reasons); + + let result = ResolutionSummaryV1::try_from(raw); + + assert_eq!( + result, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); + } + + #[test] + fn resolution_summary_without_session_id_round_trips_without_the_field() { + let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + + let json = serde_json::to_string(&row).unwrap(); + let value = serde_json::from_str::(&json).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(value.get("session_id"), None); + assert_eq!(decoded, row); + } + #[test] fn resolution_triggers_round_trip_and_match_storage_names() { let cases = [ @@ -253,7 +621,7 @@ mod tests { let recorded = reasons.checked_record(reason); - assert_eq!(recorded, Some(())); + assert_eq!(recorded, Ok(())); assert_eq!(reasons, expected); } } @@ -268,7 +636,10 @@ mod tests { let recorded = reasons.checked_record(UnnamedPackageReason::PrivateRegistry); - assert_eq!(recorded, None); + assert_eq!( + recorded, + Err(ResolutionSummaryError::UnnamedPackageCountOverflow) + ); assert_eq!(reasons, before); } From 18dc2dbcd46a346bc19dd7bcf54c7db62fca21fc Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 11:12:12 +0300 Subject: [PATCH 27/30] Read resolution summaries through the classifier Teach the shared JSONL reader to recognize version one resolution summaries and serialize them through TelemetryRow. Keep future versions distinct from invalid supported rows, including summaries with unknown fields or inconsistent package counts. --- src/telemetry/schema/mod.rs | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index e914caf4..149b2f21 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -18,6 +18,7 @@ use serde::{ use uuid::Uuid; use agent::{AgentConfigurationV1, SessionStartV1}; +use resolution::ResolutionSummaryV1; /// Random identifier for one telemetry row. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -71,6 +72,7 @@ pub(super) enum RowClassification { pub(super) enum TelemetryRow { SessionStart(SessionStartV1), AgentConfiguration(AgentConfigurationV1), + ResolutionSummary(ResolutionSummaryV1), StorageLimit(StorageLimitV1), } @@ -82,6 +84,7 @@ impl Serialize for TelemetryRow { match self { Self::SessionStart(row) => row.serialize(serializer), Self::AgentConfiguration(row) => row.serialize(serializer), + Self::ResolutionSummary(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -387,6 +390,9 @@ pub(super) fn classify_row(line: &str) -> RowClassification { ("agent_configuration", 1) => { deserialize_supported_row(line, TelemetryRow::AgentConfiguration) } + ("resolution_summary", 1) => { + deserialize_supported_row(line, TelemetryRow::ResolutionSummary) + } ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } @@ -404,7 +410,6 @@ where #[cfg(test)] mod tests { - use super::resolution::ResolutionSummaryV1; use super::*; const RECORDED_DATA: &str = @@ -782,11 +787,43 @@ mod tests { fn resolution_summary_example_round_trips() { let example = example_row("resolution_summary"); - let row = serde_json::from_str::(example).unwrap(); + let RowClassification::Supported(row) = classify_row(example) else { + panic!("resolution_summary contract example was not classified as supported"); + }; assert_eq!(serde_json::to_string(&row).unwrap(), example); } + #[test] + fn unsupported_resolution_summary_version_is_unknown_schema() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn resolution_summary_with_unknown_field_is_invalid() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""trigger""#, r#""future_field":true,"trigger""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn resolution_summary_with_mismatched_unnamed_count_is_invalid() { + let example = example_row("resolution_summary"); + let json = example.replacen(r#""unnamed_packages":1"#, r#""unnamed_packages":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + #[test] fn unsupported_agent_configuration_version_is_unknown_schema() { let example = example_row("agent_configuration"); From 24aef516eab32cd9addbbc4a0999ee450dc03a73 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 11:22:59 +0300 Subject: [PATCH 28/30] Define package resolution vocabulary Add the closed version one package ecosystem and extension match values before introducing the package resolution row. Pin their JSON names and reject unknown values so later schema changes remain explicit. --- src/telemetry/schema/resolution.rs | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 74b13e91..4f6904ce 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -40,6 +40,22 @@ pub(in crate::telemetry) enum ResolutionOutcome { Error, } +/// Public package ecosystem approved for version 1 telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum PackageEcosystem { + Cargo, +} + +/// Kind of extension content contributed by one public package. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ExtensionMatch { + Public, + UnnamedOnly, + None, +} + /// One reason that a package coordinate cannot be named. /// /// The public-identity policy selects this reason after applying source @@ -517,15 +533,49 @@ mod tests { } } + #[test] + fn package_ecosystems_round_trip_with_contract_names() { + let cases = [(PackageEcosystem::Cargo, "cargo")]; + + for (ecosystem, name) in cases { + let json = serde_json::to_string(&ecosystem).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, ecosystem); + } + } + + #[test] + fn extension_matches_round_trip_with_contract_names() { + let cases = [ + (ExtensionMatch::Public, "public"), + (ExtensionMatch::UnnamedOnly, "unnamed_only"), + (ExtensionMatch::None, "none"), + ]; + + for (extension_match, name) in cases { + let json = serde_json::to_string(&extension_match).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(json, format!(r#""{name}""#)); + assert_eq!(decoded, extension_match); + } + } + #[test] fn resolution_vocabulary_rejects_unknown_contract_names() { let unknown = r#""future_value""#; let trigger = serde_json::from_str::(unknown); let outcome = serde_json::from_str::(unknown); + let ecosystem = serde_json::from_str::(unknown); + let extension_match = serde_json::from_str::(unknown); assert!(trigger.is_err()); assert!(outcome.is_err()); + assert!(ecosystem.is_err()); + assert!(extension_match.is_err()); } #[test] From 7b84afbbadd4fb2ee7400cdf1deb219df0e63208 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 21:28:44 +0300 Subject: [PATCH 29/30] pacify merciless fmt --- src/agents/mcp_server_registration.rs | 4 ++- src/agents/mod.rs | 40 +++++++++++++++++---------- 2 files changed, 29 insertions(+), 15 deletions(-) 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" + ); } } } From f21e90fba985e770ffba7cc3e61756d88cbed4c3 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 19:22:59 +0300 Subject: [PATCH 30/30] Validate session start dates during decoding A session start's day is derived from its completion timestamp. Reject stored rows where those values disagree instead of admitting inconsistent data to typed readers. Keep file membership checks with the archive reader, which has the daily file context needed for that separate invariant. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 2 +- src/telemetry/schema/agent.rs | 91 ++++++++++++++++++- src/telemetry/schema/mod.rs | 12 +++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 9caedbd3..f2504fa8 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -158,7 +158,7 @@ Manages `state.toml` in the config directory. Deserializes through `RawState` an Implements the local, opt-in [telemetry](./telemetry.md) event log under `/telemetry/`, one JSONL file per UTC day. Off by default; gated by `[telemetry] enabled`. A `TelemetryEvent` is an `at` timestamp plus a kind-tagged `EventKind` (`session_start` / `user_prompt` / `tool_use`), serialized one per line. `record` / `record_kind` append an event; `roll_off` deletes files older than `RETENTION_DAYS` (30); `read_events` / `recent_events` read them back; `usage` + `status_text` back `telemetry status`; `recent_events` backs `telemetry show`. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The recording entry points are not yet called from the hook pipeline, so no events are produced today even when telemetry is enabled. -The replacement recording contract is being built behind private `schema`, `identity`, and `state` submodules before it is connected to production callers. Within `schema`, `mod.rs` owns shared row primitives and version dispatch, while `agent.rs` owns agent vocabulary and agent-originated rows. Within `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. +The replacement recording contract is being built behind private `schema`, `identity`, and `state` submodules before it is connected to production callers. Within `schema`, `mod.rs` owns shared row primitives and version dispatch, while `agent.rs` owns agent vocabulary and agent-originated rows. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Within `state`, `mod.rs` owns the versioned private-state file shape, while `lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index 2fac2d0c..c8ecfc74 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -16,7 +16,7 @@ Every JSONL row has: | `day` | `2026-08-03` | UTC calendar day. | | `symposium` | `0.4.0` | Symposium version that wrote the event. | -Completed operational events (`session_start` and `command`) also have `at`, an RFC3339 UTC timestamp truncated to one second. Resolution, configuration, and aggregate metric rows have only `day`. +Completed operational events (`session_start` and `command`) also have `at`, an RFC3339 UTC timestamp truncated to one second. Their `day` is the UTC calendar day containing `at`; a mismatch makes the row invalid. Resolution, configuration, and aggregate metric rows have only `day`. Counters and durations are non-negative JSON integers that fit an unsigned 64-bit value. Symposium checks arithmetic and drops an overflowing batch or observation instead of wrapping the value. diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index bab813b3..f93e28d2 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -1,5 +1,7 @@ //! Closed vocabulary shared by agent-originated telemetry rows. +use std::fmt; + use serde::{Deserialize, Serialize}; use super::{ @@ -143,9 +145,9 @@ pub(in crate::telemetry) struct SessionStartFields { /// Version 1 record of a completed registered session-start hook. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(try_from = "RawSessionStartV1")] pub(in crate::telemetry) struct SessionStartV1 { - #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + #[serde(rename = "v")] version: SchemaVersion, kind: RowKind, event_id: EventId, @@ -184,6 +186,78 @@ impl SessionStartV1 { } } +/// Invalid relationship between fields in a session-start row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionStartError { + DayDoesNotMatchTimestamp { stored: UtcDay, timestamp: UtcDay }, +} + +impl fmt::Display for SessionStartError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DayDoesNotMatchTimestamp { stored, timestamp } => write!( + formatter, + "stored session-start day {stored} does not match timestamp day {timestamp}" + ), + } + } +} + +impl std::error::Error for SessionStartError {} + +/// Strict wire representation validated before becoming a session-start row. +/// +/// Serde's `try_from` deserializes this type rather than the outer row, so its +/// version and unknown-field checks are deliberately declared here. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSessionStartV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + agent: HookAgent, + os: OperatingSystem, + arch: Architecture, + start: SessionStartKind, + session_id: Option, + retention_subject: RetentionSubject, + cohort_day: CohortDay, +} + +impl TryFrom for SessionStartV1 { + type Error = SessionStartError; + + fn try_from(raw: RawSessionStartV1) -> Result { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(SessionStartError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); + } + + Ok(Self { + version: raw.version, + kind: raw.kind, + event_id: raw.event_id, + day: raw.day, + at: raw.at, + symposium: raw.symposium, + agent: raw.agent, + os: raw.os, + arch: raw.arch, + start: raw.start, + session_id: raw.session_id, + retention_subject: raw.retention_subject, + cohort_day: raw.cohort_day, + }) + } +} + /// Fields that vary for each entry in a daily agent configuration snapshot. /// /// These fields are repeated on [`AgentConfigurationV1`] because flattening @@ -515,4 +589,17 @@ mod tests { assert!(decoded.session_id.is_none()); assert_eq!(serde_json::to_string(&decoded).unwrap(), json); } + + #[test] + fn session_start_rejects_a_day_that_disagrees_with_its_timestamp() { + let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + let mut value = serde_json::to_value(row).unwrap(); + value["day"] = serde_json::Value::String("2026-08-04".to_owned()); + + let result = serde_json::from_value::(value); + + assert!(result.unwrap_err().to_string().contains( + "stored session-start day 2026-08-04 does not match timestamp day 2026-08-03" + )); + } } diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 149b2f21..7d9aea23 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -772,6 +772,18 @@ mod tests { 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");