From 4762d8a74a4e93dd4c55056d18ec9203de4d854d Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Wed, 2 Sep 2026 11:13:47 +0300 Subject: [PATCH 01/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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"); From 3e4c9f08183c78f72a286efdd803ee7c9bc0743b Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 12:54:28 +0300 Subject: [PATCH 31/57] Define validated public package coordinates Telemetry may name a package only when its resolved coordinate is safe to record. The schema now rejects unsafe names and versions. Document the fixed version 1 grammar and keep package-specific code in its own module. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 6 +- src/telemetry/schema/resolution.rs | 52 +- src/telemetry/schema/resolution/package.rs | 515 ++++++++++++++++++ 4 files changed, 523 insertions(+), 52 deletions(-) create mode 100644 src/telemetry/schema/resolution/package.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index f2504fa8..00096a28 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. 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. +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, `agent.rs` owns agent vocabulary and agent-originated rows, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. 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 c8ecfc74..544f7d0d 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -188,13 +188,17 @@ This row records one eligible public package used as resolution input during a f | Field | Values | Meaning | | ------------------- | ----------------------- | -------------------------------------------------------------------- | | `package.ecosystem` | `cargo` | Stable public ecosystem label. | -| `package.name` | validated string | Public package name. | +| `package.name` | validated string | Public package name using the fixed version 1 grammar. | | `package.version` | validated exact version | Exact resolved public version, never a range or `*`. | | `extension_match` | `public`, `unnamed_only`, `none` | What kind of resolved extension, if any, the package contributed to. | | `package_subject` | scoped id | Deduplicates this exact coordinate for 30 days. | A package is named only when its package manager reports provenance matching a reviewed public-registry allowlist. Registry URLs themselves are not recorded. +The version 1 package-name grammar is 1 through 64 ASCII bytes. The first byte is an ASCII letter; the remaining bytes are ASCII letters, digits, `-`, or `_`. Symposium preserves the spelling without case folding or treating hyphens and underscores as equivalent. This stable telemetry grammar does not copy a registry's changing reserved-name list. + +An exact package version has three numeric semantic-version components and may include prerelease or build metadata. Missing versions, ranges, and wildcards are invalid coordinates. + `extension_match` describes what the package contributed: - `public`: at least one eligible public extension matched, including when unnamed content also matched. diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 4f6904ce..8ce27379 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -1,5 +1,7 @@ //! Schema types for resolution telemetry. +pub(in crate::telemetry) mod package; + use std::fmt; use serde::{Deserialize, Serialize}; @@ -40,22 +42,6 @@ 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 @@ -533,49 +519,15 @@ 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] diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs new file mode 100644 index 00000000..4e90fba9 --- /dev/null +++ b/src/telemetry/schema/resolution/package.rs @@ -0,0 +1,515 @@ +//! Public package coordinates used by resolution telemetry. + +use std::{fmt, str::FromStr}; + +use semver::Version; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; + +/// Public package ecosystem approved for version 1 telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum PackageEcosystem { + Cargo, +} + +/// 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, +} + +/// Public package name accepted by the version 1 telemetry contract. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::telemetry) struct PublicPackageName(String); + +impl PublicPackageName { + /// Return the validated package name. + #[must_use] + pub(in crate::telemetry) fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for PublicPackageName { + type Error = PublicPackageNameError; + + fn try_from(value: String) -> Result { + validate_public_package_name(&value)?; + Ok(Self(value)) + } +} + +impl FromStr for PublicPackageName { + type Err = PublicPackageNameError; + + fn from_str(value: &str) -> Result { + validate_public_package_name(value)?; + Ok(Self(value.to_owned())) + } +} + +impl fmt::Display for PublicPackageName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Serialize for PublicPackageName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for PublicPackageName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .try_into() + .map_err(D::Error::custom) + } +} + +fn validate_public_package_name(value: &str) -> Result<(), PublicPackageNameError> { + let Some((first, rest)) = value.as_bytes().split_first() else { + return Err(PublicPackageNameError::Empty); + }; + + if value.len() > MAX_PUBLIC_PACKAGE_NAME_BYTES { + return Err(PublicPackageNameError::TooLong); + } + + if !first.is_ascii_alphabetic() { + return Err(PublicPackageNameError::NonAlphabeticFirstCharacter); + } + + if !rest + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PublicPackageNameError::UnsupportedCharacter); + } + + Ok(()) +} + +/// Reason a package name cannot enter public telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum PublicPackageNameError { + Empty, + TooLong, + NonAlphabeticFirstCharacter, + UnsupportedCharacter, +} + +impl fmt::Display for PublicPackageNameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("public package name must not be empty"), + Self::TooLong => write!( + formatter, + "public package name exceeds {MAX_PUBLIC_PACKAGE_NAME_BYTES} bytes" + ), + Self::NonAlphabeticFirstCharacter => { + formatter.write_str("public package name must start with an ASCII letter") + } + Self::UnsupportedCharacter => formatter.write_str( + "public package name may contain only ASCII letters, digits, hyphens, and underscores", + ), + } + } +} + +impl std::error::Error for PublicPackageNameError {} + +/// Exact semantic version attached to a public package coordinate. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::telemetry) struct ExactPackageVersion(Version); + +impl ExactPackageVersion { + /// Return the validated semantic version. + #[must_use] + pub(in crate::telemetry) fn as_version(&self) -> &Version { + &self.0 + } +} + +impl From for ExactPackageVersion { + fn from(version: Version) -> Self { + Self(version) + } +} + +impl FromStr for ExactPackageVersion { + type Err = InvalidExactPackageVersion; + + fn from_str(value: &str) -> Result { + Version::parse(value) + .map(Self) + .map_err(|_| InvalidExactPackageVersion) + } +} + +impl TryFrom for ExactPackageVersion { + type Error = InvalidExactPackageVersion; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl fmt::Display for ExactPackageVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl Serialize for ExactPackageVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ExactPackageVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .try_into() + .map_err(D::Error::custom) + } +} + +/// Error returned when a package version is not an exact semantic version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct InvalidExactPackageVersion; + +impl fmt::Display for InvalidExactPackageVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("package version must be an exact semantic version") + } +} + +impl std::error::Error for InvalidExactPackageVersion {} + +/// Public package coordinate safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicPackageCoordinate { + ecosystem: PackageEcosystem, + name: PublicPackageName, + version: ExactPackageVersion, +} + +impl PublicPackageCoordinate { + /// Combine validated components into one public coordinate. + /// + /// The name and version must come from the package manager's resolved + /// package identity. In particular, `name` must not be a dependency alias + /// or the spelling from an unresolved request. + #[must_use] + pub(in crate::telemetry) fn new( + ecosystem: PackageEcosystem, + name: PublicPackageName, + version: ExactPackageVersion, + ) -> Self { + Self { + ecosystem, + name, + version, + } + } + + /// Validate raw resolved components and combine them into one coordinate. + /// + /// # Errors + /// + /// Returns an error when the package name is outside the version 1 grammar + /// or the version is not an exact semantic version. + pub(in crate::telemetry) fn try_new( + ecosystem: PackageEcosystem, + name: &str, + version: &str, + ) -> Result { + Ok(Self::new(ecosystem, name.parse()?, version.parse()?)) + } + + /// Return the public ecosystem. + #[must_use] + pub(in crate::telemetry) fn ecosystem(&self) -> PackageEcosystem { + self.ecosystem + } + + /// Return the validated package name. + #[must_use] + pub(in crate::telemetry) fn name(&self) -> &PublicPackageName { + &self.name + } + + /// Return the exact package version. + #[must_use] + pub(in crate::telemetry) fn version(&self) -> &ExactPackageVersion { + &self.version + } +} + +/// Error returned when a public package coordinate has an invalid component. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum InvalidPublicPackageCoordinate { + Name(PublicPackageNameError), + Version(InvalidExactPackageVersion), +} + +impl From for InvalidPublicPackageCoordinate { + fn from(error: PublicPackageNameError) -> Self { + Self::Name(error) + } +} + +impl From for InvalidPublicPackageCoordinate { + fn from(error: InvalidExactPackageVersion) -> Self { + Self::Version(error) + } +} + +impl fmt::Display for InvalidPublicPackageCoordinate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Name(error) => write!(formatter, "invalid public package name: {error}"), + Self::Version(error) => write!(formatter, "invalid public package version: {error}"), + } + } +} + +impl std::error::Error for InvalidPublicPackageCoordinate { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Name(error) => Some(error), + Self::Version(error) => Some(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package_name(value: &str) -> PublicPackageName { + value.parse().unwrap() + } + + fn package_version(value: &str) -> ExactPackageVersion { + value.parse().unwrap() + } + + #[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 package_vocabulary_rejects_unknown_contract_names() { + let unknown = r#""future_value""#; + + let ecosystem = serde_json::from_str::(unknown); + let extension_match = serde_json::from_str::(unknown); + + assert!(ecosystem.is_err()); + assert!(extension_match.is_err()); + } + + #[test] + fn public_package_names_accept_the_contract_grammar() { + let cases = ["a", "A1", "example-runtime", "example_runtime"]; + + for value in cases { + let name = value.parse::().unwrap(); + let json = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(name.as_str(), value); + assert_eq!(json, format!(r#""{value}""#)); + assert_eq!(decoded, name); + } + + let maximum_length = format!("a{}", "0".repeat(63)); + assert_eq!( + maximum_length + .parse::() + .unwrap() + .as_str(), + maximum_length + ); + } + + #[test] + fn public_package_names_reject_invalid_length() { + let too_long = format!("a{}", "0".repeat(64)); + + let empty = "".parse::(); + let oversized = too_long.parse::(); + + assert_eq!(empty, Err(PublicPackageNameError::Empty)); + assert_eq!(oversized, Err(PublicPackageNameError::TooLong)); + } + + #[test] + fn public_package_names_require_an_ascii_letter_first() { + let cases = ["1crate", "-crate", "_crate", "écrate"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(PublicPackageNameError::NonAlphabeticFirstCharacter) + ); + } + } + + #[test] + fn public_package_names_reject_unsupported_characters() { + let cases = ["crate.name", "crate/name", "crate name", "craté"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(PublicPackageNameError::UnsupportedCharacter) + ); + } + } + + #[test] + fn exact_package_versions_round_trip_without_losing_semver_parts() { + let cases = ["1.2.3", "1.2.3-alpha.1+build.5"]; + + for value in cases { + let version = value.parse::().unwrap(); + let json = serde_json::to_string(&version).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!(version.to_string(), value); + assert_eq!(json, format!(r#""{value}""#)); + assert_eq!(decoded, version); + } + } + + #[test] + fn exact_package_versions_reject_missing_ranges_and_wildcards() { + let cases = ["", "*", "^1.2.3", "1.2", "01.2.3", "1.2.3.4"]; + + for value in cases { + assert_eq!( + value.parse::(), + Err(InvalidExactPackageVersion) + ); + } + } + + #[test] + fn public_package_coordinate_validates_raw_components() { + let coordinate = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "1.2.3") + .unwrap(); + let invalid_name = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "private/package", "1.2.3"); + let invalid_version = + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "*"); + + assert_eq!(coordinate.name().as_str(), "example-runtime"); + assert_eq!(coordinate.version().to_string(), "1.2.3"); + assert_eq!( + invalid_name, + Err(InvalidPublicPackageCoordinate::Name( + PublicPackageNameError::UnsupportedCharacter + )) + ); + assert_eq!( + invalid_version, + Err(InvalidPublicPackageCoordinate::Version( + InvalidExactPackageVersion + )) + ); + } + + #[test] + fn public_package_coordinate_round_trips_in_contract_order() { + let coordinate = PublicPackageCoordinate::new( + PackageEcosystem::Cargo, + package_name("Example-runtime"), + package_version("1.2.3-alpha.1+build.5"), + ); + + let json = serde_json::to_string(&coordinate).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + r#"{"ecosystem":"cargo","name":"Example-runtime","version":"1.2.3-alpha.1+build.5"}"# + ); + assert_eq!(decoded, coordinate); + assert_eq!(coordinate.ecosystem(), PackageEcosystem::Cargo); + assert_eq!(coordinate.name().as_str(), "Example-runtime"); + assert_eq!( + coordinate.version().as_version(), + &Version::parse("1.2.3-alpha.1+build.5").unwrap() + ); + } + + #[test] + fn public_package_coordinate_rejects_unknown_fields() { + let json = r#"{"ecosystem":"cargo","name":"example-runtime","version":"1.2.3","source":"registry"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_package_coordinate_validates_nested_name_and_version() { + let invalid_name = r#"{"ecosystem":"cargo","name":"private/package","version":"1.2.3"}"#; + let invalid_version = r#"{"ecosystem":"cargo","name":"example-runtime","version":"*"}"#; + + let name_result = serde_json::from_str::(invalid_name); + let version_result = serde_json::from_str::(invalid_version); + + assert!(name_result.is_err()); + assert!(version_result.is_err()); + } +} From 8460afebd27506684509cee30270ddc09dfcb305 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 13:33:16 +0300 Subject: [PATCH 32/57] Derive package identities from validated coordinates Package subjects now take their ecosystem, resolved name, and exact version directly from a validated coordinate. Keep framing inside the identity module so producers cannot silently omit or reorder fields. The same writer also defines counted sequences for future structured dimensions. --- src/telemetry/identity.rs | 148 ++++++++++++++++----- src/telemetry/schema/resolution/package.rs | 51 +++++++ 2 files changed, 169 insertions(+), 30 deletions(-) diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 92e1e09c..3f68e3e6 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -70,31 +70,57 @@ impl<'a> IdentityWindow<'a> { } } -/// Canonically framed dimension fields belonging to domain `D`. +/// A typed value that supplies one identifier domain's canonical fields. /// -/// Domain-specific constructors will own field selection and order. Telemetry -/// producers never concatenate dimension strings themselves. -struct ScopedDimension { - encoded_fields: Vec, - domain: PhantomData, +/// Each schema type implements this trait for the domain it belongs to. This +/// keeps field selection and order beside the validated value while leaving +/// framing under the identity module's control. +pub(super) trait IdentityDimension { + type Domain; + + fn write(&self, writer: &mut DimensionWriter<'_>); } -#[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)); - } +/// Writes canonical identity-dimension framing to a private byte sink. +/// +/// Only this module can create a writer. Schema types can use its structured +/// operations from an [`IdentityDimension`] implementation, but telemetry +/// producers cannot construct dimensions from loose byte slices. +pub(super) struct DimensionWriter<'a> { + write: &'a mut dyn FnMut(&[u8]), +} - Self { - encoded_fields, - domain: PhantomData, +impl<'a> DimensionWriter<'a> { + fn new(write: &'a mut dyn FnMut(&[u8])) -> Self { + Self { write } + } + + /// Write one length-prefixed field. + pub(super) fn field(&mut self, value: &[u8]) { + write_frame(value, |bytes| (self.write)(bytes)); + } + + /// Write a counted sequence whose items own their recursive encoding. + pub(super) fn sequence(&mut self, items: &[T], mut write_item: impl FnMut(&mut Self, &T)) { + let count = u64::try_from(items.len()) + .expect("BUG: a slice length must fit the telemetry sequence format"); + (self.write)(&count.to_be_bytes()); + + for item in items { + write_item(self, item); } } } +#[cfg(test)] +pub(super) fn encode_dimension_for_test(dimension: &impl IdentityDimension) -> Vec { + let mut encoded = Vec::new(); + let mut append = |bytes: &[u8]| encoded.extend_from_slice(bytes); + let mut writer = DimensionWriter::new(&mut append); + dimension.write(&mut writer); + encoded +} + /// A 128-bit telemetry identifier belonging to domain `D`. /// /// Its wire form is the domain prefix followed by `ENCODED_DIGITS` lowercase @@ -176,17 +202,20 @@ impl IdentityDeriver { /// Derive an identifier from a canonical window and domain-specific fields. #[must_use] - fn derive(&self, window: &IdentityWindow<'_>, dimension: &ScopedDimension) -> ScopedId + fn derive(&self, window: &IdentityWindow<'_>, dimension: &I) -> ScopedId where - D: ScopedIdDomain, + I: IdentityDimension, + I::Domain: 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(I::Domain::HMAC_DOMAIN.as_bytes()); hmac.update(b":v1\0"); write_frame(window.0, |bytes| hmac.update(bytes)); - hmac.update(&dimension.encoded_fields); + let mut update = |bytes: &[u8]| hmac.update(bytes); + let mut writer = DimensionWriter::new(&mut update); + dimension.write(&mut writer); let digest = hmac.finalize().into_bytes(); let mut bytes = [0; IDENTIFIER_BYTES]; @@ -492,6 +521,43 @@ mod tests { const HMAC_DOMAIN: &'static str = "test_subject"; } + struct TestDimension { + fields: Vec<&'static [u8]>, + domain: PhantomData, + } + + impl TestDimension { + fn from_fields(fields: [&'static [u8]; N]) -> Self { + Self { + fields: fields.into_iter().collect(), + domain: PhantomData, + } + } + } + + impl IdentityDimension for TestDimension { + type Domain = D; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + for field in &self.fields { + writer.field(field); + } + } + } + + struct TestSequenceDimension; + + impl IdentityDimension for TestSequenceDimension { + type Domain = TestDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + let items = [b"a".as_slice(), b"bc".as_slice()]; + + writer.field(b"root"); + writer.sequence(&items, |writer, item| writer.field(item)); + } + } + /// Reads the first identifier the contract spells with `prefix`. fn contract_identifier(prefix: &str) -> &'static str { let opening_quote = RECORDED_DATA @@ -551,7 +617,7 @@ mod tests { 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 dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); let identifier = deriver.derive(&window, &dimension); @@ -569,7 +635,7 @@ mod tests { 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 dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); let first = deriver.derive(&window, &dimension); let second = deriver.derive(&window, &dimension); @@ -581,9 +647,9 @@ mod tests { 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 first_dimension = TestDimension::::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 second_dimension = TestDimension::::from_fields([b"b\0c".as_slice()]); let first = deriver.derive(&first_window, &first_dimension); let second = deriver.derive(&second_window, &second_dimension); @@ -595,12 +661,12 @@ mod tests { 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([ + let first_dimension = TestDimension::::from_fields([ b"cargo".as_slice(), b"foo1".as_slice(), b"2.3.4".as_slice(), ]); - let second_dimension = ScopedDimension::::from_fields([ + let second_dimension = TestDimension::::from_fields([ b"cargo".as_slice(), b"foo".as_slice(), b"12.3.4".as_slice(), @@ -612,6 +678,28 @@ mod tests { assert_ne!(first, second); } + #[test] + fn dimension_writer_encodes_counted_sequences_recursively() { + let root_length = 4_u64.to_be_bytes(); + let item_count = 2_u64.to_be_bytes(); + let first_item_length = 1_u64.to_be_bytes(); + let second_item_length = 2_u64.to_be_bytes(); + let expected = [ + root_length.as_slice(), + b"root".as_slice(), + item_count.as_slice(), + first_item_length.as_slice(), + b"a".as_slice(), + second_item_length.as_slice(), + b"bc".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&TestSequenceDimension); + + assert_eq!(encoded, expected); + } + #[test] fn changing_any_derivation_scope_changes_the_identifier() { let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); @@ -620,11 +708,11 @@ mod tests { 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()]); + TestDimension::::from_fields([b"dimension-1".as_slice()]); let other_session_dimension = - ScopedDimension::::from_fields([b"dimension-2".as_slice()]); + TestDimension::::from_fields([b"dimension-2".as_slice()]); let command_dimension = - ScopedDimension::::from_fields([b"dimension-1".as_slice()]); + TestDimension::::from_fields([b"dimension-1".as_slice()]); // Different domain markers produce different identifier types, so use // their shared byte representation for this one collection. diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index 4e90fba9..41348958 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -5,6 +5,8 @@ use std::{fmt, str::FromStr}; use semver::Version; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use crate::telemetry::identity::{DimensionWriter, IdentityDimension, PackageDomain}; + const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; /// Public package ecosystem approved for version 1 telemetry. @@ -14,6 +16,15 @@ pub(in crate::telemetry) enum PackageEcosystem { Cargo, } +impl PackageEcosystem { + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Cargo => "cargo", + } + } +} + /// Kind of extension content contributed by one public package. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -266,6 +277,18 @@ impl PublicPackageCoordinate { } } +impl IdentityDimension for PublicPackageCoordinate { + type Domain = PackageDomain; + + /// Write the version 1 `package_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + let version = self.version.to_string(); + writer.field(self.ecosystem.as_str().as_bytes()); + writer.field(self.name.as_str().as_bytes()); + writer.field(version.as_bytes()); + } +} + /// Error returned when a public package coordinate has an invalid component. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::telemetry) enum InvalidPublicPackageCoordinate { @@ -306,6 +329,7 @@ impl std::error::Error for InvalidPublicPackageCoordinate { #[cfg(test)] mod tests { use super::*; + use crate::telemetry::identity::encode_dimension_for_test; fn package_name(value: &str) -> PublicPackageName { value.parse().unwrap() @@ -323,6 +347,7 @@ mod tests { let json = serde_json::to_string(&ecosystem).unwrap(); let decoded = serde_json::from_str::(&json).unwrap(); + assert_eq!(ecosystem.as_str(), name); assert_eq!(json, format!(r#""{name}""#)); assert_eq!(decoded, ecosystem); } @@ -468,6 +493,32 @@ mod tests { ); } + #[test] + fn package_subject_dimension_uses_contract_field_order() { + let coordinate = PublicPackageCoordinate::try_new( + PackageEcosystem::Cargo, + "example-runtime", + "1.2.3-alpha.1+build.5", + ) + .unwrap(); + let ecosystem_length = 5_u64.to_be_bytes(); + let name_length = 15_u64.to_be_bytes(); + let version_length = 21_u64.to_be_bytes(); + let expected = [ + ecosystem_length.as_slice(), + b"cargo".as_slice(), + name_length.as_slice(), + b"example-runtime".as_slice(), + version_length.as_slice(), + b"1.2.3-alpha.1+build.5".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&coordinate); + + assert_eq!(encoded, expected); + } + #[test] fn public_package_coordinate_round_trips_in_contract_order() { let coordinate = PublicPackageCoordinate::new( From ff51a6addba7be951d692a00e428f70f3177f3ee Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 13:49:13 +0300 Subject: [PATCH 33/57] Define package resolution telemetry rows Add the versioned row for eligible public packages, using validated coordinates and domain-specific package subjects. Pin the serialized contract example and reject future versions, missing fields, and unknown fields before classifier wiring is added. --- src/telemetry/schema/mod.rs | 10 ++ src/telemetry/schema/resolution/package.rs | 105 ++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 7d9aea23..f3d05527 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -806,6 +806,16 @@ mod tests { assert_eq!(serde_json::to_string(&row).unwrap(), example); } + #[test] + fn package_resolution_example_round_trips() { + let example = example_row("package_resolution"); + + let row = serde_json::from_str::(example) + .expect("package_resolution contract example must be valid"); + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + #[test] fn unsupported_resolution_summary_version_is_unknown_schema() { let example = example_row("resolution_summary"); diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index 41348958..1fd90f5d 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -5,7 +5,12 @@ use std::{fmt, str::FromStr}; use semver::Version; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; -use crate::telemetry::identity::{DimensionWriter, IdentityDimension, PackageDomain}; +use super::super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, +}; +use crate::telemetry::identity::{ + DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, +}; const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; @@ -326,8 +331,47 @@ impl std::error::Error for InvalidPublicPackageCoordinate { } } +/// Version 1 record of one eligible public package used during resolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PackageResolutionV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + package: PublicPackageCoordinate, + extension_match: ExtensionMatch, + package_subject: PackageSubject, +} + +impl PackageResolutionV1 { + /// Create a record for one eligible public resolution-input package. + #[must_use] + pub(in crate::telemetry) fn new( + day: UtcDay, + package: PublicPackageCoordinate, + extension_match: ExtensionMatch, + package_subject: PackageSubject, + ) -> Self { + Self { + version: SchemaVersion::V1, + kind: RowKind::PackageResolution, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + package, + extension_match, + package_subject, + } + } +} + #[cfg(test)] mod tests { + use chrono::NaiveDate; + use super::*; use crate::telemetry::identity::encode_dimension_for_test; @@ -339,6 +383,16 @@ mod tests { value.parse().unwrap() } + fn package_resolution() -> PackageResolutionV1 { + PackageResolutionV1::new( + UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()), + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "1.2.3") + .unwrap(), + ExtensionMatch::Public, + "pkg_f6db813c87209816ae4896f3e60dd774".parse().unwrap(), + ) + } + #[test] fn package_ecosystems_round_trip_with_contract_names() { let cases = [(PackageEcosystem::Cargo, "cargo")]; @@ -563,4 +617,53 @@ mod tests { assert!(name_result.is_err()); assert!(version_result.is_err()); } + + #[test] + fn new_package_resolution_uses_fixed_common_fields() { + let expected_day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let expected_subject = "pkg_f6db813c87209816ae4896f3e60dd774".parse().unwrap(); + + let row = package_resolution(); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::PackageResolution); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, expected_day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.package.ecosystem(), PackageEcosystem::Cargo); + assert_eq!(row.package.name().as_str(), "example-runtime"); + assert_eq!(row.package.version().to_string(), "1.2.3"); + assert_eq!(row.extension_match, ExtensionMatch::Public); + assert_eq!(row.package_subject, expected_subject); + } + + #[test] + fn package_resolution_rejects_future_version() { + let json = serde_json::to_string(&package_resolution()).unwrap(); + let future = json.replacen(r#""v":1"#, r#""v":2"#, 1); + + let result = serde_json::from_str::(&future); + + assert!(result.is_err()); + } + + #[test] + fn package_resolution_rejects_unknown_fields() { + let json = serde_json::to_string(&package_resolution()).unwrap(); + let unknown = json.replacen(r#""package""#, r#""future_field":true,"package""#, 1); + + let result = serde_json::from_str::(&unknown); + + assert!(result.is_err()); + } + + #[test] + fn package_resolution_requires_every_field() { + let json = serde_json::to_string(&package_resolution()).unwrap(); + let missing = json.replacen(r#","extension_match":"public""#, "", 1); + + let result = serde_json::from_str::(&missing); + + assert!(result.is_err()); + } } From a047ac091e542d36f7ecc175cba1c0e9bf5ed4bc Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 14:05:10 +0300 Subject: [PATCH 34/57] Read package resolution telemetry rows Route version one package resolution records through the shared schema classifier and serializer. Keep future versions distinguishable from invalid known rows, and verify invalid public coordinates cannot enter supported output. --- src/telemetry/schema/mod.rs | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index f3d05527..bd602d91 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -18,7 +18,7 @@ use serde::{ use uuid::Uuid; use agent::{AgentConfigurationV1, SessionStartV1}; -use resolution::ResolutionSummaryV1; +use resolution::{ResolutionSummaryV1, package::PackageResolutionV1}; /// Random identifier for one telemetry row. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -73,6 +73,7 @@ pub(super) enum TelemetryRow { SessionStart(SessionStartV1), AgentConfiguration(AgentConfigurationV1), ResolutionSummary(ResolutionSummaryV1), + PackageResolution(PackageResolutionV1), StorageLimit(StorageLimitV1), } @@ -85,6 +86,7 @@ impl Serialize for TelemetryRow { Self::SessionStart(row) => row.serialize(serializer), Self::AgentConfiguration(row) => row.serialize(serializer), Self::ResolutionSummary(row) => row.serialize(serializer), + Self::PackageResolution(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -393,6 +395,9 @@ pub(super) fn classify_row(line: &str) -> RowClassification { ("resolution_summary", 1) => { deserialize_supported_row(line, TelemetryRow::ResolutionSummary) } + ("package_resolution", 1) => { + deserialize_supported_row(line, TelemetryRow::PackageResolution) + } ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } @@ -810,12 +815,43 @@ mod tests { fn package_resolution_example_round_trips() { let example = example_row("package_resolution"); - let row = serde_json::from_str::(example) - .expect("package_resolution contract example must be valid"); + let RowClassification::Supported(row) = classify_row(example) else { + panic!("package_resolution contract example was not classified as supported"); + }; assert_eq!(serde_json::to_string(&row).unwrap(), example); } + #[test] + fn unsupported_package_resolution_version_is_unknown_schema() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn package_resolution_with_unknown_field_is_invalid() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""package""#, r#""future_field":true,"package""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn package_resolution_with_invalid_coordinate_is_invalid() { + let example = example_row("package_resolution"); + let json = example.replacen(r#""version":"1.2.3""#, r#""version":"*""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + #[test] fn unsupported_resolution_summary_version_is_unknown_schema() { let example = example_row("resolution_summary"); From 6788792c9770805b5713918239e13ef9ae1839f3 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 14:26:01 +0300 Subject: [PATCH 35/57] Define tagged identity dimensions Give structured identity values one canonical way to encode variant labels and nested sequences. This keeps safe resolution paths aligned with the frozen telemetry contract. --- src/telemetry/identity.rs | 74 ++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 3f68e3e6..21d47089 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -78,6 +78,11 @@ impl<'a> IdentityWindow<'a> { pub(super) trait IdentityDimension { type Domain; + /// Write this dimension's fields in their frozen contract order. + /// + /// Use [`DimensionWriter::variant`] for tagged values and + /// [`DimensionWriter::sequence`] for counted collections. Implementations + /// must not add their own framing. fn write(&self, writer: &mut DimensionWriter<'_>); } @@ -100,6 +105,15 @@ impl<'a> DimensionWriter<'a> { write_frame(value, |bytes| (self.write)(bytes)); } + /// Write a tagged variant followed by its canonically framed fields. + /// + /// Variant labels are frozen contract values, so callers supply a static + /// string rather than data obtained at runtime. + pub(super) fn variant(&mut self, label: &'static str, write_fields: impl FnOnce(&mut Self)) { + self.field(label.as_bytes()); + write_fields(self); + } + /// Write a counted sequence whose items own their recursive encoding. pub(super) fn sequence(&mut self, items: &[T], mut write_item: impl FnMut(&mut Self, &T)) { let count = u64::try_from(items.len()) @@ -545,16 +559,30 @@ mod tests { } } - struct TestSequenceDimension; + struct TestVariantDimension; - impl IdentityDimension for TestSequenceDimension { + impl IdentityDimension for TestVariantDimension { type Domain = TestDomain; fn write(&self, writer: &mut DimensionWriter<'_>) { - let items = [b"a".as_slice(), b"bc".as_slice()]; + writer.variant("package", |writer| writer.field(b"cargo")); + } + } - writer.field(b"root"); - writer.sequence(&items, |writer, item| writer.field(item)); + struct TestNestedSequenceDimension; + + impl IdentityDimension for TestNestedSequenceDimension { + type Domain = TestDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + let groups = [ + [b"a".as_slice(), b"bc".as_slice()], + [b"d".as_slice(), b"ef".as_slice()], + ]; + + writer.sequence(&groups, |writer, group| { + writer.sequence(group, |writer, item| writer.field(item)); + }); } } @@ -678,24 +706,44 @@ mod tests { assert_ne!(first, second); } + #[test] + fn dimension_writer_prefixes_variant_fields_with_their_label() { + let variant_length = 7_u64.to_be_bytes(); + let field_length = 5_u64.to_be_bytes(); + let expected = [ + variant_length.as_slice(), + b"package".as_slice(), + field_length.as_slice(), + b"cargo".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&TestVariantDimension); + + assert_eq!(encoded, expected); + } + #[test] fn dimension_writer_encodes_counted_sequences_recursively() { - let root_length = 4_u64.to_be_bytes(); let item_count = 2_u64.to_be_bytes(); - let first_item_length = 1_u64.to_be_bytes(); - let second_item_length = 2_u64.to_be_bytes(); + let one_byte = 1_u64.to_be_bytes(); + let two_bytes = 2_u64.to_be_bytes(); let expected = [ - root_length.as_slice(), - b"root".as_slice(), item_count.as_slice(), - first_item_length.as_slice(), + item_count.as_slice(), + one_byte.as_slice(), b"a".as_slice(), - second_item_length.as_slice(), + two_bytes.as_slice(), b"bc".as_slice(), + item_count.as_slice(), + one_byte.as_slice(), + b"d".as_slice(), + two_bytes.as_slice(), + b"ef".as_slice(), ] .concat(); - let encoded = encode_dimension_for_test(&TestSequenceDimension); + let encoded = encode_dimension_for_test(&TestNestedSequenceDimension); assert_eq!(encoded, expected); } From 67f138c5b203dc4e792f1bad78b6681719384175 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 15:13:03 +0300 Subject: [PATCH 36/57] Define public extension telemetry vocabulary Add the versioned plugin and skill vocabulary used by telemetry. Validate public names against the contract and preserve their spelling. Share name checks and contract-name tests across schema modules. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 8 +- src/telemetry/schema/agent.rs | 42 +-- src/telemetry/schema/extension.rs | 254 ++++++++++++++++++ src/telemetry/schema/mod.rs | 44 ++- src/telemetry/schema/name.rs | 49 ++++ src/telemetry/schema/resolution.rs | 17 +- src/telemetry/schema/resolution/package.rs | 56 ++-- 8 files changed, 371 insertions(+), 101 deletions(-) create mode 100644 src/telemetry/schema/extension.rs create mode 100644 src/telemetry/schema/name.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 00096a28..f8e4339d 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, `agent.rs` owns agent vocabulary and agent-originated rows, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. 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. +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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns public extension vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. 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 544f7d0d..8295dca8 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -117,6 +117,8 @@ Only the following stable labels can make package, plugin, skill, or plugin-comm `crates-io` applies only when core proves allowlisted crates.io provenance; `symposium-recommendations` identifies the built-in registry. `user-plugins`, configured registries, paths, workspaces, and arbitrary git sources remain unnamed. Raw registry names and URLs are never enum values. Adding an ecosystem or public-source label expands name eligibility and requires a new consent version. +Public plugin and skill names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, `-`, or `_`. Unlike public package names, extension names may begin with a digit because they are authored extension identifiers rather than crates.io package coordinates. Symposium preserves the spelling without case folding or Unicode normalization. An otherwise valid extension whose name does not fit this telemetry grammar remains usable but is treated as unnamed by telemetry. + ## Event kinds ### `session_start` @@ -215,7 +217,7 @@ This row records one public plugin or skill and one safe path that selected it. | ------------------- | --------------------------- | --------------------------------------------------- | | `target.type` | `plugin`, `skill` | Resolved extension type. | | `target.source` | `symposium-recommendations`, `crates-io` | Stable label, never a configured URL or local name. | -| `target.name` | validated string | Name defined by eligible public content. | +| `target.name` | public extension name | Name defined by eligible public content. | | `path` | bounded typed nodes | Actual safe package/predicate/extension chain. | | `extension_subject` | scoped id | Deduplicates this safe target/path for 30 days. | @@ -268,7 +270,7 @@ This cumulative row combines plugin-hook observations for one UTC day, agent, ho | `hook` | `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `session_start`, `stop` | Symposium hook surface. | | `plugin_scope` | `public`, `unnamed`, `overflow` | Whether the bucket names an eligible public plugin. | | `plugin.source` | `symposium-recommendations`, `crates-io`, conditional | Present only when `plugin_scope=public`. | -| `plugin.name` | validated string, conditional | Present only when `plugin_scope=public`. | +| `plugin.name` | public extension name, conditional | Present only when `plugin_scope=public`. | | `attempts` | integer | Plugin-hook attempts that reached an observed terminal result. | | `executions` | integer | Those completed attempts that reached child execution. | | `outcomes` | plugin outcome counters | Exact counters named `ok`, `blocked`, and `error`. | @@ -319,7 +321,7 @@ This cumulative row combines skill-invocation observations for one UTC day, supp | `target_scope` | `public`, `unnamed`, `overflow` | Whether the bucket names an eligible public skill. | | `target.type` | `skill`, conditional | Present only when `target_scope=public`. | | `target.source` | `symposium-recommendations`, `crates-io`, conditional | Reviewed public source; present only when `target_scope=public`. | -| `target.name` | validated string, conditional | Public skill name; present only when `target_scope=public`. | +| `target.name` | public extension name, conditional | Public skill name; present only when `target_scope=public`. | | `unnamed_reason` | `ineligible`, `not_indexed`, `attribution_unavailable`, `ambiguous`, `invalid_signal`, conditional | Present only when `target_scope=unnamed`. | | `attempted` | integer | Valid Claude `PreToolUse:Skill` observations merged into the row. | | `completed` | integer | Successful Claude `PostToolUse:Skill` observations merged into the row. | diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index f93e28d2..c08f1526 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -314,7 +314,7 @@ impl AgentConfigurationV1 { mod tests { use chrono::{NaiveDate, TimeZone, Utc}; - use super::super::{RowClassification, TelemetryRow, classify_row}; + use super::super::{RowClassification, TelemetryRow, assert_contract_names, classify_row}; use super::*; fn session_start_fields(session_id: Option) -> SessionStartFields { @@ -343,13 +343,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] @@ -382,13 +376,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] @@ -410,13 +398,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] @@ -455,13 +437,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] @@ -497,13 +473,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] diff --git a/src/telemetry/schema/extension.rs b/src/telemetry/schema/extension.rs new file mode 100644 index 00000000..9cd5b377 --- /dev/null +++ b/src/telemetry/schema/extension.rs @@ -0,0 +1,254 @@ +//! Public extension vocabulary shared by telemetry rows. + +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +use super::name::{InitialByteRule, PublicNameViolation, validate_public_name}; + +const MAX_PUBLIC_EXTENSION_NAME_BYTES: usize = 64; + +/// Plugin or skill named by eligible public telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum ExtensionKind { + Plugin, + Skill, +} + +impl ExtensionKind { + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::Plugin => "plugin", + Self::Skill => "skill", + } + } +} + +/// Public source approved for version 1 extension telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(in crate::telemetry) enum PublicExtensionSource { + SymposiumRecommendations, + CratesIo, +} + +impl PublicExtensionSource { + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::SymposiumRecommendations => "symposium-recommendations", + Self::CratesIo => "crates-io", + } + } +} + +/// Public plugin or skill name accepted by the version 1 telemetry contract. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(in crate::telemetry) struct PublicExtensionName(String); + +impl PublicExtensionName { + /// Return the validated extension name without changing its spelling. + #[must_use] + pub(in crate::telemetry) fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for PublicExtensionName { + type Error = PublicExtensionNameError; + + fn try_from(value: String) -> Result { + validate_public_extension_name(&value)?; + Ok(Self(value)) + } +} + +impl FromStr for PublicExtensionName { + type Err = PublicExtensionNameError; + + fn from_str(value: &str) -> Result { + validate_public_extension_name(value)?; + Ok(Self(value.to_owned())) + } +} + +impl fmt::Display for PublicExtensionName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Serialize for PublicExtensionName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for PublicExtensionName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .try_into() + .map_err(D::Error::custom) + } +} + +fn validate_public_extension_name(value: &str) -> Result<(), PublicExtensionNameError> { + validate_public_name( + value, + MAX_PUBLIC_EXTENSION_NAME_BYTES, + InitialByteRule::Alphanumeric, + ) + .map_err(PublicExtensionNameError::from) +} + +/// Reason an extension name cannot enter public telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum PublicExtensionNameError { + Empty, + TooLong, + NonAlphanumericFirstCharacter, + UnsupportedCharacter, +} + +impl From for PublicExtensionNameError { + fn from(violation: PublicNameViolation) -> Self { + match violation { + PublicNameViolation::Empty => Self::Empty, + PublicNameViolation::TooLong => Self::TooLong, + PublicNameViolation::InvalidInitialByte => Self::NonAlphanumericFirstCharacter, + PublicNameViolation::UnsupportedCharacter => Self::UnsupportedCharacter, + } + } +} + +impl fmt::Display for PublicExtensionNameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("public extension name must not be empty"), + Self::TooLong => write!( + formatter, + "public extension name exceeds {MAX_PUBLIC_EXTENSION_NAME_BYTES} bytes" + ), + Self::NonAlphanumericFirstCharacter => formatter + .write_str("public extension name must start with an ASCII letter or digit"), + Self::UnsupportedCharacter => formatter.write_str( + "public extension name may contain only ASCII letters, digits, hyphens, and underscores", + ), + } + } +} + +impl std::error::Error for PublicExtensionNameError {} + +#[cfg(test)] +mod tests { + use super::super::assert_contract_names_with_labels; + use super::*; + + #[test] + fn extension_kinds_round_trip_with_contract_names() { + let cases = [ + (ExtensionKind::Plugin, "plugin"), + (ExtensionKind::Skill, "skill"), + ]; + + assert_contract_names_with_labels(&cases, ExtensionKind::as_str); + } + + #[test] + fn public_extension_sources_round_trip_with_contract_names() { + let cases = [ + ( + PublicExtensionSource::SymposiumRecommendations, + "symposium-recommendations", + ), + (PublicExtensionSource::CratesIo, "crates-io"), + ]; + + assert_contract_names_with_labels(&cases, PublicExtensionSource::as_str); + } + + #[test] + fn extension_vocabulary_rejects_unknown_contract_names() { + let kind = serde_json::from_str::(r#""command""#); + let source = serde_json::from_str::(r#""user-plugins""#); + + assert!(kind.is_err()); + assert!(source.is_err()); + } + + #[test] + fn public_extension_names_accept_the_contract_grammar() { + for value in ["0", "Example-runtime_2", &"a".repeat(64)] { + let name = value.parse::().unwrap(); + + assert_eq!(name.as_str(), value); + } + } + + #[test] + fn public_extension_names_reject_invalid_length() { + let empty = "".parse::(); + let too_long = "a".repeat(65).parse::(); + + assert_eq!(empty.unwrap_err(), PublicExtensionNameError::Empty); + assert_eq!(too_long.unwrap_err(), PublicExtensionNameError::TooLong); + } + + #[test] + fn public_extension_names_require_an_ascii_alphanumeric_first_byte() { + for value in ["-extension", "_extension", "\u{e9}xtension"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicExtensionNameError::NonAlphanumericFirstCharacter + ); + } + } + + #[test] + fn public_extension_names_reject_unsupported_characters() { + for value in [ + "extension.name", + "extension name", + "extension/name", + "a\u{e9}", + ] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicExtensionNameError::UnsupportedCharacter + ); + } + } + + #[test] + fn public_extension_names_round_trip_without_normalization() { + let name = "Example-runtime_2".parse::().unwrap(); + + let encoded = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&encoded).unwrap(); + + assert_eq!(encoded, r#""Example-runtime_2""#); + assert_eq!(decoded, name); + } + + #[test] + fn public_extension_name_validation_runs_during_deserialization() { + let invalid = serde_json::from_str::(r#""extension.name""#); + + assert!(invalid.is_err()); + } +} diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index bd602d91..ef114d96 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -5,6 +5,8 @@ )] mod agent; +mod extension; +mod name; mod resolution; use std::{fmt, num::NonZeroU64, sync::LazyLock}; @@ -413,6 +415,32 @@ where } } +#[cfg(test)] +fn assert_contract_names(cases: &[(T, &str)]) +where + T: Copy + fmt::Debug + PartialEq + Serialize + DeserializeOwned, +{ + for &(value, name) in cases { + let encoded = serde_json::to_string(&value).unwrap(); + let decoded: T = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(encoded, format!(r#""{name}""#)); + assert_eq!(decoded, value); + } +} + +#[cfg(test)] +fn assert_contract_names_with_labels(cases: &[(T, &str)], label: impl Fn(T) -> &'static str) +where + T: Copy + fmt::Debug + PartialEq + Serialize + DeserializeOwned, +{ + assert_contract_names(cases); + + for &(value, name) in cases { + assert_eq!(label(value), name); + } +} + #[cfg(test)] mod tests { use super::*; @@ -515,13 +543,7 @@ mod tests { (RowKind::StorageLimit, "storage_limit"), ]; - for (kind, name) in cases { - let json = serde_json::to_string(&kind).unwrap(); - let decoded = serde_json::from_str::(&json).unwrap(); - - assert_eq!(json, format!(r#""{name}""#)); - assert_eq!(decoded, kind); - } + assert_contract_names(&cases); } #[test] @@ -928,13 +950,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] diff --git a/src/telemetry/schema/name.rs b/src/telemetry/schema/name.rs new file mode 100644 index 00000000..8806e528 --- /dev/null +++ b/src/telemetry/schema/name.rs @@ -0,0 +1,49 @@ +//! Validation shared by public names in the telemetry contract. + +/// Rule applied to the first byte of a public name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InitialByteRule { + Alphabetic, + Alphanumeric, +} + +/// Structural reason a public name fails its versioned grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PublicNameViolation { + Empty, + TooLong, + InvalidInitialByte, + UnsupportedCharacter, +} + +/// Validate the common ASCII shape of a versioned public telemetry name. +pub(super) fn validate_public_name( + value: &str, + maximum_bytes: usize, + initial_byte_rule: InitialByteRule, +) -> Result<(), PublicNameViolation> { + let Some((first, rest)) = value.as_bytes().split_first() else { + return Err(PublicNameViolation::Empty); + }; + + if value.len() > maximum_bytes { + return Err(PublicNameViolation::TooLong); + } + + let initial_byte_is_valid = match initial_byte_rule { + InitialByteRule::Alphabetic => first.is_ascii_alphabetic(), + InitialByteRule::Alphanumeric => first.is_ascii_alphanumeric(), + }; + if !initial_byte_is_valid { + return Err(PublicNameViolation::InvalidInitialByte); + } + + if !rest + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PublicNameViolation::UnsupportedCharacter); + } + + Ok(()) +} diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 8ce27379..5cc56fbb 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -285,6 +285,7 @@ impl TryFrom for ResolutionSummaryV1 { mod tests { use chrono::NaiveDate; + use super::super::assert_contract_names; use super::*; fn example_reasons() -> UnnamedPackageReasons { @@ -490,15 +491,13 @@ mod tests { (ResolutionTrigger::Remove, "remove"), ]; + assert_contract_names(&cases); + 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); + assert_eq!(dropped_operation, format!(r#""{name}""#)); } } @@ -510,13 +509,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index 1fd90f5d..253af718 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::super::{ EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, + name::{InitialByteRule, PublicNameViolation, validate_public_name}, }; use crate::telemetry::identity::{ DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, @@ -96,26 +97,12 @@ impl<'de> Deserialize<'de> for PublicPackageName { } fn validate_public_package_name(value: &str) -> Result<(), PublicPackageNameError> { - let Some((first, rest)) = value.as_bytes().split_first() else { - return Err(PublicPackageNameError::Empty); - }; - - if value.len() > MAX_PUBLIC_PACKAGE_NAME_BYTES { - return Err(PublicPackageNameError::TooLong); - } - - if !first.is_ascii_alphabetic() { - return Err(PublicPackageNameError::NonAlphabeticFirstCharacter); - } - - if !rest - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(PublicPackageNameError::UnsupportedCharacter); - } - - Ok(()) + validate_public_name( + value, + MAX_PUBLIC_PACKAGE_NAME_BYTES, + InitialByteRule::Alphabetic, + ) + .map_err(PublicPackageNameError::from) } /// Reason a package name cannot enter public telemetry. @@ -127,6 +114,17 @@ pub(in crate::telemetry) enum PublicPackageNameError { UnsupportedCharacter, } +impl From for PublicPackageNameError { + fn from(violation: PublicNameViolation) -> Self { + match violation { + PublicNameViolation::Empty => Self::Empty, + PublicNameViolation::TooLong => Self::TooLong, + PublicNameViolation::InvalidInitialByte => Self::NonAlphabeticFirstCharacter, + PublicNameViolation::UnsupportedCharacter => Self::UnsupportedCharacter, + } + } +} + impl fmt::Display for PublicPackageNameError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -372,6 +370,7 @@ impl PackageResolutionV1 { mod tests { use chrono::NaiveDate; + use super::super::super::{assert_contract_names, assert_contract_names_with_labels}; use super::*; use crate::telemetry::identity::encode_dimension_for_test; @@ -397,14 +396,7 @@ mod tests { 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!(ecosystem.as_str(), name); - assert_eq!(json, format!(r#""{name}""#)); - assert_eq!(decoded, ecosystem); - } + assert_contract_names_with_labels(&cases, PackageEcosystem::as_str); } #[test] @@ -415,13 +407,7 @@ mod tests { (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); - } + assert_contract_names(&cases); } #[test] From 682e92dc2376fbe0e5d96b97603798fd62c2819a Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 15:31:20 +0300 Subject: [PATCH 37/57] Generate validated telemetry name types Use one macro for the traits shared by validated public names. Keep each validator and error type explicit. Validation still runs when names are read from telemetry JSON. --- src/telemetry/schema/extension.rs | 67 ++++------------------ src/telemetry/schema/name.rs | 62 ++++++++++++++++++++ src/telemetry/schema/resolution/package.rs | 61 +++----------------- 3 files changed, 80 insertions(+), 110 deletions(-) diff --git a/src/telemetry/schema/extension.rs b/src/telemetry/schema/extension.rs index 9cd5b377..1a1439ba 100644 --- a/src/telemetry/schema/extension.rs +++ b/src/telemetry/schema/extension.rs @@ -1,10 +1,12 @@ //! Public extension vocabulary shared by telemetry rows. -use std::{fmt, str::FromStr}; +use std::fmt; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use serde::{Deserialize, Serialize}; -use super::name::{InitialByteRule, PublicNameViolation, validate_public_name}; +use super::name::{ + InitialByteRule, PublicNameViolation, validate_public_name, validated_string_newtype, +}; const MAX_PUBLIC_EXTENSION_NAME_BYTES: usize = 64; @@ -46,59 +48,12 @@ impl PublicExtensionSource { } } -/// Public plugin or skill name accepted by the version 1 telemetry contract. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(in crate::telemetry) struct PublicExtensionName(String); - -impl PublicExtensionName { - /// Return the validated extension name without changing its spelling. - #[must_use] - pub(in crate::telemetry) fn as_str(&self) -> &str { - &self.0 - } -} - -impl TryFrom for PublicExtensionName { - type Error = PublicExtensionNameError; - - fn try_from(value: String) -> Result { - validate_public_extension_name(&value)?; - Ok(Self(value)) - } -} - -impl FromStr for PublicExtensionName { - type Err = PublicExtensionNameError; - - fn from_str(value: &str) -> Result { - validate_public_extension_name(value)?; - Ok(Self(value.to_owned())) - } -} - -impl fmt::Display for PublicExtensionName { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - -impl Serialize for PublicExtensionName { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl<'de> Deserialize<'de> for PublicExtensionName { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - String::deserialize(deserializer)? - .try_into() - .map_err(D::Error::custom) +validated_string_newtype! { + /// Public plugin or skill name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicExtensionName { + error = PublicExtensionNameError; + validate = validate_public_extension_name; + as_str_doc = "Return the validated extension name without changing its spelling."; } } diff --git a/src/telemetry/schema/name.rs b/src/telemetry/schema/name.rs index 8806e528..37a49445 100644 --- a/src/telemetry/schema/name.rs +++ b/src/telemetry/schema/name.rs @@ -1,5 +1,67 @@ //! Validation shared by public names in the telemetry contract. +/// Define a string newtype whose constructors and deserializer enforce one +/// validation function. +macro_rules! validated_string_newtype { + ( + $(#[$metadata:meta])* + $visibility:vis struct $name:ident { + error = $error:ty; + validate = $validate:path; + as_str_doc = $as_str_doc:literal; + } + ) => { + $(#[$metadata])* + #[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, + )] + #[serde(try_from = "String")] + $visibility struct $name(String); + + impl $name { + #[doc = $as_str_doc] + #[must_use] + $visibility fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = $error; + + fn try_from(value: String) -> Result { + ($validate)(&value)?; + Ok(Self(value)) + } + } + + impl std::str::FromStr for $name { + type Err = $error; + + fn from_str(value: &str) -> Result { + ($validate)(value)?; + Ok(Self(value.to_owned())) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +pub(super) use validated_string_newtype; + /// Rule applied to the first byte of a public name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum InitialByteRule { diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index 253af718..d77b8c3c 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::super::{ EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, - name::{InitialByteRule, PublicNameViolation, validate_public_name}, + name::{InitialByteRule, PublicNameViolation, validate_public_name, validated_string_newtype}, }; use crate::telemetry::identity::{ DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, @@ -40,59 +40,12 @@ pub(in crate::telemetry) enum ExtensionMatch { None, } -/// Public package name accepted by the version 1 telemetry contract. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(in crate::telemetry) struct PublicPackageName(String); - -impl PublicPackageName { - /// Return the validated package name. - #[must_use] - pub(in crate::telemetry) fn as_str(&self) -> &str { - &self.0 - } -} - -impl TryFrom for PublicPackageName { - type Error = PublicPackageNameError; - - fn try_from(value: String) -> Result { - validate_public_package_name(&value)?; - Ok(Self(value)) - } -} - -impl FromStr for PublicPackageName { - type Err = PublicPackageNameError; - - fn from_str(value: &str) -> Result { - validate_public_package_name(value)?; - Ok(Self(value.to_owned())) - } -} - -impl fmt::Display for PublicPackageName { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - -impl Serialize for PublicPackageName { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl<'de> Deserialize<'de> for PublicPackageName { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - String::deserialize(deserializer)? - .try_into() - .map_err(D::Error::custom) +validated_string_newtype! { + /// Public package name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicPackageName { + error = PublicPackageNameError; + validate = validate_public_package_name; + as_str_doc = "Return the validated package name."; } } From e418e1af45ea54e240a488aeee20320bdb5bddff Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 16:59:19 +0300 Subject: [PATCH 38/57] Bind telemetry subjects to their identity anchors Package resolution rows now derive package_subject from the validated coordinate and private identity state instead of accepting an unrelated identifier. Separate identifier-window and return-cohort scopes make the anchor choice part of each identity domain. Seal the domain registry and pin its anchor mapping in the recorded-data contract. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 22 +- src/telemetry/identity.rs | 240 ++++++++++++------ src/telemetry/schema/resolution/package.rs | 41 ++- src/telemetry/state/mod.rs | 64 ++++- 5 files changed, 268 insertions(+), 101 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index f8e4339d..ecd1b6d6 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns public extension vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. 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. +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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns public extension vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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 8295dca8..85879596 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -70,20 +70,20 @@ HMAC( ) ``` -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 window is the anchor's ASCII `YYYY-MM-DD` representation from `telemetry-state.toml`. Dimension fields use the exact UTF-8 bytes of their stable labels and validated strings, without case folding or Unicode normalization. Length framing keeps field boundaries unambiguous even when a value contains a NUL byte. Domains with no dimension fields end after the framed window. The domain strings, wire prefixes, and ordered dimension fields are frozen for consent version 1: -| Identifier | HMAC domain | Wire prefix | 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. | +| Identifier | HMAC domain | Wire prefix | Window anchor | Ordered dimension fields | +| --- | --- | --- | --- | --- | +| `session_id` | `session_id` | `sess_` | `identifier-window` | Agent, vendor session id. | +| `retention_subject` | `retention_subject` | `ret_` | `return-cohort` | None. | +| `agent_subject` | `agent_subject` | `agt_` | `identifier-window` | Agent. | +| `package_subject` | `package_subject` | `pkg_` | `identifier-window` | Package ecosystem, name, exact version. | +| `extension_subject` | `extension_subject` | `ext_` | `identifier-window` | Target type, source, name, then the complete safe resolution path. | +| `hook_subject` | `hook_subject` | `hok_` | `identifier-window` | Agent, hook surface. | +| `plugin_subject` | `plugin_subject` | `plg_` | `identifier-window` | Public source, plugin name. | +| `command_subject` | `command_subject` | `cmd_` | `identifier-window` | Command type, then its typed coordinate fields in event order. | Structured values such as an extension path use the same framing recursively. A sequence starts with its eight-byte unsigned big-endian item count. Each variant starts with its framed type label, followed by its fields in the order used by the corresponding event schema. Identity code owns this encoding; telemetry producers pass typed coordinates rather than concatenating strings. diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 21d47089..0a676472 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -56,20 +56,50 @@ impl IdentityKey { } } -/// Canonical bytes for an identifier-window or return-cohort anchor. +/// Identity material bound to one canonical rotation window. /// -/// Keeping this distinct from a dimension makes their order in the HMAC input -/// impossible to swap accidentally. -struct IdentityWindow<'a>(&'a [u8]); +/// Private telemetry state creates this handle after applying lifecycle +/// transitions. Subject-bearing row constructors can use it to derive an +/// identifier from the corresponding source value instead of accepting the two +/// independently. +pub(super) struct IdentityScope<'a, A> { + key: &'a IdentityKey, + window: String, + anchor: PhantomData, +} -#[cfg(test)] -impl<'a> IdentityWindow<'a> { +impl<'a, A> IdentityScope<'a, A> { + /// Bind a private identity key to one canonical window value. + /// + /// In production, `window` comes from a validated state anchor. Keeping the + /// conversion here avoids making the identity module depend on row schema + /// types. #[must_use] - const fn from_bytes(bytes: &'a [u8]) -> Self { - Self(bytes) + pub(super) fn new(key: &'a IdentityKey, window: String) -> Self { + Self { + key, + window, + anchor: PhantomData, + } + } + + /// Derive the identifier belonging to a typed source value. + #[must_use] + pub(super) fn derive(&self, dimension: &I) -> ScopedId + where + I: IdentityDimension, + I::Domain: ScopedIdDomain, + { + derive_scoped_id(self.key, self.window.as_bytes(), dimension) } } +/// Identity material bound to the active 30-day identifier window. +pub(super) type IdentifierWindowScope<'a> = IdentityScope<'a, IdentifierWindowAnchor>; + +/// Identity material bound to the active D0-D30 return cohort. +pub(super) type ReturnCohortScope<'a> = IdentityScope<'a, ReturnCohortAnchor>; + /// A typed value that supplies one identifier domain's canonical fields. /// /// Each schema type implements this trait for the domain it belongs to. This @@ -194,48 +224,65 @@ impl Hash for ScopedId { } } +mod sealed { + pub trait Sealed {} +} + +/// Marker for the state anchor that scopes an identifier domain. +pub(super) trait IdentityAnchor: sealed::Sealed { + const CONTRACT_NAME: &'static str; +} + +/// The anchor shared by identifiers that rotate on the 30-day window. +pub(super) enum IdentifierWindowAnchor {} + +impl sealed::Sealed for IdentifierWindowAnchor {} + +impl IdentityAnchor for IdentifierWindowAnchor { + const CONTRACT_NAME: &'static str = "identifier-window"; +} + +/// The anchor dedicated to D0-D30 return measurement. +pub(super) enum ReturnCohortAnchor {} + +impl sealed::Sealed for ReturnCohortAnchor {} + +impl IdentityAnchor for ReturnCohortAnchor { + const CONTRACT_NAME: &'static str = "return-cohort"; +} + /// Marker supplying a [`ScopedId`] domain's frozen derivation and wire labels. /// -/// Private, which seals it: both constants are published contract surfaces, -/// not extension points. Changing either requires a new consent version. -trait ScopedIdDomain { +/// Visible only inside telemetry so typed derivation APIs can name the bound. +/// The real domains remain declared centrally below: these constants are +/// published contract surfaces, not general extension points. Changing an +/// anchor category or either string requires a new consent version. +pub(super) trait ScopedIdDomain: sealed::Sealed { + type Anchor: IdentityAnchor; + const PREFIX: &'static str; const HMAC_DOMAIN: &'static str; } -/// 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: &I) -> ScopedId - where - I: IdentityDimension, - I::Domain: 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(I::Domain::HMAC_DOMAIN.as_bytes()); - hmac.update(b":v1\0"); - write_frame(window.0, |bytes| hmac.update(bytes)); - let mut update = |bytes: &[u8]| hmac.update(bytes); - let mut writer = DimensionWriter::new(&mut update); - dimension.write(&mut writer); +fn derive_scoped_id(key: &IdentityKey, window: &[u8], dimension: &I) -> ScopedId +where + I: IdentityDimension, + I::Domain: ScopedIdDomain, +{ + let mut hmac = + HmacSha256::new_from_slice(&key.0).expect("BUG: HMAC-SHA-256 must accept a 32-byte key"); + hmac.update(b"telemetry:"); + hmac.update(I::Domain::HMAC_DOMAIN.as_bytes()); + hmac.update(b":v1\0"); + write_frame(window, |bytes| hmac.update(bytes)); + let mut update = |bytes: &[u8]| hmac.update(bytes); + let mut writer = DimensionWriter::new(&mut update); + dimension.write(&mut writer); - let digest = hmac.finalize().into_bytes(); - let mut bytes = [0; IDENTIFIER_BYTES]; - bytes.copy_from_slice(&digest[..IDENTIFIER_BYTES]); - ScopedId::from_bytes(bytes) - } + 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. @@ -437,7 +484,7 @@ pub(super) mod state_key_hex { } } -/// Declares each identifier domain, its contract prefix, and its alias. +/// Declares each identifier domain, its anchor, contract strings, and alias. /// /// A macro because a function cannot introduce types, and the prefix is the /// hand-written part worth handing to the tests as a table. @@ -445,6 +492,7 @@ macro_rules! scoped_id_domains { ( $( $domain:ident => $alias:ident { + anchor: $anchor:ty, hmac_domain: $hmac_domain:literal, wire_prefix: $prefix:literal, } @@ -453,7 +501,11 @@ macro_rules! scoped_id_domains { $( pub(super) enum $domain {} + impl sealed::Sealed for $domain {} + impl ScopedIdDomain for $domain { + type Anchor = $anchor; + const PREFIX: &'static str = $prefix; const HMAC_DOMAIN: &'static str = $hmac_domain; } @@ -467,42 +519,50 @@ macro_rules! scoped_id_domains { ]; #[cfg(test)] - const DOMAIN_CONTRACTS: &[(&str, &str)] = &[ - $(($hmac_domain, $prefix)),+ + const DOMAIN_CONTRACTS: &[(&str, &str, &str)] = &[ + $(($hmac_domain, $prefix, <$anchor as IdentityAnchor>::CONTRACT_NAME)),+ ]; }; } scoped_id_domains! { SessionDomain => SessionId { + anchor: IdentifierWindowAnchor, hmac_domain: "session_id", wire_prefix: "sess_", } RetentionDomain => RetentionSubject { + anchor: ReturnCohortAnchor, hmac_domain: "retention_subject", wire_prefix: "ret_", } AgentDomain => AgentSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "agent_subject", wire_prefix: "agt_", } PackageDomain => PackageSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "package_subject", wire_prefix: "pkg_", } ExtensionDomain => ExtensionSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "extension_subject", wire_prefix: "ext_", } HookDomain => HookSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "hook_subject", wire_prefix: "hok_", } PluginDomain => PluginSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "plugin_subject", wire_prefix: "plg_", } CommandDomain => CommandSubject { + anchor: IdentifierWindowAnchor, hmac_domain: "command_subject", wire_prefix: "cmd_", } @@ -530,7 +590,11 @@ mod tests { enum TestDomain {} + impl sealed::Sealed for TestDomain {} + impl ScopedIdDomain for TestDomain { + type Anchor = IdentifierWindowAnchor; + const PREFIX: &'static str = "test_"; const HMAC_DOMAIN: &'static str = "test_subject"; } @@ -569,6 +633,14 @@ mod tests { } } + struct TestRetentionDimension; + + impl IdentityDimension for TestRetentionDimension { + type Domain = RetentionDomain; + + fn write(&self, _writer: &mut DimensionWriter<'_>) {} + } + struct TestNestedSequenceDimension; impl IdentityDimension for TestNestedSequenceDimension { @@ -643,11 +715,10 @@ mod tests { #[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 identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); let dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); - let identifier = deriver.derive(&window, &dimension); + let identifier = identity.derive(&dimension); // Cross-checked with .NET's HMACSHA256 over the contract's header and // two unsigned 64-bit big-endian length-prefixed values. The complete @@ -661,34 +732,49 @@ mod tests { #[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 identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); let dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); - let first = deriver.derive(&window, &dimension); - let second = deriver.derive(&window, &dimension); + let first = identity.derive(&dimension); + let second = identity.derive(&dimension); assert_eq!(first, second); } + #[test] + fn return_cohort_scope_derives_retention_subjects() { + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let identity = ReturnCohortScope::new(&key, "2026-08-03".to_owned()); + + let identifier = identity.derive(&TestRetentionDimension); + + // Cross-checked with .NET's HMACSHA256 over the retention header and + // framed cohort anchor. The complete digest is + // 270adecd2120c543261f04bd771df49170e407de5d3116f98a0468f832fcfcbb. + assert_eq!( + identifier.to_string(), + "ret_270adecd2120c543261f04bd771df491" + ); + } + #[test] fn length_framing_separates_nul_at_different_boundaries() { - let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); - let first_window = IdentityWindow::from_bytes(b"a\0b"); + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let first_identity = IdentifierWindowScope::new(&key, "a\0b".to_owned()); let first_dimension = TestDimension::::from_fields([b"c".as_slice()]); - let second_window = IdentityWindow::from_bytes(b"a"); + let second_identity = IdentifierWindowScope::new(&key, "a".to_owned()); let second_dimension = TestDimension::::from_fields([b"b\0c".as_slice()]); - let first = deriver.derive(&first_window, &first_dimension); - let second = deriver.derive(&second_window, &second_dimension); + let first = first_identity.derive(&first_dimension); + let second = second_identity.derive(&second_dimension); assert_ne!(first, second); } #[test] fn length_framing_separates_dimension_field_boundaries() { - let deriver = IdentityDeriver::new(IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES])); - let window = IdentityWindow::from_bytes(b"window-1"); + let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); let first_dimension = TestDimension::::from_fields([ b"cargo".as_slice(), b"foo1".as_slice(), @@ -700,8 +786,8 @@ mod tests { b"12.3.4".as_slice(), ]); - let first = deriver.derive(&window, &first_dimension); - let second = deriver.derive(&window, &second_dimension); + let first = identity.derive(&first_dimension); + let second = identity.derive(&second_dimension); assert_ne!(first, second); } @@ -750,11 +836,11 @@ mod tests { #[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 key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); + let other_key = IdentityKey::from_bytes([0x24; IDENTITY_KEY_BYTES]); + let identity = IdentifierWindowScope::new(&key, "window-1".to_owned()); + let other_key_identity = IdentifierWindowScope::new(&other_key, "window-1".to_owned()); + let other_window_identity = IdentifierWindowScope::new(&key, "window-2".to_owned()); let session_dimension = TestDimension::::from_fields([b"dimension-1".as_slice()]); let other_session_dimension = @@ -765,15 +851,11 @@ mod tests { // 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, + identity.derive(&session_dimension).bytes, + other_key_identity.derive(&session_dimension).bytes, + identity.derive(&command_dimension).bytes, + other_window_identity.derive(&session_dimension).bytes, + identity.derive(&other_session_dimension).bytes, ]; let unique_identifiers = identifiers.into_iter().collect::>(); @@ -823,7 +905,7 @@ mod tests { fn hmac_domains_are_distinct() { let domains = DOMAIN_CONTRACTS .iter() - .map(|(domain, _)| *domain) + .map(|(domain, _, _)| *domain) .collect::>(); assert_eq!(domains.len(), DOMAIN_CONTRACTS.len()); @@ -831,8 +913,8 @@ mod tests { #[test] fn derivation_constants_match_the_recorded_data_contract() { - for (domain, prefix) in DOMAIN_CONTRACTS { - let contract_row = format!("| `{domain}` | `{domain}` | `{prefix}` |"); + for (domain, prefix, anchor) in DOMAIN_CONTRACTS { + let contract_row = format!("| `{domain}` | `{domain}` | `{prefix}` | `{anchor}` |"); assert!( RECORDED_DATA.contains(&contract_row), diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index d77b8c3c..be2bec58 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -10,7 +10,7 @@ use super::super::{ name::{InitialByteRule, PublicNameViolation, validate_public_name, validated_string_newtype}, }; use crate::telemetry::identity::{ - DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, + DimensionWriter, IdentifierWindowScope, IdentityDimension, PackageDomain, PackageSubject, }; const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; @@ -301,11 +301,13 @@ impl PackageResolutionV1 { /// Create a record for one eligible public resolution-input package. #[must_use] pub(in crate::telemetry) fn new( + identity: &IdentifierWindowScope<'_>, day: UtcDay, package: PublicPackageCoordinate, extension_match: ExtensionMatch, - package_subject: PackageSubject, ) -> Self { + let package_subject = identity.derive(&package); + Self { version: SchemaVersion::V1, kind: RowKind::PackageResolution, @@ -325,7 +327,14 @@ mod tests { use super::super::super::{assert_contract_names, assert_contract_names_with_labels}; use super::*; - use crate::telemetry::identity::encode_dimension_for_test; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; + + const TEST_STATE: &str = r#"version = 1 + +[identity] +key = "4242424242424242424242424242424242424242424242424242424242424242" +identifier-window-anchor = "2026-08-03" +"#; fn package_name(value: &str) -> PublicPackageName { value.parse().unwrap() @@ -336,12 +345,19 @@ mod tests { } fn package_resolution() -> PackageResolutionV1 { + package_resolution_for("example-runtime") + } + + fn package_resolution_for(package_name: &str) -> PackageResolutionV1 { + let state: TelemetryStateV1 = toml::from_str(TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + PackageResolutionV1::new( + &identity, UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()), - PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, "example-runtime", "1.2.3") + PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, package_name, "1.2.3") .unwrap(), ExtensionMatch::Public, - "pkg_f6db813c87209816ae4896f3e60dd774".parse().unwrap(), ) } @@ -558,9 +574,12 @@ mod tests { } #[test] - fn new_package_resolution_uses_fixed_common_fields() { + fn new_package_resolution_derives_subject_from_its_coordinate() { let expected_day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); - let expected_subject = "pkg_f6db813c87209816ae4896f3e60dd774".parse().unwrap(); + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, ecosystem, published name, and exact version. The + // complete digest is a7907f7a5ae0de9ae55469f276ba73b2fe68e9b97c4bdd1867dd0685acd297ee. + let expected_subject = "pkg_a7907f7a5ae0de9ae55469f276ba73b2".parse().unwrap(); let row = package_resolution(); @@ -576,6 +595,14 @@ mod tests { assert_eq!(row.package_subject, expected_subject); } + #[test] + fn package_subject_changes_with_the_source_coordinate() { + let first = package_resolution_for("example-runtime"); + let second = package_resolution_for("example-tools"); + + assert_ne!(first.package_subject, second.package_subject); + } + #[test] fn package_resolution_rejects_future_version() { let json = serde_json::to_string(&package_resolution()).unwrap(); diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index 8bcb0b2e..490d54f8 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::{ - identity::{IdentityKey, state_key_hex}, + identity::{IdentifierWindowScope, IdentityKey, ReturnCohortScope, state_key_hex}, schema::UtcDay, }; @@ -57,7 +57,7 @@ impl<'de> Deserialize<'de> for StateVersion { /// must not silently turn malformed state into valid state. #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] -struct TelemetryStateV1 { +pub(super) struct TelemetryStateV1 { version: StateVersion, identity: IdentityState, } @@ -87,6 +87,33 @@ impl TelemetryStateV1 { }, } } + + /// Bind the stored key to the active identifier-window anchor. + /// + /// Call this only after applying any lifecycle transition for the current + /// operation, so every derived identifier uses the state that will be + /// persisted before its row is appended. + #[must_use] + pub(super) fn identifier_window_scope(&self) -> IdentifierWindowScope<'_> { + IdentifierWindowScope::new( + &self.identity.key, + self.identity.identifier_window_anchor.to_string(), + ) + } + + /// Bind the stored key to the active return-cohort anchor, when present. + /// + /// A newly enabled or reset recorder has no return cohort until its first + /// session observation. Call this after applying that observation so a D31 + /// rollover uses the newly selected anchor. + #[must_use] + pub(super) fn return_cohort_scope(&self) -> Option> { + let anchor = self.identity.return_cohort_anchor?; + Some(ReturnCohortScope::new( + &self.identity.key, + anchor.to_string(), + )) + } } /// Stable identity material and the dates that define its rotation windows. @@ -107,13 +134,24 @@ mod tests { use chrono::NaiveDate; use super::{IdentityKey, TelemetryStateV1}; - use crate::telemetry::schema::UtcDay; + use crate::telemetry::{ + identity::{DimensionWriter, IdentityDimension, RetentionDomain}, + 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"; + struct RetentionDimension; + + impl IdentityDimension for RetentionDimension { + type Domain = RetentionDomain; + + fn write(&self, _writer: &mut DimensionWriter<'_>) {} + } + fn day(year: i32, month: u32, day: u32) -> UtcDay { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } @@ -193,9 +231,29 @@ mod tests { let serialized = toml::to_string_pretty(&state).unwrap(); assert!(state.identity.return_cohort_anchor.is_none()); + assert!(state.return_cohort_scope().is_none()); assert_eq!(serialized, source); } + #[test] + fn return_cohort_scope_uses_the_return_cohort_anchor() { + let first: TelemetryStateV1 = + toml::from_str(&state_with_anchors(KEY, "2026-09-10", "2026-08-11")).unwrap(); + let second: TelemetryStateV1 = + toml::from_str(&state_with_anchors(KEY, "2026-09-10", "2026-08-12")).unwrap(); + + let first_subject = first + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + let second_subject = second + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + + assert_ne!(first_subject, second_subject); + } + #[test] fn future_state_version_is_rejected() { let source = state_with_return_cohort(KEY).replacen("version = 1", "version = 2", 1); From 1e0e5feaa3965cf74b5bbb6025ec60873ff1a314 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 17:31:13 +0300 Subject: [PATCH 39/57] Define public extension coordinates Represent public plugins and skills as validated type, source, and name coordinates. Reject incomplete, expanded, or invalid wire forms before they can enter telemetry rows. --- md/design/module-structure.md | 2 +- src/telemetry/schema/extension.rs | 124 ++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index ecd1b6d6..8cecbef5 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns public extension vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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/src/telemetry/schema/extension.rs b/src/telemetry/schema/extension.rs index 1a1439ba..ae01048c 100644 --- a/src/telemetry/schema/extension.rs +++ b/src/telemetry/schema/extension.rs @@ -105,6 +105,60 @@ impl fmt::Display for PublicExtensionNameError { impl std::error::Error for PublicExtensionNameError {} +/// Public plugin or skill coordinate safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicExtensionCoordinate { + #[serde(rename = "type")] + kind: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, +} + +impl PublicExtensionCoordinate { + /// Combine validated components into one public extension coordinate. + #[must_use] + pub(in crate::telemetry) const fn new( + kind: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, + ) -> Self { + Self { kind, source, name } + } + + /// Validate a raw public name and combine it with its typed coordinate. + /// + /// # Errors + /// + /// Returns an error when `name` is outside the version 1 public extension + /// grammar. + pub(in crate::telemetry) fn try_new( + kind: ExtensionKind, + source: PublicExtensionSource, + name: &str, + ) -> Result { + Ok(Self::new(kind, source, name.parse()?)) + } + + /// Return the public plugin or skill kind. + #[must_use] + pub(in crate::telemetry) const fn kind(&self) -> ExtensionKind { + self.kind + } + + /// Return the allowlisted public source. + #[must_use] + pub(in crate::telemetry) const fn source(&self) -> PublicExtensionSource { + self.source + } + + /// Return the validated public extension name. + #[must_use] + pub(in crate::telemetry) const fn name(&self) -> &PublicExtensionName { + &self.name + } +} + #[cfg(test)] mod tests { use super::super::assert_contract_names_with_labels; @@ -206,4 +260,74 @@ mod tests { assert!(invalid.is_err()); } + + #[test] + fn public_extension_coordinate_round_trips_in_contract_order() { + let coordinate = PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "Example-debugging_2", + ) + .unwrap(); + + let json = serde_json::to_string(&coordinate).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + r#"{"type":"skill","source":"symposium-recommendations","name":"Example-debugging_2"}"# + ); + assert_eq!(decoded, coordinate); + } + + #[test] + fn public_extension_coordinate_exposes_its_validated_components() { + let name = "example-tools".parse::().unwrap(); + let coordinate = PublicExtensionCoordinate::new( + ExtensionKind::Plugin, + PublicExtensionSource::CratesIo, + name.clone(), + ); + + assert_eq!(coordinate.kind(), ExtensionKind::Plugin); + assert_eq!(coordinate.source(), PublicExtensionSource::CratesIo); + assert_eq!(coordinate.name(), &name); + } + + #[test] + fn public_extension_coordinate_rejects_unknown_fields() { + let json = + r#"{"type":"plugin","source":"crates-io","name":"example-tools","path":"private"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_extension_coordinate_requires_every_field() { + let json = r#"{"type":"plugin","source":"crates-io"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn public_extension_coordinate_validates_its_nested_name() { + let raw_result = PublicExtensionCoordinate::try_new( + ExtensionKind::Plugin, + PublicExtensionSource::CratesIo, + "private/plugin", + ); + let json_result = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","name":"private/plugin"}"#, + ); + + assert_eq!( + raw_result.unwrap_err(), + PublicExtensionNameError::UnsupportedCharacter + ); + assert!(json_result.is_err()); + } } From 3e5549bc485ca86f9eeb7c53364b2558c9be5483 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 18:37:17 +0300 Subject: [PATCH 40/57] Define extension resolution path nodes Add strict recursive nodes for public extension resolution evidence. Reuse validated coordinates and test the published JSON as the contract. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 17 ++ src/telemetry/schema/mod.rs | 30 ++- src/telemetry/schema/resolution.rs | 1 + src/telemetry/schema/resolution/extension.rs | 195 ++++++++++++++++++ 5 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 src/telemetry/schema/resolution/extension.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 8cecbef5..d20f0fa1 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; the strict recursive node vocabulary for safe extension-resolution paths lives in `resolution/extension.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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 85879596..34d24a61 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -232,6 +232,23 @@ Path nodes are limited to: | `not` | Marker only; the child is not recorded. | | `opaque` | Fixed reason: `private_source`, `non_package_predicate`, or `limit`. | +The exact version 1 node shapes are shown below. These examples use public +placeholder coordinates; the same package and extension validation rules +described above apply inside path nodes. + +```json +{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"} +{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"} +{"type":"all","children":[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"any","child":{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}},{"type":"not"},{"type":"opaque","reason":"limit"}]} +{"type":"any","child":{"type":"not"}} +{"type":"not"} +{"type":"opaque","reason":"non_package_predicate"} +``` + +`all.children` contains one or more nodes. `any.child` contains the one branch +that made the expression succeed. Each object is strict: missing fields, +additional fields, unknown node types, and unknown opaque reasons are invalid. + Shell commands, paths, environment variables, custom predicate names or arguments, and private package or extension names never enter a path. An opaque marker can represent their position. Witness depth counts nested evidence nodes from the root, which is level 1, to a terminal package, extension, `not`, or opaque node. A subtree that would exceed level 8 becomes `opaque: limit`. The complete path is also limited to 16 evidence leaves and 4 KiB. Evidence depth does not count filesystem components; filesystem paths are never recorded. diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index ef114d96..60caad3b 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -415,6 +415,25 @@ where } } +#[cfg(test)] +const RECORDED_DATA_CONTRACT: &str = + include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); + +#[cfg(test)] +fn recorded_data_example_block(section_heading: &str, opening_fence: &str) -> &'static str { + let (_, after_heading) = RECORDED_DATA_CONTRACT + .split_once(section_heading) + .unwrap_or_else(|| panic!("recorded-data contract must contain {section_heading}")); + let (_, after_fence) = after_heading + .split_once(opening_fence) + .unwrap_or_else(|| panic!("{section_heading} must contain an {opening_fence} block")); + let (example_block, _) = after_fence + .split_once("```") + .unwrap_or_else(|| panic!("{section_heading} example block must have a closing fence")); + + example_block +} + #[cfg(test)] fn assert_contract_names(cases: &[(T, &str)]) where @@ -445,16 +464,9 @@ where mod tests { use super::*; - const RECORDED_DATA: &str = - include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); - fn example_row(requested_kind: &str) -> &'static str { - let (_, after_fence) = RECORDED_DATA - .split_once("```jsonl") - .expect("recorded-data contract must contain a JSONL example block"); - let (example_block, _) = after_fence - .split_once("```") - .expect("recorded-data JSONL example block must have a closing fence"); + let example_block = + recorded_data_example_block("## Example JSONL for every row kind", "```jsonl"); example_block .lines() diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 5cc56fbb..4760baf9 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -1,5 +1,6 @@ //! Schema types for resolution telemetry. +pub(in crate::telemetry) mod extension; pub(in crate::telemetry) mod package; use std::fmt; diff --git a/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs new file mode 100644 index 00000000..d251edab --- /dev/null +++ b/src/telemetry/schema/resolution/extension.rs @@ -0,0 +1,195 @@ +//! Safe extension-resolution path vocabulary. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + super::extension::{ + ExtensionKind, PublicExtensionCoordinate, PublicExtensionName, PublicExtensionSource, + }, + package::PublicPackageCoordinate, +}; + +/// Safe evidence node in a successful extension-resolution path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResolutionPathNode { + Package(PublicPackageCoordinate), + Extension(ExtensionNode), + All(AllNode), + Any(AnyNode), + Not(NotNode), + Opaque(OpaqueNode), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionNode { + extension_type: ExtensionKind, + source: PublicExtensionSource, + name: PublicExtensionName, +} + +impl From for ExtensionNode { + fn from(coordinate: PublicExtensionCoordinate) -> Self { + Self { + extension_type: coordinate.kind(), + source: coordinate.source(), + name: coordinate.name().clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawAllNode")] +struct AllNode { + children: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAllNode { + children: Vec, +} + +impl TryFrom for AllNode { + type Error = EmptyAllNode; + + fn try_from(raw: RawAllNode) -> Result { + if raw.children.is_empty() { + return Err(EmptyAllNode); + } + + Ok(Self { + children: raw.children, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EmptyAllNode; + +impl fmt::Display for EmptyAllNode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("all resolution node must contain at least one child") + } +} + +impl std::error::Error for EmptyAllNode {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AnyNode { + child: Box, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NotNode {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpaqueNode { + reason: OpaqueResolutionReason, +} + +/// Fixed explanation for resolution evidence that is unsafe to name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum OpaqueResolutionReason { + PrivateSource, + NonPackagePredicate, + Limit, +} + +#[cfg(test)] +mod tests { + use super::super::super::recorded_data_example_block; + use super::*; + + fn documented_path_node_examples() -> impl Iterator { + let example_block = recorded_data_example_block("### `extension_resolution`", "```json"); + + example_block.lines().filter(|line| !line.is_empty()) + } + + #[test] + fn documented_resolution_path_nodes_round_trip_in_contract_shape() { + for json in documented_path_node_examples() { + let node = serde_json::from_str::(json).unwrap(); + let encoded = serde_json::to_string(&node).unwrap(); + + assert_eq!(encoded, json); + } + } + + #[test] + fn extension_node_is_built_from_a_validated_coordinate() { + let coordinate = PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "example-debugging", + ) + .unwrap(); + + let node = ResolutionPathNode::Extension(coordinate.into()); + let encoded = serde_json::to_string(&node).unwrap(); + + assert_eq!( + encoded, + r#"{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"example-debugging"}"# + ); + } + + #[test] + fn resolution_path_nodes_reject_unknown_nested_fields() { + let json = r#"{"type":"any","child":{"type":"not","predicate":"private"}}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn resolution_path_nodes_require_their_contract_fields() { + let json = r#"{"type":"extension","extension_type":"skill","name":"example-debugging"}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn all_resolution_node_requires_at_least_one_child() { + let json = r#"{"type":"all","children":[]}"#; + + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } + + #[test] + fn resolution_path_nodes_reject_unknown_contract_vocabulary() { + let unknown_type = serde_json::from_str::(r#"{"type":"custom"}"#); + let unknown_reason = serde_json::from_str::( + r#"{"type":"opaque","reason":"private_predicate"}"#, + ); + + assert!(unknown_type.is_err()); + assert!(unknown_reason.is_err()); + } + + #[test] + fn resolution_path_nodes_validate_public_coordinates() { + let invalid_package = serde_json::from_str::( + r#"{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2"}"#, + ); + let invalid_extension = serde_json::from_str::( + r#"{"type":"extension","extension_type":"skill","source":"symposium-recommendations","name":"private/skill"}"#, + ); + + assert!(invalid_package.is_err()); + assert!(invalid_extension.is_err()); + } +} From 328069daf76063da494653d5d946bdc9a7736bed Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 19:49:54 +0300 Subject: [PATCH 41/57] Validate extension resolution paths Wrap extension resolution evidence in a validated path type before it is used by an event row. Reject empty paths and enforce the contract's depth, leaf-count, and encoded-size limits during construction and JSON deserialization. Document the exact boundaries and cover recursive all/any paths. --- md/design/module-structure.md | 2 +- md/rfds/telemetry-recording/README.md | 2 +- .../contract/recorded-data.md | 2 +- src/telemetry/schema/resolution/extension.rs | 321 ++++++++++++++++++ 4 files changed, 324 insertions(+), 3 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d20f0fa1..edbd64dd 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; the strict recursive node vocabulary for safe extension-resolution paths lives in `resolution/extension.rs`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary and the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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 7e6cf712..63ea1b63 100644 --- a/md/rfds/telemetry-recording/README.md +++ b/md/rfds/telemetry-recording/README.md @@ -229,7 +229,7 @@ Safe nodes are public `package` and `extension` coordinates, `all` contributors, Shell commands, paths, environment values, custom predicate details, workspace members, wildcards, private names, and a negated child never enter the path. -Witness depth counts nested evidence nodes from the root, which is level 1, to a terminal package, extension, `not`, or opaque node. A subtree that would exceed level 8 becomes `opaque: limit`. The complete path is also limited to 16 evidence leaves and 4 KiB. These limits do not count filesystem path components; filesystem paths are never recorded. +The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, `not`, or opaque node. A subtree that would exceed depth 8 becomes `opaque: limit`. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. These limits do not count filesystem path components; filesystem paths are never recorded. Full sync builds safe evidence for successful installations because the generated attribution index needs it even when telemetry is disabled. Only an enabled recorder serializes that evidence as telemetry. diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index 34d24a61..4e1ab020 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -251,7 +251,7 @@ additional fields, unknown node types, and unknown opaque reasons are invalid. Shell commands, paths, environment variables, custom predicate names or arguments, and private package or extension names never enter a path. An opaque marker can represent their position. -Witness depth counts nested evidence nodes from the root, which is level 1, to a terminal package, extension, `not`, or opaque node. A subtree that would exceed level 8 becomes `opaque: limit`. The complete path is also limited to 16 evidence leaves and 4 KiB. Evidence depth does not count filesystem components; filesystem paths are never recorded. +The path array is non-empty, and each top-level node begins at depth 1. Depth counts nested evidence nodes through a terminal package, extension, `not`, or opaque node. A subtree that would exceed depth 8 becomes `opaque: limit`. The complete path is limited to 16 evidence leaves. Its 4 KiB bound is the byte length of the compact UTF-8 JSON encoding of the complete path array, excluding the surrounding event row. Evidence depth does not count filesystem components; filesystem paths are never recorded. This event says an extension resolved. It does not say that an agent read or used the extension; a matching `extension_invocation_metrics` aggregate separately reports observed agent activation. Version 1 can produce that aggregate only for Claude. diff --git a/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs index d251edab..f4813f4d 100644 --- a/src/telemetry/schema/resolution/extension.rs +++ b/src/telemetry/schema/resolution/extension.rs @@ -11,6 +11,15 @@ use super::{ package::PublicPackageCoordinate, }; +/// Maximum root-to-leaf depth of a recorded resolution path. +const MAX_RESOLUTION_PATH_DEPTH: usize = 8; + +/// Maximum combined terminal-node count in a recorded resolution path. +const MAX_RESOLUTION_PATH_LEAVES: usize = 16; + +/// Maximum compact UTF-8 JSON size of a complete resolution path. +const MAX_RESOLUTION_PATH_ENCODED_BYTES: usize = 4 * 1024; + /// Safe evidence node in a successful extension-resolution path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -23,6 +32,130 @@ enum ResolutionPathNode { Opaque(OpaqueNode), } +impl ResolutionPathNode { + fn validate_depth(&self, depth: usize) -> Result<(), ResolutionPathError> { + if depth > MAX_RESOLUTION_PATH_DEPTH { + return Err(ResolutionPathError::DepthExceeded { + observed: depth, + maximum: MAX_RESOLUTION_PATH_DEPTH, + }); + } + + match self { + Self::All(node) => { + let child_depth = depth + .checked_add(1) + .expect("BUG: resolution path depth is bounded before descending"); + for child in &node.children { + child.validate_depth(child_depth)?; + } + } + Self::Any(node) => { + let child_depth = depth + .checked_add(1) + .expect("BUG: resolution path depth is bounded before descending"); + node.child.validate_depth(child_depth)?; + } + Self::Package(_) | Self::Extension(_) | Self::Not(_) | Self::Opaque(_) => {} + } + + Ok(()) + } + + fn count_leaves(&self, leaf_count: &mut usize) -> Result<(), ResolutionPathError> { + match self { + Self::All(node) => { + for child in &node.children { + child.count_leaves(leaf_count)?; + } + } + Self::Any(node) => node.child.count_leaves(leaf_count)?, + Self::Package(_) | Self::Extension(_) | Self::Not(_) | Self::Opaque(_) => { + let observed = leaf_count + .checked_add(1) + .expect("BUG: resolution path leaf count is bounded before incrementing"); + + if observed > MAX_RESOLUTION_PATH_LEAVES { + return Err(ResolutionPathError::LeafCountExceeded { + observed, + maximum: MAX_RESOLUTION_PATH_LEAVES, + }); + } + + *leaf_count = observed; + } + } + + Ok(()) + } +} + +/// Complete evidence path for one successful extension resolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +struct ResolutionPath(Vec); + +impl TryFrom> for ResolutionPath { + type Error = ResolutionPathError; + + fn try_from(nodes: Vec) -> Result { + if nodes.is_empty() { + return Err(ResolutionPathError::Empty); + } + + for node in &nodes { + node.validate_depth(1)?; + } + + let mut leaf_count = 0; + for node in &nodes { + node.count_leaves(&mut leaf_count)?; + } + + let encoded_size = serde_json::to_vec(&nodes) + .expect("BUG: resolution path nodes must have an infallible JSON representation") + .len(); + if encoded_size > MAX_RESOLUTION_PATH_ENCODED_BYTES { + return Err(ResolutionPathError::EncodedSizeExceeded { + observed: encoded_size, + maximum: MAX_RESOLUTION_PATH_ENCODED_BYTES, + }); + } + + Ok(Self(nodes)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolutionPathError { + Empty, + DepthExceeded { observed: usize, maximum: usize }, + LeafCountExceeded { observed: usize, maximum: usize }, + EncodedSizeExceeded { observed: usize, maximum: usize }, +} + +impl fmt::Display for ResolutionPathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("resolution path must contain at least one node"), + Self::DepthExceeded { observed, maximum } => write!( + formatter, + "resolution path depth {observed} exceeds maximum {maximum}" + ), + Self::LeafCountExceeded { observed, maximum } => write!( + formatter, + "resolution path leaf count {observed} exceeds maximum {maximum}" + ), + Self::EncodedSizeExceeded { observed, maximum } => write!( + formatter, + "resolution path encoded size {observed} bytes exceeds maximum {maximum} bytes" + ), + } + } +} + +impl std::error::Error for ResolutionPathError {} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct ExtensionNode { @@ -114,6 +247,80 @@ mod tests { example_block.lines().filter(|line| !line.is_empty()) } + fn resolution_path_with_depth(depth: usize) -> String { + assert!(depth > 0); + + let mut node = r#"{"type":"not"}"#.to_owned(); + for _ in 1..depth { + node = format!(r#"{{"type":"any","child":{node}}}"#); + } + + format!("[{node}]") + } + + fn resolution_path_with_all_depth(depth: usize) -> String { + assert!(depth > 0); + + let mut node = r#"{"type":"not"}"#.to_owned(); + for _ in 1..depth { + node = format!(r#"{{"type":"all","children":[{node}]}}"#); + } + + format!("[{node}]") + } + + fn resolution_path_with_leaves(leaves: usize) -> String { + let nodes = std::iter::repeat_n(r#"{"type":"not"}"#, leaves) + .collect::>() + .join(","); + + format!("[{nodes}]") + } + + fn package_resolution_path_with_encoded_size(encoded_size: usize) -> String { + const PREFIX: &str = + r#"[{"type":"package","ecosystem":"cargo","name":"a","version":"1.2.3+"#; + const SUFFIX: &str = r#""}]"#; + + let metadata_size = encoded_size + .checked_sub(PREFIX.len() + SUFFIX.len()) + .unwrap(); + let json = format!("{PREFIX}{}{SUFFIX}", "a".repeat(metadata_size)); + + assert_eq!(json.len(), encoded_size); + json + } + + fn resolution_path_with_leaves_nested_under_all_and_any( + left_leaves: usize, + right_leaves: usize, + ) -> String { + let left_children = std::iter::repeat_n(serde_json::json!({ "type": "not" }), left_leaves) + .collect::>(); + let right_children = + std::iter::repeat_n(serde_json::json!({ "type": "not" }), right_leaves) + .collect::>(); + + serde_json::json!([{ + "type": "all", + "children": [ + { "type": "all", "children": left_children }, + { + "type": "any", + "child": { "type": "all", "children": right_children } + } + ] + }]) + .to_string() + } + + fn validate_resolution_path(json: &str) -> Result { + let nodes = serde_json::from_str::>(json) + .expect("BUG: generated test path must contain valid resolution nodes"); + + ResolutionPath::try_from(nodes) + } + #[test] fn documented_resolution_path_nodes_round_trip_in_contract_shape() { for json in documented_path_node_examples() { @@ -124,6 +331,120 @@ mod tests { } } + #[test] + fn non_empty_resolution_path_round_trips_as_an_array() { + let json = r#"[{"type":"not"}]"#; + + let path = serde_json::from_str::(json).unwrap(); + let encoded = serde_json::to_string(&path).unwrap(); + + assert_eq!(encoded, json); + } + + #[test] + fn empty_resolution_path_is_rejected_at_both_boundaries() { + let constructed = ResolutionPath::try_from(Vec::new()); + let deserialized = serde_json::from_str::("[]"); + + assert_eq!(constructed.unwrap_err(), ResolutionPathError::Empty); + assert!( + deserialized + .unwrap_err() + .to_string() + .contains("resolution path must contain at least one node") + ); + } + + #[test] + fn resolution_path_accepts_depth_eight_and_rejects_depth_nine() { + let at_limit = resolution_path_with_depth(8); + let beyond_limit = resolution_path_with_depth(9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::DepthExceeded { + observed: 9, + maximum: 8, + } + ); + } + + #[test] + fn resolution_path_counts_depth_through_all_nodes() { + let at_limit = resolution_path_with_all_depth(8); + let beyond_limit = resolution_path_with_all_depth(9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::DepthExceeded { + observed: 9, + maximum: 8, + } + ); + } + + #[test] + fn resolution_path_accepts_sixteen_leaves_and_rejects_seventeen() { + let at_limit = resolution_path_with_leaves(16); + let beyond_limit = resolution_path_with_leaves(17); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::LeafCountExceeded { + observed: 17, + maximum: 16, + } + ); + } + + #[test] + fn resolution_path_counts_only_terminal_leaves_across_nested_all_and_any_nodes() { + let at_limit = resolution_path_with_leaves_nested_under_all_and_any(8, 8); + let beyond_limit = resolution_path_with_leaves_nested_under_all_and_any(8, 9); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::LeafCountExceeded { + observed: 17, + maximum: 16, + } + ); + } + + #[test] + fn resolution_path_accepts_4096_bytes_and_rejects_4097() { + let at_limit = package_resolution_path_with_encoded_size(4_096); + let beyond_limit = package_resolution_path_with_encoded_size(4_097); + + let accepted = validate_resolution_path(&at_limit); + let rejected = validate_resolution_path(&beyond_limit); + + assert!(accepted.is_ok()); + assert_eq!( + rejected.unwrap_err(), + ResolutionPathError::EncodedSizeExceeded { + observed: 4_097, + maximum: 4_096, + } + ); + } + #[test] fn extension_node_is_built_from_a_validated_coordinate() { let coordinate = PublicExtensionCoordinate::try_new( From 731ff160a8b2756cd1f15ee82093f38905e4c114 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Fri, 11 Sep 2026 20:36:41 +0300 Subject: [PATCH 42/57] Derive stable extension subjects from resolution paths Encode public targets and their validated resolution paths through the shared identity framing rules. Reuse package coordinate encoding so a package node cannot drift from package subjects. Pin every path variant and opaque reason to the wire contract, including an independently checked HMAC vector. Share the fixed identity state fixture between subject tests. --- md/design/module-structure.md | 2 +- src/telemetry/schema/mod.rs | 8 + src/telemetry/schema/resolution/extension.rs | 189 ++++++++++++++++++- src/telemetry/schema/resolution/package.rs | 26 +-- 4 files changed, 210 insertions(+), 15 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index edbd64dd..ecea7c6f 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary and the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the canonical target-and-path encoding for `extension_subject`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 60caad3b..143ec9e7 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -419,6 +419,14 @@ where const RECORDED_DATA_CONTRACT: &str = include_str!("../../../md/rfds/telemetry-recording/contract/recorded-data.md"); +#[cfg(test)] +const IDENTIFIER_WINDOW_TEST_STATE: &str = r#"version = 1 + +[identity] +key = "4242424242424242424242424242424242424242424242424242424242424242" +identifier-window-anchor = "2026-08-03" +"#; + #[cfg(test)] fn recorded_data_example_block(section_heading: &str, opening_fence: &str) -> &'static str { let (_, after_heading) = RECORDED_DATA_CONTRACT diff --git a/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs index f4813f4d..eb7135f6 100644 --- a/src/telemetry/schema/resolution/extension.rs +++ b/src/telemetry/schema/resolution/extension.rs @@ -10,6 +10,9 @@ use super::{ }, package::PublicPackageCoordinate, }; +use crate::telemetry::identity::{ + DimensionWriter, ExtensionDomain, ExtensionSubject, IdentifierWindowScope, IdentityDimension, +}; /// Maximum root-to-leaf depth of a recorded resolution path. const MAX_RESOLUTION_PATH_DEPTH: usize = 8; @@ -33,6 +36,31 @@ enum ResolutionPathNode { } impl ResolutionPathNode { + fn write_identity(&self, writer: &mut DimensionWriter<'_>) { + match self { + Self::Package(coordinate) => writer.variant("package", |writer| { + coordinate.write_identity_fields(writer); + }), + Self::Extension(node) => writer.variant("extension", |writer| { + writer.field(node.extension_type.as_str().as_bytes()); + writer.field(node.source.as_str().as_bytes()); + writer.field(node.name.as_str().as_bytes()); + }), + Self::All(node) => writer.variant("all", |writer| { + writer.sequence(&node.children, |writer, child| { + child.write_identity(writer); + }); + }), + Self::Any(node) => writer.variant("any", |writer| { + node.child.write_identity(writer); + }), + Self::Not(_) => writer.variant("not", |_| {}), + Self::Opaque(node) => writer.variant("opaque", |writer| { + writer.field(node.reason.as_str().as_bytes()); + }), + } + } + fn validate_depth(&self, depth: usize) -> Result<(), ResolutionPathError> { if depth > MAX_RESOLUTION_PATH_DEPTH { return Err(ResolutionPathError::DepthExceeded { @@ -95,6 +123,39 @@ impl ResolutionPathNode { #[serde(try_from = "Vec")] struct ResolutionPath(Vec); +impl ResolutionPath { + /// Derive the subject for this path and its resolved public target. + #[must_use] + fn derive_subject( + &self, + scope: &IdentifierWindowScope<'_>, + target: &PublicExtensionCoordinate, + ) -> ExtensionSubject { + scope.derive(&ExtensionSubjectDimension { target, path: self }) + } + + fn write_identity(&self, writer: &mut DimensionWriter<'_>) { + writer.sequence(&self.0, |writer, node| node.write_identity(writer)); + } +} + +struct ExtensionSubjectDimension<'a> { + target: &'a PublicExtensionCoordinate, + path: &'a ResolutionPath, +} + +impl IdentityDimension for ExtensionSubjectDimension<'_> { + type Domain = ExtensionDomain; + + /// Write the version 1 `extension_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.target.kind().as_str().as_bytes()); + writer.field(self.target.source().as_str().as_bytes()); + writer.field(self.target.name().as_str().as_bytes()); + self.path.write_identity(writer); + } +} + impl TryFrom> for ResolutionPath { type Error = ResolutionPathError; @@ -236,10 +297,24 @@ enum OpaqueResolutionReason { Limit, } +impl OpaqueResolutionReason { + const fn as_str(self) -> &'static str { + match self { + Self::PrivateSource => "private_source", + Self::NonPackagePredicate => "non_package_predicate", + Self::Limit => "limit", + } + } +} + #[cfg(test)] mod tests { - use super::super::super::recorded_data_example_block; + use super::super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names_with_labels, + recorded_data_example_block, + }; use super::*; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; fn documented_path_node_examples() -> impl Iterator { let example_block = recorded_data_example_block("### `extension_resolution`", "```json"); @@ -321,6 +396,28 @@ mod tests { ResolutionPath::try_from(nodes) } + fn append_expected_field(output: &mut Vec, value: &str) { + let length = u64::try_from(value.len()).unwrap(); + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value.as_bytes()); + } + + fn public_target() -> PublicExtensionCoordinate { + PublicExtensionCoordinate::try_new( + ExtensionKind::Skill, + PublicExtensionSource::SymposiumRecommendations, + "example-debugging", + ) + .unwrap() + } + + fn resolution_path_with_every_node_variant() -> ResolutionPath { + serde_json::from_str( + r#"[{"type":"package","ecosystem":"cargo","name":"example-runtime","version":"1.2.3"},{"type":"extension","extension_type":"plugin","source":"crates-io","name":"example-tools"},{"type":"all","children":[{"type":"any","child":{"type":"opaque","reason":"private_source"}},{"type":"not"}]}]"#, + ) + .unwrap() + } + #[test] fn documented_resolution_path_nodes_round_trip_in_contract_shape() { for json in documented_path_node_examples() { @@ -331,6 +428,20 @@ mod tests { } } + #[test] + fn opaque_resolution_reasons_round_trip_with_identity_labels() { + let cases = [ + (OpaqueResolutionReason::PrivateSource, "private_source"), + ( + OpaqueResolutionReason::NonPackagePredicate, + "non_package_predicate", + ), + (OpaqueResolutionReason::Limit, "limit"), + ]; + + assert_contract_names_with_labels(&cases, OpaqueResolutionReason::as_str); + } + #[test] fn non_empty_resolution_path_round_trips_as_an_array() { let json = r#"[{"type":"not"}]"#; @@ -341,6 +452,82 @@ mod tests { assert_eq!(encoded, json); } + #[test] + fn extension_subject_dimension_places_target_before_counted_path() { + let target = public_target(); + let path = serde_json::from_str::(r#"[{"type":"not"}]"#).unwrap(); + let dimension = ExtensionSubjectDimension { + target: &target, + path: &path, + }; + + let encoded = encode_dimension_for_test(&dimension); + let expected = [ + 5_u64.to_be_bytes().as_slice(), + b"skill", + 25_u64.to_be_bytes().as_slice(), + b"symposium-recommendations", + 17_u64.to_be_bytes().as_slice(), + b"example-debugging", + 1_u64.to_be_bytes().as_slice(), + 3_u64.to_be_bytes().as_slice(), + b"not", + ] + .concat(); + + assert_eq!(encoded, expected); + } + + #[test] + fn extension_subject_dimension_encodes_every_path_node_variant() { + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + let dimension = ExtensionSubjectDimension { + target: &target, + path: &path, + }; + + let encoded = encode_dimension_for_test(&dimension); + let mut expected = Vec::new(); + append_expected_field(&mut expected, "skill"); + append_expected_field(&mut expected, "symposium-recommendations"); + append_expected_field(&mut expected, "example-debugging"); + expected.extend_from_slice(&3_u64.to_be_bytes()); + append_expected_field(&mut expected, "package"); + append_expected_field(&mut expected, "cargo"); + append_expected_field(&mut expected, "example-runtime"); + append_expected_field(&mut expected, "1.2.3"); + append_expected_field(&mut expected, "extension"); + append_expected_field(&mut expected, "plugin"); + append_expected_field(&mut expected, "crates-io"); + append_expected_field(&mut expected, "example-tools"); + append_expected_field(&mut expected, "all"); + expected.extend_from_slice(&2_u64.to_be_bytes()); + append_expected_field(&mut expected, "any"); + append_expected_field(&mut expected, "opaque"); + append_expected_field(&mut expected, "private_source"); + append_expected_field(&mut expected, "not"); + + assert_eq!(encoded, expected); + } + + #[test] + fn extension_subject_derivation_matches_independent_vector() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, public target, and complete recursive path. The + // complete digest is + // 63872efd4737ec84179b4e8b0662c1212e9e3295e1940387c4a4e2cca0a9090e. + let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); + + let subject = path.derive_subject(&identity, &target); + + assert_eq!(subject, expected_subject); + } + #[test] fn empty_resolution_path_is_rejected_at_both_boundaries() { let constructed = ResolutionPath::try_from(Vec::new()); diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index be2bec58..2121b7d6 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -231,6 +231,14 @@ impl PublicPackageCoordinate { pub(in crate::telemetry) fn version(&self) -> &ExactPackageVersion { &self.version } + + /// Write package coordinate fields in version 1 identity order. + pub(super) fn write_identity_fields(&self, writer: &mut DimensionWriter<'_>) { + let version = self.version.to_string(); + writer.field(self.ecosystem.as_str().as_bytes()); + writer.field(self.name.as_str().as_bytes()); + writer.field(version.as_bytes()); + } } impl IdentityDimension for PublicPackageCoordinate { @@ -238,10 +246,7 @@ impl IdentityDimension for PublicPackageCoordinate { /// Write the version 1 `package_subject` fields in contract order. fn write(&self, writer: &mut DimensionWriter<'_>) { - let version = self.version.to_string(); - writer.field(self.ecosystem.as_str().as_bytes()); - writer.field(self.name.as_str().as_bytes()); - writer.field(version.as_bytes()); + self.write_identity_fields(writer); } } @@ -325,17 +330,12 @@ impl PackageResolutionV1 { mod tests { use chrono::NaiveDate; - use super::super::super::{assert_contract_names, assert_contract_names_with_labels}; + use super::super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, + }; use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; - const TEST_STATE: &str = r#"version = 1 - -[identity] -key = "4242424242424242424242424242424242424242424242424242424242424242" -identifier-window-anchor = "2026-08-03" -"#; - fn package_name(value: &str) -> PublicPackageName { value.parse().unwrap() } @@ -349,7 +349,7 @@ identifier-window-anchor = "2026-08-03" } fn package_resolution_for(package_name: &str) -> PackageResolutionV1 { - let state: TelemetryStateV1 = toml::from_str(TEST_STATE).unwrap(); + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); let identity = state.identifier_window_scope(); PackageResolutionV1::new( From 4345b99eae91b6e60069e25c60d470e291bbc85e Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 15:26:05 +0300 Subject: [PATCH 43/57] Add extension resolution row construction Derive the extension subject from the row's validated target and path. This prevents callers from pairing recorded evidence with an identifier derived from different inputs. --- md/design/module-structure.md | 2 +- src/telemetry/schema/resolution/extension.rs | 71 +++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index ecea7c6f..9c200360 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the canonical target-and-path encoding for `extension_subject`. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/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/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs index eb7135f6..3616146f 100644 --- a/src/telemetry/schema/resolution/extension.rs +++ b/src/telemetry/schema/resolution/extension.rs @@ -5,8 +5,11 @@ use std::fmt; use serde::{Deserialize, Serialize}; use super::{ - super::extension::{ - ExtensionKind, PublicExtensionCoordinate, PublicExtensionName, PublicExtensionSource, + super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, + extension::{ + ExtensionKind, PublicExtensionCoordinate, PublicExtensionName, PublicExtensionSource, + }, }, package::PublicPackageCoordinate, }; @@ -121,7 +124,7 @@ impl ResolutionPathNode { /// Complete evidence path for one successful extension resolution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "Vec")] -struct ResolutionPath(Vec); +pub(in crate::telemetry) struct ResolutionPath(Vec); impl ResolutionPath { /// Derive the subject for this path and its resolved public target. @@ -139,6 +142,45 @@ impl ResolutionPath { } } +/// Version 1 record of one public extension and a safe path that selected it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct ExtensionResolutionV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + symposium: SymposiumVersion, + target: PublicExtensionCoordinate, + path: ResolutionPath, + extension_subject: ExtensionSubject, +} + +impl ExtensionResolutionV1 { + /// Create a record for one public extension and its safe resolution path. + #[must_use] + pub(in crate::telemetry) fn new( + identity: &IdentifierWindowScope<'_>, + day: UtcDay, + target: PublicExtensionCoordinate, + path: ResolutionPath, + ) -> Self { + let extension_subject = path.derive_subject(identity, &target); + + Self { + version: SchemaVersion::V1, + kind: RowKind::ExtensionResolution, + event_id: EventId::new(), + day, + symposium: SymposiumVersion::current(), + target, + path, + extension_subject, + } + } +} + struct ExtensionSubjectDimension<'a> { target: &'a PublicExtensionCoordinate, path: &'a ResolutionPath, @@ -309,6 +351,8 @@ impl OpaqueResolutionReason { #[cfg(test)] mod tests { + use chrono::NaiveDate; + use super::super::super::{ IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names_with_labels, recorded_data_example_block, @@ -528,6 +572,27 @@ mod tests { assert_eq!(subject, expected_subject); } + #[test] + fn new_extension_resolution_derives_subject_from_its_target_and_path() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let target = public_target(); + let path = resolution_path_with_every_node_variant(); + let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); + + let row = ExtensionResolutionV1::new(&identity, day, target, path); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::ExtensionResolution); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, day); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.target, public_target()); + assert_eq!(row.path, resolution_path_with_every_node_variant()); + assert_eq!(row.extension_subject, expected_subject); + } + #[test] fn empty_resolution_path_is_rejected_at_both_boundaries() { let constructed = ResolutionPath::try_from(Vec::new()); From 00724aa404e443210939f02c009f64b0a96535bb Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 16:04:07 +0300 Subject: [PATCH 44/57] Support extension resolution rows in the schema reader Classify version-one extension resolution rows and preserve their contract shape when reading and writing JSONL. Exercise malformed targets, fields, and paths, including a nested path that round-trips through the complete classifier. --- src/telemetry/schema/mod.rs | 78 +++++++++++++++++++- src/telemetry/schema/resolution/extension.rs | 26 ++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 143ec9e7..bfd22889 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -20,7 +20,9 @@ use serde::{ use uuid::Uuid; use agent::{AgentConfigurationV1, SessionStartV1}; -use resolution::{ResolutionSummaryV1, package::PackageResolutionV1}; +use resolution::{ + ResolutionSummaryV1, extension::ExtensionResolutionV1, package::PackageResolutionV1, +}; /// Random identifier for one telemetry row. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -76,6 +78,7 @@ pub(super) enum TelemetryRow { AgentConfiguration(AgentConfigurationV1), ResolutionSummary(ResolutionSummaryV1), PackageResolution(PackageResolutionV1), + ExtensionResolution(ExtensionResolutionV1), StorageLimit(StorageLimitV1), } @@ -89,6 +92,7 @@ impl Serialize for TelemetryRow { Self::AgentConfiguration(row) => row.serialize(serializer), Self::ResolutionSummary(row) => row.serialize(serializer), Self::PackageResolution(row) => row.serialize(serializer), + Self::ExtensionResolution(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -400,6 +404,9 @@ pub(super) fn classify_row(line: &str) -> RowClassification { ("package_resolution", 1) => { deserialize_supported_row(line, TelemetryRow::PackageResolution) } + ("extension_resolution", 1) => { + deserialize_supported_row(line, TelemetryRow::ExtensionResolution) + } ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } @@ -864,6 +871,75 @@ mod tests { assert_eq!(serde_json::to_string(&row).unwrap(), example); } + #[test] + fn extension_resolution_example_round_trips() { + let example = example_row("extension_resolution"); + + let RowClassification::Supported(row) = classify_row(example) else { + panic!("extension_resolution contract example was not classified as supported"); + }; + + assert_eq!(serde_json::to_string(&row).unwrap(), example); + } + + #[test] + fn unsupported_extension_resolution_version_is_unknown_schema() { + let example = example_row("extension_resolution"); + let json = example.replacen(r#""v":1"#, r#""v":2"#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::UnknownSchema); + } + + #[test] + fn extension_resolution_with_unknown_field_is_invalid() { + let example = example_row("extension_resolution"); + let json = example.replacen(r#""target""#, r#""future_field":true,"target""#, 1); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_missing_field_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value.as_object_mut().unwrap().remove("path"); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_invalid_target_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value["target"]["name"] = serde_json::json!("private/example-debugging"); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + + #[test] + fn extension_resolution_with_over_limit_path_is_invalid() { + let example = example_row("extension_resolution"); + let mut value = serde_json::from_str::(example).unwrap(); + value["path"] = serde_json::Value::Array( + std::iter::repeat_n(serde_json::json!({ "type": "not" }), 17).collect(), + ); + let json = serde_json::to_string(&value).unwrap(); + + let classification = classify_row(&json); + + assert_eq!(classification, RowClassification::Invalid); + } + #[test] fn unsupported_package_resolution_version_is_unknown_schema() { let example = example_row("package_resolution"); diff --git a/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs index 3616146f..01e5f439 100644 --- a/src/telemetry/schema/resolution/extension.rs +++ b/src/telemetry/schema/resolution/extension.rs @@ -354,8 +354,8 @@ mod tests { use chrono::NaiveDate; use super::super::super::{ - IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names_with_labels, - recorded_data_example_block, + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, + assert_contract_names_with_labels, classify_row, recorded_data_example_block, }; use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; @@ -593,6 +593,28 @@ mod tests { assert_eq!(row.extension_subject, expected_subject); } + #[test] + fn nested_extension_resolution_round_trips_through_the_classifier() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let row = ExtensionResolutionV1::new( + &identity, + day, + public_target(), + resolution_path_with_every_node_variant(), + ); + let json = serde_json::to_string(&row).unwrap(); + + let RowClassification::Supported(TelemetryRow::ExtensionResolution(decoded)) = + classify_row(&json) + else { + panic!("nested extension_resolution row was not classified as supported"); + }; + + assert_eq!(decoded, row); + } + #[test] fn empty_resolution_path_is_rejected_at_both_boundaries() { let constructed = ResolutionPath::try_from(Vec::new()); From 4371e4a893bea93c791681754daf51e3b1e36331 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 16:28:22 +0300 Subject: [PATCH 45/57] Derive agent subjects during row construction Build each agent configuration subject from the row's agent and active identifier window. Callers can no longer pair a configuration observation with a subject derived for another agent. Pin the wire label and HMAC result so serialized names and derived identities cannot drift independently. --- md/design/module-structure.md | 2 +- src/telemetry/schema/agent.rs | 89 ++++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 9c200360..d2f8654a 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope; package-resolution construction uses the identifier-window scope to derive `package_subject` from the coordinate instead of accepting the two independently. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, and agent configuration derives `agent_subject` from its agent. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/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/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index c08f1526..17c4a2ef 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -10,7 +10,10 @@ use super::{ }; use crate::{ agents::Agent, - telemetry::identity::{AgentSubject, RetentionSubject, SessionId}, + telemetry::identity::{ + AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, + RetentionSubject, SessionId, + }, }; /// Agent included in the daily configuration snapshot. @@ -27,6 +30,31 @@ pub(in crate::telemetry) enum SupportedAgent { Goose, } +impl SupportedAgent { + /// Return the frozen version 1 wire label. + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Copilot => "copilot", + Self::Gemini => "gemini", + Self::Kiro => "kiro", + Self::OpenCode => "opencode", + Self::Goose => "goose", + } + } +} + +impl IdentityDimension for SupportedAgent { + type Domain = AgentDomain; + + /// Write the version 1 `agent_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.as_str().as_bytes()); + } +} + impl From for SupportedAgent { fn from(agent: Agent) -> Self { match agent { @@ -266,7 +294,6 @@ impl TryFrom for SessionStartV1 { 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. @@ -290,11 +317,14 @@ impl AgentConfigurationV1 { /// Create one agent entry in a daily configuration snapshot. #[must_use] pub(in crate::telemetry) fn new( + identity: &IdentifierWindowScope<'_>, day: UtcDay, os: OperatingSystem, arch: Architecture, fields: AgentConfigurationFields, ) -> Self { + let agent_subject = identity.derive(&fields.agent); + Self { version: SchemaVersion::V1, kind: RowKind::AgentConfiguration, @@ -305,7 +335,7 @@ impl AgentConfigurationV1 { configured: fields.configured, os, arch, - agent_subject: fields.agent_subject, + agent_subject, } } } @@ -314,8 +344,12 @@ impl AgentConfigurationV1 { mod tests { use chrono::{NaiveDate, TimeZone, Utc}; - use super::super::{RowClassification, TelemetryRow, assert_contract_names, classify_row}; + use super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, + assert_contract_names_with_labels, classify_row, + }; use super::*; + use crate::telemetry::state::TelemetryStateV1; fn session_start_fields(session_id: Option) -> SessionStartFields { SessionStartFields { @@ -333,6 +367,23 @@ mod tests { UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 9, 14, 2).unwrap()) } + fn agent_configuration(agent: SupportedAgent) -> AgentConfigurationV1 { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + + AgentConfigurationV1::new( + &identity, + day, + OperatingSystem::Linux, + Architecture::X86_64, + AgentConfigurationFields { + agent, + configured: true, + }, + ) + } + #[test] fn hook_agents_round_trip_with_contract_names() { let cases = [ @@ -376,7 +427,7 @@ mod tests { (SupportedAgent::Goose, "goose"), ]; - assert_contract_names(&cases); + assert_contract_names_with_labels(&cases, SupportedAgent::as_str); } #[test] @@ -517,20 +568,14 @@ mod tests { } #[test] - fn new_agent_configuration_uses_fixed_common_fields() { + fn new_agent_configuration_derives_subject_from_its_agent() { let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); - let agent_subject = "agt_9255770e1679cb789796a9f9e86325c5".parse().unwrap(); + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, and agent. The complete digest is + // e346647f3c83e0f8bea71a0ff04bfb6fa601f0967a92713894dcea7f793214b0. + let expected_subject = "agt_e346647f3c83e0f8bea71a0ff04bfb6f".parse().unwrap(); - let row = AgentConfigurationV1::new( - day, - OperatingSystem::Linux, - Architecture::X86_64, - AgentConfigurationFields { - agent: SupportedAgent::Claude, - configured: true, - agent_subject, - }, - ); + let row = agent_configuration(SupportedAgent::Claude); assert_eq!(row.version, SchemaVersion::V1); assert_eq!(row.kind, RowKind::AgentConfiguration); @@ -541,7 +586,15 @@ mod tests { assert!(row.configured); assert_eq!(row.os, OperatingSystem::Linux); assert_eq!(row.arch, Architecture::X86_64); - assert_eq!(row.agent_subject, agent_subject); + assert_eq!(row.agent_subject, expected_subject); + } + + #[test] + fn agent_subject_changes_with_the_agent() { + let claude = agent_configuration(SupportedAgent::Claude); + let codex = agent_configuration(SupportedAgent::Codex); + + assert_ne!(claude.agent_subject, codex.agent_subject); } #[test] From e09327459e958d5989cb08ab54336c88330c7b13 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 17:00:20 +0300 Subject: [PATCH 46/57] Define session identity derivation inputs Represent raw vendor session identifiers as a non-serializable input and encode session dimensions from the agent and vendor value in contract order. Use the production retention dimension in state tests so both identity domains are pinned by independently verified vectors. --- src/telemetry/identity.rs | 24 ++++--- src/telemetry/schema/agent.rs | 120 +++++++++++++++++++++++++++++++++- src/telemetry/state/mod.rs | 13 +--- 3 files changed, 133 insertions(+), 24 deletions(-) diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 0a676472..86e0efbb 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -116,6 +116,20 @@ pub(super) trait IdentityDimension { fn write(&self, writer: &mut DimensionWriter<'_>); } +/// The empty dimension used to derive a return-cohort subject. +/// +/// A retention subject is scoped only by its return-cohort anchor. Keeping the +/// empty dimension as a type ensures callers cannot add an accidental field to +/// that derivation. +pub(super) struct RetentionDimension; + +impl IdentityDimension for RetentionDimension { + type Domain = RetentionDomain; + + /// Write no fields, as required by the version 1 identity contract. + fn write(&self, _writer: &mut DimensionWriter<'_>) {} +} + /// Writes canonical identity-dimension framing to a private byte sink. /// /// Only this module can create a writer. Schema types can use its structured @@ -633,14 +647,6 @@ mod tests { } } - struct TestRetentionDimension; - - impl IdentityDimension for TestRetentionDimension { - type Domain = RetentionDomain; - - fn write(&self, _writer: &mut DimensionWriter<'_>) {} - } - struct TestNestedSequenceDimension; impl IdentityDimension for TestNestedSequenceDimension { @@ -746,7 +752,7 @@ mod tests { let key = IdentityKey::from_bytes([0x42; IDENTITY_KEY_BYTES]); let identity = ReturnCohortScope::new(&key, "2026-08-03".to_owned()); - let identifier = identity.derive(&TestRetentionDimension); + let identifier = identity.derive(&RetentionDimension); // Cross-checked with .NET's HMACSHA256 over the retention header and // framed cohort anchor. The complete digest is diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 17c4a2ef..36093a64 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -12,7 +12,7 @@ use crate::{ agents::Agent, telemetry::identity::{ AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, - RetentionSubject, SessionId, + RetentionSubject, SessionDomain, SessionId, }, }; @@ -83,6 +83,20 @@ pub(in crate::telemetry) enum HookAgent { Kiro, } +impl HookAgent { + /// Return the frozen version 1 wire label. + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Copilot => "copilot", + Self::Gemini => "gemini", + Self::Kiro => "kiro", + } + } +} + impl From for SupportedAgent { fn from(agent: HookAgent) -> Self { match agent { @@ -95,6 +109,53 @@ impl From for SupportedAgent { } } +/// A raw vendor session identifier supplied by an agent. +/// +/// This value is used only as an identity-derivation input. It deliberately +/// implements neither formatting nor serialization traits so telemetry cannot +/// accidentally write it to a row or diagnostic. +pub(in crate::telemetry) struct VendorSessionId(String); + +impl VendorSessionId { + /// Wrap a vendor session identifier without changing its UTF-8 bytes. + #[must_use] + pub(in crate::telemetry) fn new(value: String) -> Self { + Self(value) + } + + /// Borrow the exact UTF-8 bytes supplied by the agent. + #[must_use] + fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +/// Canonical version 1 inputs for a scoped session identifier. +struct SessionDimension<'a> { + agent: HookAgent, + vendor_session_id: &'a VendorSessionId, +} + +impl<'a> SessionDimension<'a> { + #[must_use] + const fn new(agent: HookAgent, vendor_session_id: &'a VendorSessionId) -> Self { + Self { + agent, + vendor_session_id, + } + } +} + +impl IdentityDimension for SessionDimension<'_> { + type Domain = SessionDomain; + + /// Write the version 1 `session_id` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(self.agent.as_str().as_bytes()); + writer.field(self.vendor_session_id.as_bytes()); + } +} + /// Operating-system class for the running Symposium build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -349,7 +410,7 @@ mod tests { assert_contract_names_with_labels, classify_row, }; use super::*; - use crate::telemetry::state::TelemetryStateV1; + use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; fn session_start_fields(session_id: Option) -> SessionStartFields { SessionStartFields { @@ -394,7 +455,60 @@ mod tests { (HookAgent::Kiro, "kiro"), ]; - assert_contract_names(&cases); + assert_contract_names_with_labels(&cases, HookAgent::as_str); + } + + #[test] + fn session_dimension_uses_agent_then_vendor_session_id() { + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + let dimension = SessionDimension::new(HookAgent::Claude, &vendor_session_id); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 6].as_slice(), + b"claude".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 18].as_slice(), + b"vendor-session-123".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&dimension); + + assert_eq!(encoded, expected); + } + + #[test] + fn session_subject_derivation_matches_independent_vector() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + let dimension = SessionDimension::new(HookAgent::Claude, &vendor_session_id); + + let subject = identity.derive(&dimension); + + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, agent, and vendor session id. The complete + // digest is + // 2f77ea40740f4be8e85ba05e7924e1ad054037d26629db2ef7dc7097dddf723a. + assert_eq!( + subject, + "sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap() + ); + } + + #[test] + fn session_subject_changes_with_the_agent_or_vendor_session_id() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let first_vendor_id = VendorSessionId::new("vendor-session-123".to_owned()); + let second_vendor_id = VendorSessionId::new("vendor-session-456".to_owned()); + + let first = identity.derive(&SessionDimension::new(HookAgent::Claude, &first_vendor_id)); + let other_agent = + identity.derive(&SessionDimension::new(HookAgent::Codex, &first_vendor_id)); + let other_vendor_id = + identity.derive(&SessionDimension::new(HookAgent::Claude, &second_vendor_id)); + + assert_ne!(first, other_agent); + assert_ne!(first, other_vendor_id); } #[test] diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index 490d54f8..4978e839 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -134,24 +134,13 @@ mod tests { use chrono::NaiveDate; use super::{IdentityKey, TelemetryStateV1}; - use crate::telemetry::{ - identity::{DimensionWriter, IdentityDimension, RetentionDomain}, - schema::UtcDay, - }; + use crate::telemetry::{identity::RetentionDimension, schema::UtcDay}; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const GENERATED_KEY_BYTE: u8 = 0x42; /// Lowercase hexadecimal encoding of 32 [`GENERATED_KEY_BYTE`] bytes. const GENERATED_KEY: &str = "4242424242424242424242424242424242424242424242424242424242424242"; - struct RetentionDimension; - - impl IdentityDimension for RetentionDimension { - type Domain = RetentionDomain; - - fn write(&self, _writer: &mut DimensionWriter<'_>) {} - } - fn day(year: i32, month: u32, day: u32) -> UtcDay { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } From 546b838494828471de5b477b75b1eb962c11dbce Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 17:29:09 +0300 Subject: [PATCH 47/57] Bind session transitions to current identity state Keep each completed session transition as a single-use value, then validate both selected anchors before exposing its identity scopes. This prevents stale lifecycle data from being combined with newer state. Record that the persistence layer must provide a successful-write token before binding, so the eventual persist-before-derive order can be enforced by the API. --- md/design/module-structure.md | 2 +- src/telemetry/identity.rs | 4 +- src/telemetry/state/lifecycle.rs | 234 ++++++++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 4 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d2f8654a..0505cd43 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, and agent configuration derives `agent_subject` from its agent. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, and agent configuration derives `agent_subject` from its agent. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/src/telemetry/identity.rs b/src/telemetry/identity.rs index 86e0efbb..29de088f 100644 --- a/src/telemetry/identity.rs +++ b/src/telemetry/identity.rs @@ -104,7 +104,9 @@ pub(super) type ReturnCohortScope<'a> = IdentityScope<'a, ReturnCohortAnchor>; /// /// Each schema type implements this trait for the domain it belongs to. This /// keeps field selection and order beside the validated value while leaving -/// framing under the identity module's control. +/// framing under the identity module's control. A domain with no schema value, +/// such as retention, keeps its empty dimension here so private state can use +/// the production encoding without depending on a row module. pub(super) trait IdentityDimension { type Domain; diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs index 96408b16..f2ff7d5c 100644 --- a/src/telemetry/state/lifecycle.rs +++ b/src/telemetry/state/lifecycle.rs @@ -4,7 +4,7 @@ use std::fmt; use super::{IdentityState, TelemetryStateV1}; use crate::telemetry::{ - identity::IdentityKey, + identity::{IdentifierWindowScope, IdentityKey, ReturnCohortScope}, schema::{CohortDay, UtcDay}, }; @@ -100,6 +100,56 @@ impl TelemetryStateV1 { }) } + /// Bind a completed session transition to the unchanged private state. + /// + /// Storage calls this only after atomically persisting the state changed by + /// [`Self::observe_session`]. Both selected anchors are checked before any + /// identity scope is exposed, so an observation whose anchors no longer + /// match current state is rejected. Storage must still bind immediately + /// after persistence while holding the same telemetry lock; anchors do not + /// identify a private-state instance by themselves. When private-state + /// persistence is implemented, its successful write token will become an + /// additional required binding input so this ordering is structural. + /// + /// # Errors + /// + /// Returns an error when either stored anchor differs from the anchor + /// selected by `observation`. + pub(super) fn bind_session_observation( + &self, + observation: SessionObservation, + ) -> Result, SessionObservationBindingError> { + let selected_identifier_window = observation.identifier_window.anchor(); + let current_identifier_window = self.identity.identifier_window_anchor; + if selected_identifier_window != current_identifier_window { + return Err(SessionObservationBindingError::IdentifierWindowChanged { + selected_anchor: selected_identifier_window, + current_anchor: current_identifier_window, + }); + } + + let selected_return_cohort = observation.return_cohort.anchor(); + let current_return_cohort = self.identity.return_cohort_anchor; + if current_return_cohort != Some(selected_return_cohort) { + return Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor: selected_return_cohort, + current_anchor: current_return_cohort, + }); + } + + let identifier_window_scope = self.identifier_window_scope(); + let return_cohort_scope = self + .return_cohort_scope() + .expect("BUG: the checked return-cohort anchor must be present"); + + Ok(BoundSessionObservation { + identifier_window: observation.identifier_window, + return_cohort: observation.return_cohort, + identifier_window_scope, + return_cohort_scope, + }) + } + /// Select the identifier window without mutating private state. fn select_identifier_window( &self, @@ -157,12 +207,84 @@ impl TelemetryStateV1 { /// 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)] +#[derive(Debug, PartialEq, Eq)] pub(super) struct SessionObservation { pub(super) identifier_window: IdentifierWindowUpdate, pub(super) return_cohort: ReturnCohortUpdate, } +/// A persisted session transition bound to both of its identity scopes. +#[must_use = "a bound session observation supplies the session-start identity fields"] +pub(super) struct BoundSessionObservation<'a> { + identifier_window: IdentifierWindowUpdate, + return_cohort: ReturnCohortUpdate, + identifier_window_scope: IdentifierWindowScope<'a>, + return_cohort_scope: ReturnCohortScope<'a>, +} + +impl BoundSessionObservation<'_> { + /// Return identity material bound to the selected identifier window. + #[must_use] + pub(super) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { + &self.identifier_window_scope + } + + /// Return identity material bound to the selected return cohort. + #[must_use] + pub(super) fn return_cohort_scope(&self) -> &ReturnCohortScope<'_> { + &self.return_cohort_scope + } + + /// Return the observed day within the selected return cohort. + #[must_use] + pub(super) fn cohort_day(&self) -> CohortDay { + self.return_cohort.day() + } +} + +/// A session transition that no longer matches the current private state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SessionObservationBindingError { + IdentifierWindowChanged { + selected_anchor: UtcDay, + current_anchor: UtcDay, + }, + ReturnCohortChanged { + selected_anchor: UtcDay, + current_anchor: Option, + }, +} + +impl fmt::Display for SessionObservationBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdentifierWindowChanged { + selected_anchor, + current_anchor, + } => write!( + formatter, + "session selected identifier-window anchor {selected_anchor}, but current state uses {current_anchor}" + ), + Self::ReturnCohortChanged { + selected_anchor, + current_anchor: Some(current_anchor), + } => write!( + formatter, + "session selected return-cohort anchor {selected_anchor}, but current state uses {current_anchor}" + ), + Self::ReturnCohortChanged { + selected_anchor, + current_anchor: None, + } => write!( + formatter, + "session selected return-cohort anchor {selected_anchor}, but current state has no return cohort" + ), + } + } +} + +impl std::error::Error for SessionObservationBindingError {} + /// 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)] @@ -255,6 +377,9 @@ mod tests { use chrono::NaiveDate; use super::*; + use crate::telemetry::identity::{ + DimensionWriter, IdentityDimension, RetentionDimension, SessionDomain, + }; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const GENERATED_KEY_BYTE: u8 = 0x42; @@ -264,6 +389,16 @@ mod tests { #[derive(Debug, PartialEq, Eq)] struct TestKeySourceError; + struct TestSessionDimension; + + impl IdentityDimension for TestSessionDimension { + type Domain = SessionDomain; + + fn write(&self, writer: &mut DimensionWriter<'_>) { + writer.field(b"test-session"); + } + } + fn day(year: i32, month: u32, day: u32) -> UtcDay { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } @@ -399,6 +534,101 @@ mod tests { assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); } + #[test] + fn observed_session_binds_both_scopes_to_the_selected_anchors() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let old_session = state + .identifier_window_scope() + .derive(&TestSessionDimension); + let old_retention = state + .return_cohort_scope() + .expect("fixture has an observed-session cohort") + .derive(&RetentionDimension); + + let observation = state.observe_session(day(2026, 10, 25)).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + let new_session = observation + .identifier_window_scope() + .derive(&TestSessionDimension); + let new_retention = observation + .return_cohort_scope() + .derive(&RetentionDimension); + + assert_ne!(new_session, old_session); + assert_ne!(new_retention, old_retention); + assert_eq!(observation.cohort_day(), CohortDay::D0); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert!(matches!( + observation.return_cohort, + ReturnCohortUpdate::Started { .. } + )); + } + + #[test] + fn binding_rejects_an_observation_from_an_older_identifier_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state.observe_session(day(2026, 9, 11)).unwrap(); + let current_day = day(2026, 10, 25); + let _current_observation = state.observe_session(current_day).unwrap(); + + let result = state.bind_session_observation(older_observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::IdentifierWindowChanged { + selected_anchor, + current_anchor, + }) if selected_anchor == day(2026, 9, 10) && current_anchor == current_day + )); + } + + #[test] + fn binding_rejects_an_observation_from_an_older_return_cohort() { + let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state.observe_session(day(2026, 9, 10)).unwrap(); + let current_cohort = day(2026, 9, 11); + let _current_observation = state.observe_session(current_cohort).unwrap(); + + let result = state.bind_session_observation(older_observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor, + current_anchor: Some(current_anchor), + }) if selected_anchor == day(2026, 8, 11) && current_anchor == current_cohort + )); + } + + #[test] + fn binding_rejects_an_observation_after_identifier_reset() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let observation = state.observe_session(day(2026, 9, 11)).unwrap(); + state + .reset_identifiers_with::(day(2026, 9, 10), |bytes| { + bytes.fill(GENERATED_KEY_BYTE); + Ok(()) + }) + .unwrap(); + + let result = state.bind_session_observation(observation); + + assert!(matches!( + result, + Err(SessionObservationBindingError::ReturnCohortChanged { + selected_anchor, + current_anchor: None, + }) if selected_anchor == day(2026, 9, 10) + )); + } + #[test] fn window_rollover_preserves_the_key_and_return_cohort() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); From 86eee8b73b29ed29e7ca8907f10a63343ecbdaa1 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sat, 12 Sep 2026 19:03:32 +0300 Subject: [PATCH 48/57] Derive session identity from agent input Keep the agent and its optional scoped session identifier together so callers cannot associate an identifier with a different agent. Agents without vendor session identifiers remain explicitly unidentified without exposing raw vendor values. --- src/telemetry/schema/agent.rs | 68 ++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 36093a64..ba208e3d 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -156,6 +156,45 @@ impl IdentityDimension for SessionDimension<'_> { } } +/// An agent session paired with its optional scoped identifier. +/// +/// The identifier is derived from the same agent stored here, so callers +/// cannot associate one agent with an identifier derived for another. Agents +/// that do not supply a vendor session identifier remain explicitly +/// unidentified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct AgentSessionIdentity { + agent: HookAgent, + session_id: Option, +} + +impl AgentSessionIdentity { + /// Derive the identifier for one agent session in an identifier window. + #[must_use] + pub(in crate::telemetry) fn new( + identity: &IdentifierWindowScope<'_>, + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, + ) -> Self { + let session_id = vendor_session_id.map(|vendor_session_id| { + identity.derive(&SessionDimension::new(agent, vendor_session_id)) + }); + + Self { agent, session_id } + } + + /// Return the agent whose session this identity describes. + #[must_use] + pub(in crate::telemetry) const fn agent(self) -> HookAgent { + self.agent + } + + /// Return the scoped identifier when the agent supplied a vendor id. + pub(in crate::telemetry) const fn session_id(self) -> Option { + self.session_id + } +} + /// Operating-system class for the running Symposium build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -480,17 +519,17 @@ mod tests { let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); let identity = state.identifier_window_scope(); let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); - let dimension = SessionDimension::new(HookAgent::Claude, &vendor_session_id); - let subject = identity.derive(&dimension); + let session = + AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&vendor_session_id)); // Cross-checked with .NET's HMACSHA256 over the contract header, // identifier window, agent, and vendor session id. The complete // digest is // 2f77ea40740f4be8e85ba05e7924e1ad054037d26629db2ef7dc7097dddf723a. assert_eq!( - subject, - "sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap() + session.session_id(), + Some("sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap()) ); } @@ -501,14 +540,25 @@ mod tests { let first_vendor_id = VendorSessionId::new("vendor-session-123".to_owned()); let second_vendor_id = VendorSessionId::new("vendor-session-456".to_owned()); - let first = identity.derive(&SessionDimension::new(HookAgent::Claude, &first_vendor_id)); + let first = AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&first_vendor_id)); let other_agent = - identity.derive(&SessionDimension::new(HookAgent::Codex, &first_vendor_id)); + AgentSessionIdentity::new(&identity, HookAgent::Codex, Some(&first_vendor_id)); let other_vendor_id = - identity.derive(&SessionDimension::new(HookAgent::Claude, &second_vendor_id)); + AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&second_vendor_id)); + + assert_ne!(first.session_id(), other_agent.session_id()); + assert_ne!(first.session_id(), other_vendor_id.session_id()); + } + + #[test] + fn agent_session_without_vendor_id_is_unidentified() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + + let session = AgentSessionIdentity::new(&identity, HookAgent::Copilot, None); - assert_ne!(first, other_agent); - assert_ne!(first, other_vendor_id); + assert_eq!(session.agent(), HookAgent::Copilot); + assert_eq!(session.session_id(), None); } #[test] From 644f656bfc278cbe72728fbe3c1210e664669dc4 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 13:17:17 +0300 Subject: [PATCH 49/57] Bind session rows to one completion timestamp Capture the completion timestamp when observing a session and carry it through the bound state transition. Session rows now derive their day, cohort position, and scoped identifiers from that single observation. This removes the post-persistence mismatch failure that could leave a new return cohort without its D0 row. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 4 +- src/telemetry/schema/agent.rs | 95 +++++++++----- src/telemetry/state/lifecycle.rs | 122 +++++++++++------- src/telemetry/state/mod.rs | 2 + 5 files changed, 145 insertions(+), 80 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 0505cd43..7278093c 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, and agent configuration derives `agent_subject` from its agent. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. +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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, and session start derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index 4e1ab020..b212ec9a 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -140,6 +140,8 @@ GitHub Copilot does not currently supply a session id. OpenCode and Goose do not These rows, not `hook_metrics` rows whose `hook` is `session_start`, are authoritative for observed-session and return measurements. A stored D0 row admits its `retention_subject` cohort to analysis. D1, D7, or D30 is present when at least one later session-start row has that `cohort_day`, regardless of agent or vendor session id. A later row without a stored D0 for the same subject is ignored. Multiple rows on the same cohort day count once. +The producer captures `at` once when session-start handling completes. That timestamp determines `day` and the session observation from which the producer derives `session_id`, `retention_subject`, and `cohort_day`. + The aggregate hook rows measure only session-start hook reliability and latency. ### `agent_configuration` @@ -429,7 +431,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 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. +Under that lock, session recording uses the UTC day from the completion timestamp and rejects it when it precedes 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 from the same timestamp and transition. 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/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index ba208e3d..be88f2ca 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -10,9 +10,12 @@ use super::{ }; use crate::{ agents::Agent, - telemetry::identity::{ - AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, - RetentionSubject, SessionDomain, SessionId, + telemetry::{ + identity::{ + AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, + RetentionDimension, RetentionSubject, SessionDomain, SessionId, + }, + state::BoundSessionObservation, }, }; @@ -256,19 +259,16 @@ pub(in crate::telemetry) enum SessionStartKind { Unknown, } -/// Agent-supplied and derived fields for one completed session-start hook. +/// Non-derived inputs 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 { +/// The raw vendor identifier is used only during subject derivation and cannot +/// be formatted or serialized as part of this input bundle. +pub(in crate::telemetry) struct SessionStartFields<'a> { pub(in crate::telemetry) agent: HookAgent, pub(in crate::telemetry) os: OperatingSystem, pub(in crate::telemetry) arch: Architecture, pub(in crate::telemetry) start: SessionStartKind, - pub(in crate::telemetry) session_id: Option, - pub(in crate::telemetry) retention_subject: RetentionSubject, - pub(in crate::telemetry) cohort_day: CohortDay, + pub(in crate::telemetry) vendor_session_id: Option<&'a VendorSessionId>, } /// Version 1 record of a completed registered session-start hook. @@ -294,8 +294,24 @@ pub(in crate::telemetry) struct SessionStartV1 { impl SessionStartV1 { /// Create a record for a completed registered session-start hook. + /// + /// Both scoped identifiers and the cohort day come from the same bound + /// state transition as its captured completion timestamp. #[must_use] - pub(in crate::telemetry) fn new(at: UtcSecond, fields: SessionStartFields) -> Self { + pub(in crate::telemetry) fn new( + fields: SessionStartFields<'_>, + observation: &BoundSessionObservation<'_>, + ) -> Self { + let at = observation.completed_at(); + let session = AgentSessionIdentity::new( + observation.identifier_window_scope(), + fields.agent, + fields.vendor_session_id, + ); + let retention_subject = observation + .return_cohort_scope() + .derive(&RetentionDimension); + Self { version: SchemaVersion::V1, kind: RowKind::SessionStart, @@ -303,13 +319,13 @@ impl SessionStartV1 { day: at.day(), at, symposium: SymposiumVersion::current(), - agent: fields.agent, + agent: session.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, + session_id: session.session_id(), + retention_subject, + cohort_day: observation.cohort_day(), } } } @@ -451,15 +467,13 @@ mod tests { use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; - fn session_start_fields(session_id: Option) -> SessionStartFields { + fn session_start_fields(vendor_session_id: Option<&VendorSessionId>) -> 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, + vendor_session_id, } } @@ -467,6 +481,17 @@ mod tests { UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 9, 14, 2).unwrap()) } + fn session_start( + completed_at: UtcSecond, + vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + SessionStartV1::new(session_start_fields(vendor_session_id), &observation) + } + fn agent_configuration(agent: SupportedAgent) -> AgentConfigurationV1 { let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); let identity = state.identifier_window_scope(); @@ -709,12 +734,16 @@ mod tests { } #[test] - fn new_session_start_uses_fixed_common_fields_and_timestamp_day() { + fn new_session_start_derives_identity_and_cohort_from_bound_observation() { let at = session_start_time(); - let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); - let fields = session_start_fields(Some(session_id)); + let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); + // These are the independently cross-checked session and retention + // vectors pinned by the focused identity tests above and in + // `identity.rs`. + let expected_session = "sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap(); + let expected_retention = "ret_270adecd2120c543261f04bd771df491".parse().unwrap(); - let row = SessionStartV1::new(at, fields); + let row = session_start(at, Some(&vendor_session_id)); assert_eq!(row.version, SchemaVersion::V1); assert_eq!(row.kind, RowKind::SessionStart); @@ -722,13 +751,13 @@ mod tests { 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); + assert_eq!(row.agent, HookAgent::Claude); + assert_eq!(row.os, OperatingSystem::Linux); + assert_eq!(row.arch, Architecture::X86_64); + assert_eq!(row.start, SessionStartKind::Fresh); + assert_eq!(row.session_id, Some(expected_session)); + assert_eq!(row.retention_subject, expected_retention); + assert_eq!(row.cohort_day, CohortDay::D0); } #[test] @@ -763,7 +792,7 @@ mod tests { #[test] fn session_start_without_session_id_classifies_and_round_trips() { - let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + let row = session_start(session_start_time(), None); let json = serde_json::to_string(&row).unwrap(); let value = serde_json::from_str::(&json).unwrap(); @@ -779,7 +808,7 @@ mod tests { #[test] fn session_start_rejects_a_day_that_disagrees_with_its_timestamp() { - let row = SessionStartV1::new(session_start_time(), session_start_fields(None)); + let row = session_start(session_start_time(), None); let mut value = serde_json::to_value(row).unwrap(); value["day"] = serde_json::Value::String("2026-08-04".to_owned()); diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs index f2ff7d5c..b9f5f98a 100644 --- a/src/telemetry/state/lifecycle.rs +++ b/src/telemetry/state/lifecycle.rs @@ -5,7 +5,7 @@ use std::fmt; use super::{IdentityState, TelemetryStateV1}; use crate::telemetry::{ identity::{IdentifierWindowScope, IdentityKey, ReturnCohortScope}, - schema::{CohortDay, UtcDay}, + schema::{CohortDay, UtcDay, UtcSecond}, }; /// Exclusive length of an identifier window in UTC-day positions. @@ -64,7 +64,7 @@ impl TelemetryStateV1 { Ok(()) } - /// Observe a session on a day accepted by the monotonic clock policy. + /// Observe a session at its captured completion timestamp. /// /// This selects the identifier window and return cohort before mutating /// either anchor. The first observed session establishes cohort D0. An @@ -80,14 +80,15 @@ impl TelemetryStateV1 { /// /// # 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( + /// Returns an error if the timestamp's UTC day precedes either stored + /// anchor. A conforming storage caller filters this case through its durable + /// day policy; these checks protect against an incorrect caller or + /// inconsistent state. Neither anchor changes when validation fails. + pub(in crate::telemetry) fn observe_session( &mut self, - effective_day: UtcDay, + completed_at: UtcSecond, ) -> Result { + let effective_day = completed_at.day(); let identifier_window = self.select_identifier_window(effective_day)?; let return_cohort = self.select_return_cohort(effective_day)?; @@ -95,6 +96,7 @@ impl TelemetryStateV1 { self.identity.return_cohort_anchor = Some(return_cohort.anchor()); Ok(SessionObservation { + completed_at, identifier_window, return_cohort, }) @@ -115,7 +117,7 @@ impl TelemetryStateV1 { /// /// Returns an error when either stored anchor differs from the anchor /// selected by `observation`. - pub(super) fn bind_session_observation( + pub(in crate::telemetry) fn bind_session_observation( &self, observation: SessionObservation, ) -> Result, SessionObservationBindingError> { @@ -143,6 +145,7 @@ impl TelemetryStateV1 { .expect("BUG: the checked return-cohort anchor must be present"); Ok(BoundSessionObservation { + completed_at: observation.completed_at, identifier_window: observation.identifier_window, return_cohort: observation.return_cohort, identifier_window_scope, @@ -208,14 +211,16 @@ impl TelemetryStateV1 { /// Identity and return-cohort selections for one observed session. #[must_use = "session identity state must be persisted before identifiers are emitted"] #[derive(Debug, PartialEq, Eq)] -pub(super) struct SessionObservation { +pub(in crate::telemetry) struct SessionObservation { + completed_at: UtcSecond, pub(super) identifier_window: IdentifierWindowUpdate, pub(super) return_cohort: ReturnCohortUpdate, } /// A persisted session transition bound to both of its identity scopes. #[must_use = "a bound session observation supplies the session-start identity fields"] -pub(super) struct BoundSessionObservation<'a> { +pub(in crate::telemetry) struct BoundSessionObservation<'a> { + completed_at: UtcSecond, identifier_window: IdentifierWindowUpdate, return_cohort: ReturnCohortUpdate, identifier_window_scope: IdentifierWindowScope<'a>, @@ -223,28 +228,34 @@ pub(super) struct BoundSessionObservation<'a> { } impl BoundSessionObservation<'_> { + /// Return when Symposium completed the observed session-start handling. + #[must_use] + pub(in crate::telemetry) fn completed_at(&self) -> UtcSecond { + self.completed_at + } + /// Return identity material bound to the selected identifier window. #[must_use] - pub(super) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { + pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { &self.identifier_window_scope } /// Return identity material bound to the selected return cohort. #[must_use] - pub(super) fn return_cohort_scope(&self) -> &ReturnCohortScope<'_> { + pub(in crate::telemetry) fn return_cohort_scope(&self) -> &ReturnCohortScope<'_> { &self.return_cohort_scope } /// Return the observed day within the selected return cohort. #[must_use] - pub(super) fn cohort_day(&self) -> CohortDay { + pub(in crate::telemetry) fn cohort_day(&self) -> CohortDay { self.return_cohort.day() } } /// A session transition that no longer matches the current private state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum SessionObservationBindingError { +pub(in crate::telemetry) enum SessionObservationBindingError { IdentifierWindowChanged { selected_anchor: UtcDay, current_anchor: UtcDay, @@ -336,7 +347,7 @@ impl ReturnCohortUpdate { /// An observed session earlier than one of its stored identity anchors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum SessionObservationError { +pub(in crate::telemetry) enum SessionObservationError { BeforeIdentifierWindow { observed_day: UtcDay, window_anchor: UtcDay, @@ -374,12 +385,13 @@ impl std::error::Error for SessionObservationError {} mod tests { use std::convert::Infallible; - use chrono::NaiveDate; + use chrono::{NaiveDate, TimeZone, Utc}; use super::*; use crate::telemetry::identity::{ DimensionWriter, IdentityDimension, RetentionDimension, SessionDomain, }; + use crate::telemetry::schema::UtcSecond; const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const GENERATED_KEY_BYTE: u8 = 0x42; @@ -403,6 +415,10 @@ mod tests { UtcDay::from_date(NaiveDate::from_ymd_opt(year, month, day).unwrap()) } + fn completion_time(year: i32, month: u32, day: u32) -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(year, month, day, 12, 0, 0).unwrap()) + } + fn state_with_return_cohort(key: &str) -> String { state_with_anchors(key, "2026-09-10", "2026-08-11") } @@ -480,8 +496,8 @@ mod tests { 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(); + for completed_at in [completion_time(2026, 9, 10), completion_time(2026, 10, 9)] { + let observation = state.observe_session(completed_at).unwrap(); assert_eq!( observation.identifier_window, @@ -496,9 +512,10 @@ mod tests { 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 completed_at = completion_time(2026, 10, 10); + let observed_day = completed_at.day(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); assert_eq!( observation.identifier_window, @@ -514,9 +531,10 @@ mod tests { 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 completed_at = completion_time(2026, 10, 25); + let observed_day = completed_at.day(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); assert_eq!( observation.identifier_window, @@ -546,7 +564,9 @@ mod tests { .expect("fixture has an observed-session cohort") .derive(&RetentionDimension); - let observation = state.observe_session(day(2026, 10, 25)).unwrap(); + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 10, 25, 16, 30, 12).unwrap()); + let observation = state.observe_session(completed_at).unwrap(); let observation = state.bind_session_observation(observation).unwrap(); let new_session = observation .identifier_window_scope() @@ -557,6 +577,7 @@ mod tests { assert_ne!(new_session, old_session); assert_ne!(new_retention, old_retention); + assert_eq!(observation.completed_at(), completed_at); assert_eq!(observation.cohort_day(), CohortDay::D0); assert!(matches!( observation.identifier_window, @@ -572,9 +593,11 @@ mod tests { fn binding_rejects_an_observation_from_an_older_identifier_window() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let older_observation = state.observe_session(day(2026, 9, 11)).unwrap(); + let older_observation = state.observe_session(completion_time(2026, 9, 11)).unwrap(); let current_day = day(2026, 10, 25); - let _current_observation = state.observe_session(current_day).unwrap(); + let _current_observation = state + .observe_session(completion_time(2026, 10, 25)) + .unwrap(); let result = state.bind_session_observation(older_observation); @@ -591,9 +614,9 @@ mod tests { fn binding_rejects_an_observation_from_an_older_return_cohort() { let source = state_with_anchors(KEY, "2026-09-01", "2026-08-11"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let older_observation = state.observe_session(day(2026, 9, 10)).unwrap(); + let older_observation = state.observe_session(completion_time(2026, 9, 10)).unwrap(); let current_cohort = day(2026, 9, 11); - let _current_observation = state.observe_session(current_cohort).unwrap(); + let _current_observation = state.observe_session(completion_time(2026, 9, 11)).unwrap(); let result = state.bind_session_observation(older_observation); @@ -610,7 +633,7 @@ mod tests { fn binding_rejects_an_observation_after_identifier_reset() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observation = state.observe_session(day(2026, 9, 11)).unwrap(); + let observation = state.observe_session(completion_time(2026, 9, 11)).unwrap(); state .reset_identifiers_with::(day(2026, 9, 10), |bytes| { bytes.fill(GENERATED_KEY_BYTE); @@ -634,7 +657,9 @@ mod tests { 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 observation = state + .observe_session(completion_time(2026, 10, 10)) + .unwrap(); let serialized = toml::to_string_pretty(&state).unwrap(); let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); @@ -650,7 +675,9 @@ mod tests { 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 error = state + .observe_session(completion_time(2026, 9, 9)) + .unwrap_err(); let serialized = toml::to_string_pretty(&state).unwrap(); assert_eq!( @@ -664,9 +691,10 @@ mod tests { 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 completed_at = completion_time(2026, 9, 10); + let observed_day = completed_at.day(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); assert_eq!(observation.return_cohort.day(), CohortDay::D0); assert_eq!(state.identity.return_cohort_anchor, Some(observed_day)); @@ -676,9 +704,10 @@ mod tests { 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 completed_at = completion_time(2026, 10, 25); + let observed_day = completed_at.day(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); let serialized = toml::to_string_pretty(&state).unwrap(); let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); @@ -701,15 +730,15 @@ mod tests { 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), + for (window_anchor, completed_at, expected_day) in [ + ("2026-08-11", completion_time(2026, 8, 11), 0_i64), + ("2026-08-11", completion_time(2026, 8, 12), 1), + ("2026-09-01", completion_time(2026, 9, 10), 30), ] { let source = state_with_anchors(KEY, window_anchor, "2026-08-11"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); assert_eq!( observation.return_cohort.day(), @@ -727,9 +756,9 @@ mod tests { 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 completed_at = completion_time(2026, 9, 11); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); let serialized = toml::to_string_pretty(&state).unwrap(); let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); @@ -747,9 +776,10 @@ mod tests { 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 completed_at = completion_time(2026, 9, 11); + let observed_day = completed_at.day(); - let observation = state.observe_session(observed_day).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); let serialized = toml::to_string_pretty(&state).unwrap(); let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); @@ -773,7 +803,9 @@ mod tests { 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 error = state + .observe_session(completion_time(2026, 9, 9)) + .unwrap_err(); let serialized = toml::to_string_pretty(&state).unwrap(); assert_eq!( diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index 4978e839..7b62a46e 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -16,6 +16,8 @@ use super::{ mod lifecycle; +pub(in crate::telemetry) use lifecycle::BoundSessionObservation; + /// The initial schema version of `telemetry-state.toml`. /// /// Exact rather than permissive, unlike a row's `SchemaVersion`: a row written by From 925d97a5d7f2f226158153378d9f277d309a2d91 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 14:41:15 +0300 Subject: [PATCH 50/57] Define eligible command coordinates Add the built-in and public plugin command vocabulary, including a normalized remove operation for cargo agents use --remove. Tie built-in telemetry to the parsed CLI with an exhaustive mapping, and generate shared public-name validation errors from one macro. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 4 +- src/telemetry/schema/command.rs | 425 ++++++++++++++++++ src/telemetry/schema/extension.rs | 59 +-- src/telemetry/schema/mod.rs | 20 +- src/telemetry/schema/name.rs | 101 ++++- src/telemetry/schema/resolution/package.rs | 56 +-- 7 files changed, 551 insertions(+), 116 deletions(-) create mode 100644 src/telemetry/schema/command.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 7278093c..b2df6d59 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, and session start derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. +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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, and session start derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index b212ec9a..40bbad3b 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -390,7 +390,7 @@ This row records one completed eligible top-level user command. Arguments are never recorded. Internal `hook`, all `telemetry` commands, and ineligible external/plugin commands do not produce command events. -Built-in names are `init`, `sync`, `search`, `use`, `status`, `plugin_sync`, `plugin_list`, `plugin_show`, `plugin_validate`, `self_update`, and `crate_info`: +Built-in command operations are `init`, `sync`, `search`, `use`, `remove`, `status`, `plugin_sync`, `plugin_list`, `plugin_show`, `plugin_validate`, `self_update`, and `crate_info`. `remove` is the telemetry name for `cargo agents use --remove`; it is not a separate CLI subcommand. ```json {"type":"builtin","name":"use"} @@ -402,6 +402,8 @@ An eligible plugin command contains only its reviewed public-source label, publi {"type":"plugin","source":"symposium-recommendations","plugin":"example-tools","name":"example-check"} ``` +Public plugin-command names use a fixed version 1 grammar: 1 through 64 ASCII bytes, beginning with an ASCII letter or digit, followed by ASCII letters, digits, `-`, or `_`. Symposium preserves the spelling without case folding or Unicode normalization. A plugin command outside this telemetry grammar remains usable but does not produce a command event. + ### `storage_limit` This row means that the next complete low-volume event batch did not fit in the shared daily 8 MiB allowance. In addition to the common fields, `dropped_operation` is `session_start`, `manual_sync`, `use`, `remove`, `init`, `configuration`, or `command`. It identifies the top-level operation whose batch was rejected. diff --git a/src/telemetry/schema/command.rs b/src/telemetry/schema/command.rs new file mode 100644 index 00000000..a73a9428 --- /dev/null +++ b/src/telemetry/schema/command.rs @@ -0,0 +1,425 @@ +//! Eligible command vocabulary shared by command telemetry. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::{ + extension::{PublicExtensionName, PublicExtensionNameError, PublicExtensionSource}, + name::{InitialByteRule, validated_string_newtype}, +}; +use crate::cli::{Commands, PluginCommand}; + +const MAX_PUBLIC_COMMAND_NAME_BYTES: usize = 64; + +/// Built-in command eligible for version 1 telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum BuiltinCommand { + Init, + Sync, + Search, + Use, + Remove, + Status, + PluginSync, + PluginList, + PluginShow, + PluginValidate, + SelfUpdate, + CrateInfo, +} + +impl BuiltinCommand { + /// Classify a parsed top-level CLI command for command telemetry. + /// + /// Hook and telemetry commands are excluded by the version 1 contract. + /// External commands are classified separately using their public plugin + /// provenance. + #[must_use] + pub(in crate::telemetry) const fn from_cli(command: &Commands) -> Option { + match command { + Commands::Init { .. } => Some(Self::Init), + Commands::Sync => Some(Self::Sync), + Commands::Search { .. } => Some(Self::Search), + Commands::Use { remove: false, .. } => Some(Self::Use), + Commands::Use { remove: true, .. } => Some(Self::Remove), + Commands::Status => Some(Self::Status), + Commands::Plugin { command } => Some(Self::from_plugin_cli(command)), + Commands::SelfUpdate => Some(Self::SelfUpdate), + Commands::CrateInfo { .. } => Some(Self::CrateInfo), + Commands::Hook { .. } | Commands::Telemetry { .. } | Commands::External(_) => None, + } + } + + const fn from_plugin_cli(command: &PluginCommand) -> Self { + match command { + PluginCommand::Sync { .. } => Self::PluginSync, + PluginCommand::List => Self::PluginList, + PluginCommand::Show { .. } => Self::PluginShow, + PluginCommand::Validate { .. } => Self::PluginValidate, + } + } + + /// Return the frozen version 1 wire label. + #[must_use] + pub(in crate::telemetry) const fn as_str(self) -> &'static str { + match self { + Self::Init => "init", + Self::Sync => "sync", + Self::Search => "search", + Self::Use => "use", + Self::Remove => "remove", + Self::Status => "status", + Self::PluginSync => "plugin_sync", + Self::PluginList => "plugin_list", + Self::PluginShow => "plugin_show", + Self::PluginValidate => "plugin_validate", + Self::SelfUpdate => "self_update", + Self::CrateInfo => "crate_info", + } + } +} + +/// Closed result of an eligible command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(in crate::telemetry) enum CommandOutcome { + Ok, + Error, +} + +validated_string_newtype! { + /// Public plugin-command name accepted by the version 1 telemetry contract. + pub(in crate::telemetry) struct PublicCommandName { + error = PublicCommandNameError; + maximum_bytes = MAX_PUBLIC_COMMAND_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphanumeric; + invalid_initial = NonAlphanumericFirstCharacter; + noun = "public command name"; + as_str_doc = "Return the validated command name without changing its spelling."; + } +} + +/// Eligible public plugin command safe to place in telemetry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::telemetry) struct PublicPluginCommandCoordinate { + source: PublicExtensionSource, + plugin: PublicExtensionName, + name: PublicCommandName, +} + +impl PublicPluginCommandCoordinate { + /// Combine validated components into one public plugin-command coordinate. + #[must_use] + pub(in crate::telemetry) const fn new( + source: PublicExtensionSource, + plugin: PublicExtensionName, + name: PublicCommandName, + ) -> Self { + Self { + source, + plugin, + name, + } + } + + /// Validate raw names and combine them with an allowlisted public source. + /// + /// # Errors + /// + /// Returns an error when either name is outside its version 1 public + /// telemetry grammar. + pub(in crate::telemetry) fn try_new( + source: PublicExtensionSource, + plugin: &str, + name: &str, + ) -> Result { + Ok(Self::new(source, plugin.parse()?, name.parse()?)) + } +} + +/// Reason a public plugin-command coordinate is ineligible for telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) enum InvalidPublicPluginCommandCoordinate { + PluginName(PublicExtensionNameError), + CommandName(PublicCommandNameError), +} + +impl From for InvalidPublicPluginCommandCoordinate { + fn from(error: PublicExtensionNameError) -> Self { + Self::PluginName(error) + } +} + +impl From for InvalidPublicPluginCommandCoordinate { + fn from(error: PublicCommandNameError) -> Self { + Self::CommandName(error) + } +} + +impl fmt::Display for InvalidPublicPluginCommandCoordinate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PluginName(error) => write!(formatter, "invalid plugin name: {error}"), + Self::CommandName(error) => write!(formatter, "invalid command name: {error}"), + } + } +} + +impl std::error::Error for InvalidPublicPluginCommandCoordinate { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PluginName(error) => Some(error), + Self::CommandName(error) => Some(error), + } + } +} + +/// Typed coordinate of an eligible command. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(in crate::telemetry) enum CommandCoordinate { + Builtin { name: BuiltinCommand }, + Plugin(PublicPluginCommandCoordinate), +} + +impl CommandCoordinate { + /// Build a coordinate for a fixed built-in command. + #[must_use] + pub(in crate::telemetry) const fn builtin(name: BuiltinCommand) -> Self { + Self::Builtin { name } + } + + /// Build a coordinate for an eligible public plugin command. + #[must_use] + pub(in crate::telemetry) const fn plugin(coordinate: PublicPluginCommandCoordinate) -> Self { + Self::Plugin(coordinate) + } +} + +#[cfg(test)] +mod tests { + use clap::Parser as _; + + use super::super::{ + assert_contract_names, assert_contract_names_with_labels, recorded_data_example_block_at, + }; + use super::*; + use crate::cli::Cli; + + fn parse_command(arguments: &[&str]) -> Commands { + Cli::try_parse_from(std::iter::once("cargo-agents").chain(arguments.iter().copied())) + .unwrap() + .command + .unwrap() + } + + #[test] + fn cli_commands_map_exhaustively_to_telemetry_builtins() { + let cases: &[(&[&str], Option)] = &[ + (&["init"], Some(BuiltinCommand::Init)), + (&["sync"], Some(BuiltinCommand::Sync)), + (&["search", "serde"], Some(BuiltinCommand::Search)), + (&["use", "serde"], Some(BuiltinCommand::Use)), + (&["use", "serde", "--remove"], Some(BuiltinCommand::Remove)), + (&["status"], Some(BuiltinCommand::Status)), + (&["plugin", "sync"], Some(BuiltinCommand::PluginSync)), + (&["plugin", "list"], Some(BuiltinCommand::PluginList)), + ( + &["plugin", "show", "example-tools"], + Some(BuiltinCommand::PluginShow), + ), + ( + &["plugin", "validate", "."], + Some(BuiltinCommand::PluginValidate), + ), + (&["self-update"], Some(BuiltinCommand::SelfUpdate)), + (&["crate-info", "serde"], Some(BuiltinCommand::CrateInfo)), + (&["hook", "claude", "session-start"], None), + (&["telemetry"], None), + (&["example-check"], None), + ]; + + for &(arguments, expected) in cases { + let command = parse_command(arguments); + + assert_eq!(BuiltinCommand::from_cli(&command), expected); + } + } + + #[test] + fn builtin_commands_round_trip_with_contract_names() { + let cases = [ + (BuiltinCommand::Init, "init"), + (BuiltinCommand::Sync, "sync"), + (BuiltinCommand::Search, "search"), + (BuiltinCommand::Use, "use"), + (BuiltinCommand::Remove, "remove"), + (BuiltinCommand::Status, "status"), + (BuiltinCommand::PluginSync, "plugin_sync"), + (BuiltinCommand::PluginList, "plugin_list"), + (BuiltinCommand::PluginShow, "plugin_show"), + (BuiltinCommand::PluginValidate, "plugin_validate"), + (BuiltinCommand::SelfUpdate, "self_update"), + (BuiltinCommand::CrateInfo, "crate_info"), + ]; + + assert_contract_names_with_labels(&cases, BuiltinCommand::as_str); + } + + #[test] + fn command_outcomes_round_trip_with_contract_names() { + let cases = [(CommandOutcome::Ok, "ok"), (CommandOutcome::Error, "error")]; + + assert_contract_names(&cases); + } + + #[test] + fn command_vocabulary_rejects_unknown_contract_names() { + let builtin = serde_json::from_str::(r#""telemetry""#); + let outcome = serde_json::from_str::(r#""cancelled""#); + + assert!(builtin.is_err()); + assert!(outcome.is_err()); + } + + #[test] + fn public_command_names_accept_the_contract_grammar() { + for value in ["0", "Example-check_2", &"a".repeat(64)] { + let name = value.parse::().unwrap(); + + assert_eq!(name.as_str(), value); + } + } + + #[test] + fn public_command_names_reject_invalid_length() { + let empty = "".parse::(); + let too_long = "a".repeat(65).parse::(); + + assert_eq!(empty.unwrap_err(), PublicCommandNameError::Empty); + assert_eq!(too_long.unwrap_err(), PublicCommandNameError::TooLong); + } + + #[test] + fn public_command_names_require_an_ascii_alphanumeric_first_byte() { + for value in ["-check", "_check", "\u{e9}check"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicCommandNameError::NonAlphanumericFirstCharacter + ); + } + } + + #[test] + fn public_command_names_reject_unsupported_characters() { + for value in ["plugin.check", "plugin check", "plugin/check", "a\u{e9}"] { + let result = value.parse::(); + + assert_eq!( + result.unwrap_err(), + PublicCommandNameError::UnsupportedCharacter + ); + } + } + + #[test] + fn public_command_names_round_trip_without_normalization() { + let name = "Example-check_2".parse::().unwrap(); + + let encoded = serde_json::to_string(&name).unwrap(); + let decoded = serde_json::from_str::(&encoded).unwrap(); + + assert_eq!(encoded, r#""Example-check_2""#); + assert_eq!(decoded, name); + } + + #[test] + fn plugin_command_coordinate_round_trips_in_contract_shape() { + let coordinate = PublicPluginCommandCoordinate::try_new( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ) + .unwrap(); + let command = CommandCoordinate::plugin(coordinate); + + let json = serde_json::to_string(&command).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + recorded_data_example_block_at("### `command`", "```json", 1).trim() + ); + assert_eq!(decoded, command); + } + + #[test] + fn builtin_command_coordinate_round_trips_in_contract_shape() { + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + + let json = serde_json::to_string(&command).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + + assert_eq!( + json, + recorded_data_example_block_at("### `command`", "```json", 0).trim() + ); + assert_eq!(decoded, command); + } + + #[test] + fn plugin_command_coordinate_validates_both_names() { + let invalid_plugin = PublicPluginCommandCoordinate::try_new( + PublicExtensionSource::CratesIo, + "private/plugin", + "check", + ); + let invalid_command = PublicPluginCommandCoordinate::try_new( + PublicExtensionSource::CratesIo, + "example-tools", + "private/check", + ); + let invalid_json = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools","name":"private/check"}"#, + ); + + assert_eq!( + invalid_plugin.unwrap_err(), + InvalidPublicPluginCommandCoordinate::PluginName( + PublicExtensionNameError::UnsupportedCharacter + ) + ); + assert_eq!( + invalid_command.unwrap_err(), + InvalidPublicPluginCommandCoordinate::CommandName( + PublicCommandNameError::UnsupportedCharacter + ) + ); + assert!(invalid_json.is_err()); + } + + #[test] + fn command_coordinates_reject_unknown_or_missing_fields() { + let unknown_builtin_field = serde_json::from_str::( + r#"{"type":"builtin","name":"use","args":"example-tools"}"#, + ); + let unknown_plugin_field = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools","name":"check","args":"--all"}"#, + ); + let missing_field = serde_json::from_str::( + r#"{"type":"plugin","source":"crates-io","plugin":"example-tools"}"#, + ); + let unknown_type = + serde_json::from_str::(r#"{"type":"external","name":"check"}"#); + + assert!(unknown_builtin_field.is_err()); + assert!(unknown_plugin_field.is_err()); + assert!(missing_field.is_err()); + assert!(unknown_type.is_err()); + } +} diff --git a/src/telemetry/schema/extension.rs b/src/telemetry/schema/extension.rs index ae01048c..973888b9 100644 --- a/src/telemetry/schema/extension.rs +++ b/src/telemetry/schema/extension.rs @@ -1,12 +1,8 @@ //! Public extension vocabulary shared by telemetry rows. -use std::fmt; - use serde::{Deserialize, Serialize}; -use super::name::{ - InitialByteRule, PublicNameViolation, validate_public_name, validated_string_newtype, -}; +use super::name::{InitialByteRule, validated_string_newtype}; const MAX_PUBLIC_EXTENSION_NAME_BYTES: usize = 64; @@ -52,59 +48,14 @@ validated_string_newtype! { /// Public plugin or skill name accepted by the version 1 telemetry contract. pub(in crate::telemetry) struct PublicExtensionName { error = PublicExtensionNameError; - validate = validate_public_extension_name; + maximum_bytes = MAX_PUBLIC_EXTENSION_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphanumeric; + invalid_initial = NonAlphanumericFirstCharacter; + noun = "public extension name"; as_str_doc = "Return the validated extension name without changing its spelling."; } } -fn validate_public_extension_name(value: &str) -> Result<(), PublicExtensionNameError> { - validate_public_name( - value, - MAX_PUBLIC_EXTENSION_NAME_BYTES, - InitialByteRule::Alphanumeric, - ) - .map_err(PublicExtensionNameError::from) -} - -/// Reason an extension name cannot enter public telemetry. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::telemetry) enum PublicExtensionNameError { - Empty, - TooLong, - NonAlphanumericFirstCharacter, - UnsupportedCharacter, -} - -impl From for PublicExtensionNameError { - fn from(violation: PublicNameViolation) -> Self { - match violation { - PublicNameViolation::Empty => Self::Empty, - PublicNameViolation::TooLong => Self::TooLong, - PublicNameViolation::InvalidInitialByte => Self::NonAlphanumericFirstCharacter, - PublicNameViolation::UnsupportedCharacter => Self::UnsupportedCharacter, - } - } -} - -impl fmt::Display for PublicExtensionNameError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => formatter.write_str("public extension name must not be empty"), - Self::TooLong => write!( - formatter, - "public extension name exceeds {MAX_PUBLIC_EXTENSION_NAME_BYTES} bytes" - ), - Self::NonAlphanumericFirstCharacter => formatter - .write_str("public extension name must start with an ASCII letter or digit"), - Self::UnsupportedCharacter => formatter.write_str( - "public extension name may contain only ASCII letters, digits, hyphens, and underscores", - ), - } - } -} - -impl std::error::Error for PublicExtensionNameError {} - /// Public plugin or skill coordinate safe to place in telemetry. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index bfd22889..58cacb9c 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -5,6 +5,7 @@ )] mod agent; +mod command; mod extension; mod name; mod resolution; @@ -436,12 +437,25 @@ identifier-window-anchor = "2026-08-03" #[cfg(test)] fn recorded_data_example_block(section_heading: &str, opening_fence: &str) -> &'static str { + recorded_data_example_block_at(section_heading, opening_fence, 0) +} + +#[cfg(test)] +fn recorded_data_example_block_at( + section_heading: &str, + opening_fence: &str, + block_index: usize, +) -> &'static str { let (_, after_heading) = RECORDED_DATA_CONTRACT .split_once(section_heading) .unwrap_or_else(|| panic!("recorded-data contract must contain {section_heading}")); - let (_, after_fence) = after_heading - .split_once(opening_fence) - .unwrap_or_else(|| panic!("{section_heading} must contain an {opening_fence} block")); + let after_fence = after_heading + .split(opening_fence) + .skip(1) + .nth(block_index) + .unwrap_or_else(|| { + panic!("{section_heading} must contain {opening_fence} block {block_index}") + }); let (example_block, _) = after_fence .split_once("```") .unwrap_or_else(|| panic!("{section_heading} example block must have a closing fence")); diff --git a/src/telemetry/schema/name.rs b/src/telemetry/schema/name.rs index 37a49445..5bfe5fbc 100644 --- a/src/telemetry/schema/name.rs +++ b/src/telemetry/schema/name.rs @@ -1,13 +1,15 @@ //! Validation shared by public names in the telemetry contract. -/// Define a string newtype whose constructors and deserializer enforce one -/// validation function. +/// Define a public-name newtype and its grammar-specific error type. macro_rules! validated_string_newtype { ( $(#[$metadata:meta])* $visibility:vis struct $name:ident { - error = $error:ty; - validate = $validate:path; + error = $error:ident; + maximum_bytes = $maximum_bytes:expr; + initial_byte_rule = $initial_byte_rule:expr; + invalid_initial = $invalid_initial:ident; + noun = $noun:literal; as_str_doc = $as_str_doc:literal; } ) => { @@ -38,7 +40,12 @@ macro_rules! validated_string_newtype { type Error = $error; fn try_from(value: String) -> Result { - ($validate)(&value)?; + $crate::telemetry::schema::name::validate_public_name( + &value, + $maximum_bytes, + $initial_byte_rule, + ) + .map_err($error::from)?; Ok(Self(value)) } } @@ -47,7 +54,12 @@ macro_rules! validated_string_newtype { type Err = $error; fn from_str(value: &str) -> Result { - ($validate)(value)?; + $crate::telemetry::schema::name::validate_public_name( + value, + $maximum_bytes, + $initial_byte_rule, + ) + .map_err($error::from)?; Ok(Self(value.to_owned())) } } @@ -57,6 +69,59 @@ macro_rules! validated_string_newtype { formatter.write_str(&self.0) } } + + #[doc = concat!("Reason a ", $noun, " cannot enter public telemetry.")] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + $visibility enum $error { + Empty, + TooLong, + $invalid_initial, + UnsupportedCharacter, + } + + impl From<$crate::telemetry::schema::name::PublicNameViolation> for $error { + fn from( + violation: $crate::telemetry::schema::name::PublicNameViolation, + ) -> Self { + match violation { + $crate::telemetry::schema::name::PublicNameViolation::Empty => Self::Empty, + $crate::telemetry::schema::name::PublicNameViolation::TooLong => Self::TooLong, + $crate::telemetry::schema::name::PublicNameViolation::InvalidInitialByte => { + Self::$invalid_initial + } + $crate::telemetry::schema::name::PublicNameViolation::UnsupportedCharacter => { + Self::UnsupportedCharacter + } + } + } + } + + impl std::fmt::Display for $error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(formatter, "{} must not be empty", $noun), + Self::TooLong => write!( + formatter, + "{} exceeds {} bytes", + $noun, + $maximum_bytes, + ), + Self::$invalid_initial => write!( + formatter, + "{} must start with {}", + $noun, + ($initial_byte_rule).description(), + ), + Self::UnsupportedCharacter => write!( + formatter, + "{} may contain only ASCII letters, digits, hyphens, and underscores", + $noun, + ), + } + } + } + + impl std::error::Error for $error {} }; } @@ -69,6 +134,16 @@ pub(super) enum InitialByteRule { Alphanumeric, } +impl InitialByteRule { + /// Describe the initial byte accepted by this validation rule. + pub(super) const fn description(self) -> &'static str { + match self { + Self::Alphabetic => "an ASCII letter", + Self::Alphanumeric => "an ASCII letter or digit", + } + } +} + /// Structural reason a public name fails its versioned grammar. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum PublicNameViolation { @@ -109,3 +184,17 @@ pub(super) fn validate_public_name( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_byte_rules_describe_their_enforced_grammar() { + assert_eq!(InitialByteRule::Alphabetic.description(), "an ASCII letter"); + assert_eq!( + InitialByteRule::Alphanumeric.description(), + "an ASCII letter or digit" + ); + } +} diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index 2121b7d6..bb49e1bf 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use super::super::{ EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, - name::{InitialByteRule, PublicNameViolation, validate_public_name, validated_string_newtype}, + name::{InitialByteRule, validated_string_newtype}, }; use crate::telemetry::identity::{ DimensionWriter, IdentifierWindowScope, IdentityDimension, PackageDomain, PackageSubject, @@ -44,60 +44,14 @@ validated_string_newtype! { /// Public package name accepted by the version 1 telemetry contract. pub(in crate::telemetry) struct PublicPackageName { error = PublicPackageNameError; - validate = validate_public_package_name; + maximum_bytes = MAX_PUBLIC_PACKAGE_NAME_BYTES; + initial_byte_rule = InitialByteRule::Alphabetic; + invalid_initial = NonAlphabeticFirstCharacter; + noun = "public package name"; as_str_doc = "Return the validated package name."; } } -fn validate_public_package_name(value: &str) -> Result<(), PublicPackageNameError> { - validate_public_name( - value, - MAX_PUBLIC_PACKAGE_NAME_BYTES, - InitialByteRule::Alphabetic, - ) - .map_err(PublicPackageNameError::from) -} - -/// Reason a package name cannot enter public telemetry. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::telemetry) enum PublicPackageNameError { - Empty, - TooLong, - NonAlphabeticFirstCharacter, - UnsupportedCharacter, -} - -impl From for PublicPackageNameError { - fn from(violation: PublicNameViolation) -> Self { - match violation { - PublicNameViolation::Empty => Self::Empty, - PublicNameViolation::TooLong => Self::TooLong, - PublicNameViolation::InvalidInitialByte => Self::NonAlphabeticFirstCharacter, - PublicNameViolation::UnsupportedCharacter => Self::UnsupportedCharacter, - } - } -} - -impl fmt::Display for PublicPackageNameError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => formatter.write_str("public package name must not be empty"), - Self::TooLong => write!( - formatter, - "public package name exceeds {MAX_PUBLIC_PACKAGE_NAME_BYTES} bytes" - ), - Self::NonAlphabeticFirstCharacter => { - formatter.write_str("public package name must start with an ASCII letter") - } - Self::UnsupportedCharacter => formatter.write_str( - "public package name may contain only ASCII letters, digits, hyphens, and underscores", - ), - } - } -} - -impl std::error::Error for PublicPackageNameError {} - /// Exact semantic version attached to a public package coordinate. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(in crate::telemetry) struct ExactPackageVersion(Version); From 3fdbbdc720b87e4140ef0e8babe349a184cab3d1 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 15:04:01 +0300 Subject: [PATCH 51/57] Test session timing at UTC midnight Exercise a session completed at the final second of a UTC day. Keep the row date, completion timestamp, and stored return-cohort anchor aligned so a boundary regression cannot silently lose the D0 record. --- src/telemetry/schema/agent.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index be88f2ca..6551d17a 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -760,6 +760,27 @@ mod tests { assert_eq!(row.cohort_day, CohortDay::D0); } + #[test] + fn session_start_before_utc_midnight_keeps_row_and_cohort_on_the_same_day() { + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 23, 59, 59).unwrap()); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + let row = SessionStartV1::new(session_start_fields(None), &observation); + let stored_state = toml::to_string(&state).unwrap(); + let stored_state = toml::from_str::(&stored_state).unwrap(); + let cohort_anchor = stored_state["identity"]["return-cohort-anchor"] + .as_str() + .unwrap(); + + assert_eq!(row.at, completed_at); + assert_eq!(row.day, completed_at.day()); + assert_eq!(cohort_anchor, row.day.to_string()); + assert_eq!(row.cohort_day, CohortDay::D0); + } + #[test] fn new_agent_configuration_derives_subject_from_its_agent() { let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); From 933992dc5e1c6bba5ec7bcb55be5f08373deae7d Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 15:39:04 +0300 Subject: [PATCH 52/57] Derive command subjects from typed coordinates Use each validated command coordinate as the complete input to its stable telemetry identifier. Tests pin built-in and plugin field order, an independently checked digest, and separation between coordinates. --- src/telemetry/schema/command.rs | 147 ++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/src/telemetry/schema/command.rs b/src/telemetry/schema/command.rs index a73a9428..9df2b4df 100644 --- a/src/telemetry/schema/command.rs +++ b/src/telemetry/schema/command.rs @@ -8,7 +8,10 @@ use super::{ extension::{PublicExtensionName, PublicExtensionNameError, PublicExtensionSource}, name::{InitialByteRule, validated_string_newtype}, }; -use crate::cli::{Commands, PluginCommand}; +use crate::{ + cli::{Commands, PluginCommand}, + telemetry::identity::{CommandDomain, DimensionWriter, IdentityDimension}, +}; const MAX_PUBLIC_COMMAND_NAME_BYTES: usize = 64; @@ -199,15 +202,37 @@ impl CommandCoordinate { } } +impl IdentityDimension for CommandCoordinate { + type Domain = CommandDomain; + + /// Write the version 1 `command_subject` fields in contract order. + fn write(&self, writer: &mut DimensionWriter<'_>) { + match self { + Self::Builtin { name } => writer.variant("builtin", |writer| { + writer.field(name.as_str().as_bytes()); + }), + Self::Plugin(coordinate) => writer.variant("plugin", |writer| { + writer.field(coordinate.source.as_str().as_bytes()); + writer.field(coordinate.plugin.as_str().as_bytes()); + writer.field(coordinate.name.as_str().as_bytes()); + }), + } + } +} + #[cfg(test)] mod tests { use clap::Parser as _; use super::super::{ - assert_contract_names, assert_contract_names_with_labels, recorded_data_example_block_at, + IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, + recorded_data_example_block_at, }; use super::*; - use crate::cli::Cli; + use crate::{ + cli::Cli, + telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}, + }; fn parse_command(arguments: &[&str]) -> Commands { Cli::try_parse_from(std::iter::once("cargo-agents").chain(arguments.iter().copied())) @@ -216,6 +241,16 @@ mod tests { .unwrap() } + fn plugin_command( + source: PublicExtensionSource, + plugin: &str, + name: &str, + ) -> CommandCoordinate { + CommandCoordinate::plugin( + PublicPluginCommandCoordinate::try_new(source, plugin, name).unwrap(), + ) + } + #[test] fn cli_commands_map_exhaustively_to_telemetry_builtins() { let cases: &[(&[&str], Option)] = &[ @@ -340,13 +375,11 @@ mod tests { #[test] fn plugin_command_coordinate_round_trips_in_contract_shape() { - let coordinate = PublicPluginCommandCoordinate::try_new( + let command = plugin_command( PublicExtensionSource::SymposiumRecommendations, "example-tools", "example-check", - ) - .unwrap(); - let command = CommandCoordinate::plugin(coordinate); + ); let json = serde_json::to_string(&command).unwrap(); let decoded = serde_json::from_str::(&json).unwrap(); @@ -372,6 +405,106 @@ mod tests { assert_eq!(decoded, command); } + #[test] + fn builtin_command_subject_dimension_uses_type_then_name() { + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 7].as_slice(), + b"builtin".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 3].as_slice(), + b"use".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&command); + + assert_eq!(encoded, expected); + } + + #[test] + fn plugin_command_subject_dimension_uses_contract_field_order() { + let command = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + let expected = [ + [0, 0, 0, 0, 0, 0, 0, 6].as_slice(), + b"plugin".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 25].as_slice(), + b"symposium-recommendations".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 13].as_slice(), + b"example-tools".as_slice(), + [0, 0, 0, 0, 0, 0, 0, 13].as_slice(), + b"example-check".as_slice(), + ] + .concat(); + + let encoded = encode_dimension_for_test(&command); + + assert_eq!(encoded, expected); + } + + #[test] + fn command_subject_derivation_matches_independent_vector() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let command = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + + let subject = identity.derive(&command); + + // Cross-checked with .NET's HMACSHA256 over the contract header, + // identifier window, command type, source, plugin name, and command + // name. The complete digest is + // c50f828e42f9eb719589039d90da38fa69c82f644689a19ce34562513a236c41. + assert_eq!( + subject, + "cmd_c50f828e42f9eb719589039d90da38fa".parse().unwrap() + ); + } + + #[test] + fn command_subject_changes_with_the_typed_coordinate() { + let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let identity = state.identifier_window_scope(); + let baseline = plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "example-check", + ); + let changed_coordinates = [ + CommandCoordinate::builtin(BuiltinCommand::Use), + plugin_command( + PublicExtensionSource::CratesIo, + "example-tools", + "example-check", + ), + plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "other-tools", + "example-check", + ), + plugin_command( + PublicExtensionSource::SymposiumRecommendations, + "example-tools", + "other-check", + ), + ]; + + let baseline_subject = identity.derive(&baseline); + let use_subject = identity.derive(&CommandCoordinate::builtin(BuiltinCommand::Use)); + let remove_subject = identity.derive(&CommandCoordinate::builtin(BuiltinCommand::Remove)); + + for coordinate in &changed_coordinates { + assert_ne!(identity.derive(coordinate), baseline_subject); + } + assert_ne!(use_subject, remove_subject); + } + #[test] fn plugin_command_coordinate_validates_both_names() { let invalid_plugin = PublicPluginCommandCoordinate::try_new( From 8d03fb012171970f6a68772c8c891365d7b1d268 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 19:23:21 +0300 Subject: [PATCH 53/57] Rotate identifiers for every recording operation Add a window-only observation that advances identifier state without touching return cohorts. Bind timestamps, days, and identity scopes to the selected window before rows can consume them. Session observations now reuse the same transition, keeping window selection and stale-state checks in one place. --- src/telemetry/state/lifecycle.rs | 386 +++++++++++++++++++++++++------ 1 file changed, 316 insertions(+), 70 deletions(-) diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs index b9f5f98a..d0dc8a70 100644 --- a/src/telemetry/state/lifecycle.rs +++ b/src/telemetry/state/lifecycle.rs @@ -64,6 +64,68 @@ impl TelemetryStateV1 { Ok(()) } + /// Observe one recording operation at its captured completion timestamp. + /// + /// This selects and, when needed, advances the identifier window without + /// changing the return cohort. One operation should reuse the returned + /// observation for every row it emits, so a batch cannot cross identity + /// windows partway through. + /// + /// Storage must call this while holding the telemetry lock, after rejecting + /// a day before the latest-opened-day high-water mark. Any high-water + /// advancement and this transition belong to one private-state replacement. + /// That replacement must complete before the observation is bound or its + /// selected anchor is used to derive an identifier. + /// + /// # Errors + /// + /// Returns an error if the timestamp's UTC day precedes the stored + /// identifier-window anchor. A conforming storage caller filters this case + /// through its durable day policy; the check protects against an incorrect + /// caller or inconsistent state. State does not change on failure. + pub(in crate::telemetry) fn observe_recording( + &mut self, + completed_at: UtcSecond, + ) -> Result { + let observation = self.select_recording(completed_at)?; + self.identity.identifier_window_anchor = observation.identifier_window.anchor(); + Ok(observation) + } + + /// Bind a completed recording transition to the unchanged private state. + /// + /// Storage calls this only after atomically persisting the state changed by + /// [`Self::observe_recording`]. The selected anchor is checked before its + /// identity scope is exposed. Storage must still bind immediately after + /// persistence while holding the same telemetry lock; the anchor does not + /// identify a private-state instance by itself. When private-state + /// persistence is implemented, its successful write token will become an + /// additional required binding input so this ordering is structural. + /// + /// # Errors + /// + /// Returns an error when the stored identifier-window anchor differs from + /// the anchor selected by `observation`. + pub(in crate::telemetry) fn bind_recording_observation( + &self, + observation: RecordingObservation, + ) -> Result, RecordingObservationBindingError> { + let selected_anchor = observation.identifier_window.anchor(); + let current_anchor = self.identity.identifier_window_anchor; + if selected_anchor != current_anchor { + return Err(RecordingObservationBindingError { + selected_anchor, + current_anchor, + }); + } + + Ok(BoundRecordingObservation { + completed_at: observation.completed_at, + identifier_window: observation.identifier_window, + identifier_window_scope: self.identifier_window_scope(), + }) + } + /// Observe a session at its captured completion timestamp. /// /// This selects the identifier window and return cohort before mutating @@ -88,16 +150,15 @@ impl TelemetryStateV1 { &mut self, completed_at: UtcSecond, ) -> Result { - let effective_day = completed_at.day(); - let identifier_window = self.select_identifier_window(effective_day)?; + let recording = self.select_recording(completed_at)?; + let effective_day = recording.completed_at.day(); let return_cohort = self.select_return_cohort(effective_day)?; - self.identity.identifier_window_anchor = identifier_window.anchor(); + self.identity.identifier_window_anchor = recording.identifier_window.anchor(); self.identity.return_cohort_anchor = Some(return_cohort.anchor()); Ok(SessionObservation { - completed_at, - identifier_window, + recording, return_cohort, }) } @@ -121,14 +182,7 @@ impl TelemetryStateV1 { &self, observation: SessionObservation, ) -> Result, SessionObservationBindingError> { - let selected_identifier_window = observation.identifier_window.anchor(); - let current_identifier_window = self.identity.identifier_window_anchor; - if selected_identifier_window != current_identifier_window { - return Err(SessionObservationBindingError::IdentifierWindowChanged { - selected_anchor: selected_identifier_window, - current_anchor: current_identifier_window, - }); - } + let recording = self.bind_recording_observation(observation.recording)?; let selected_return_cohort = observation.return_cohort.anchor(); let current_return_cohort = self.identity.return_cohort_anchor; @@ -139,30 +193,39 @@ impl TelemetryStateV1 { }); } - let identifier_window_scope = self.identifier_window_scope(); let return_cohort_scope = self .return_cohort_scope() .expect("BUG: the checked return-cohort anchor must be present"); Ok(BoundSessionObservation { - completed_at: observation.completed_at, - identifier_window: observation.identifier_window, + recording, return_cohort: observation.return_cohort, - identifier_window_scope, return_cohort_scope, }) } + /// Select a recording operation without mutating private state. + fn select_recording( + &self, + completed_at: UtcSecond, + ) -> Result { + let identifier_window = self.select_identifier_window(completed_at.day())?; + Ok(RecordingObservation { + completed_at, + identifier_window, + }) + } + /// Select the identifier window without mutating private state. fn select_identifier_window( &self, effective_day: UtcDay, - ) -> Result { + ) -> Result { let anchor = self.identity.identifier_window_anchor; let elapsed_days = effective_day.days_since(anchor); if elapsed_days < 0 { - return Err(SessionObservationError::BeforeIdentifierWindow { + return Err(RecordingObservationError { observed_day: effective_day, window_anchor: anchor, }); @@ -208,22 +271,93 @@ impl TelemetryStateV1 { } } +/// Identity-window selection for one recording operation. +#[must_use = "recording identity state must be persisted before identifiers are emitted"] +#[derive(Debug, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservation { + completed_at: UtcSecond, + pub(super) identifier_window: IdentifierWindowUpdate, +} + +/// A persisted recording transition bound to its identifier-window scope. +#[must_use = "a bound recording observation supplies timestamp and identity context"] +pub(in crate::telemetry) struct BoundRecordingObservation<'a> { + completed_at: UtcSecond, + identifier_window: IdentifierWindowUpdate, + identifier_window_scope: IdentifierWindowScope<'a>, +} + +impl BoundRecordingObservation<'_> { + /// Return when Symposium completed the observed recording operation. + #[must_use] + pub(in crate::telemetry) fn completed_at(&self) -> UtcSecond { + self.completed_at + } + + /// Return the UTC day derived from the operation's completion timestamp. + #[must_use] + pub(in crate::telemetry) fn day(&self) -> UtcDay { + self.completed_at.day() + } + + /// Return identity material bound to the selected identifier window. + #[must_use] + pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { + &self.identifier_window_scope + } +} + +/// A recording observation whose selected window no longer matches state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservationBindingError { + selected_anchor: UtcDay, + current_anchor: UtcDay, +} + +impl fmt::Display for RecordingObservationBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "recording selected identifier-window anchor {}, but current state uses {}", + self.selected_anchor, self.current_anchor + ) + } +} + +impl std::error::Error for RecordingObservationBindingError {} + +/// A recording operation earlier than its stored identifier-window anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::telemetry) struct RecordingObservationError { + observed_day: UtcDay, + window_anchor: UtcDay, +} + +impl fmt::Display for RecordingObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "observed day {} precedes the identifier-window anchor {}", + self.observed_day, self.window_anchor + ) + } +} + +impl std::error::Error for RecordingObservationError {} + /// Identity and return-cohort selections for one observed session. #[must_use = "session identity state must be persisted before identifiers are emitted"] #[derive(Debug, PartialEq, Eq)] pub(in crate::telemetry) struct SessionObservation { - completed_at: UtcSecond, - pub(super) identifier_window: IdentifierWindowUpdate, + recording: RecordingObservation, pub(super) return_cohort: ReturnCohortUpdate, } /// A persisted session transition bound to both of its identity scopes. #[must_use = "a bound session observation supplies the session-start identity fields"] pub(in crate::telemetry) struct BoundSessionObservation<'a> { - completed_at: UtcSecond, - identifier_window: IdentifierWindowUpdate, + recording: BoundRecordingObservation<'a>, return_cohort: ReturnCohortUpdate, - identifier_window_scope: IdentifierWindowScope<'a>, return_cohort_scope: ReturnCohortScope<'a>, } @@ -231,13 +365,13 @@ impl BoundSessionObservation<'_> { /// Return when Symposium completed the observed session-start handling. #[must_use] pub(in crate::telemetry) fn completed_at(&self) -> UtcSecond { - self.completed_at + self.recording.completed_at() } /// Return identity material bound to the selected identifier window. #[must_use] pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { - &self.identifier_window_scope + self.recording.identifier_window_scope() } /// Return identity material bound to the selected return cohort. @@ -256,10 +390,7 @@ impl BoundSessionObservation<'_> { /// A session transition that no longer matches the current private state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::telemetry) enum SessionObservationBindingError { - IdentifierWindowChanged { - selected_anchor: UtcDay, - current_anchor: UtcDay, - }, + IdentifierWindowChanged(RecordingObservationBindingError), ReturnCohortChanged { selected_anchor: UtcDay, current_anchor: Option, @@ -269,13 +400,7 @@ pub(in crate::telemetry) enum SessionObservationBindingError { impl fmt::Display for SessionObservationBindingError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::IdentifierWindowChanged { - selected_anchor, - current_anchor, - } => write!( - formatter, - "session selected identifier-window anchor {selected_anchor}, but current state uses {current_anchor}" - ), + Self::IdentifierWindowChanged(error) => fmt::Display::fmt(error, formatter), Self::ReturnCohortChanged { selected_anchor, current_anchor: Some(current_anchor), @@ -294,7 +419,20 @@ impl fmt::Display for SessionObservationBindingError { } } -impl std::error::Error for SessionObservationBindingError {} +impl std::error::Error for SessionObservationBindingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IdentifierWindowChanged(error) => Some(error), + Self::ReturnCohortChanged { .. } => None, + } + } +} + +impl From for SessionObservationBindingError { + fn from(error: RecordingObservationBindingError) -> Self { + Self::IdentifierWindowChanged(error) + } +} /// Whether selecting an identifier window changed private state. #[must_use = "an advanced identifier window must be persisted before use"] @@ -348,26 +486,23 @@ impl ReturnCohortUpdate { /// An observed session earlier than one of its stored identity anchors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::telemetry) enum SessionObservationError { - BeforeIdentifierWindow { - observed_day: UtcDay, - window_anchor: UtcDay, - }, + BeforeIdentifierWindow(RecordingObservationError), BeforeReturnCohort { observed_day: UtcDay, cohort_anchor: UtcDay, }, } +impl From for SessionObservationError { + fn from(error: RecordingObservationError) -> Self { + Self::BeforeIdentifierWindow(error) + } +} + impl fmt::Display for SessionObservationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::BeforeIdentifierWindow { - observed_day, - window_anchor, - } => write!( - formatter, - "observed day {observed_day} precedes the identifier-window anchor {window_anchor}" - ), + Self::BeforeIdentifierWindow(error) => fmt::Display::fmt(error, formatter), Self::BeforeReturnCohort { observed_day, cohort_anchor, @@ -379,7 +514,14 @@ impl fmt::Display for SessionObservationError { } } -impl std::error::Error for SessionObservationError {} +impl std::error::Error for SessionObservationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BeforeIdentifierWindow(error) => Some(error), + Self::BeforeReturnCohort { .. } => None, + } + } +} #[cfg(test)] mod tests { @@ -401,9 +543,9 @@ mod tests { #[derive(Debug, PartialEq, Eq)] struct TestKeySourceError; - struct TestSessionDimension; + struct TestWindowDimension; - impl IdentityDimension for TestSessionDimension { + impl IdentityDimension for TestWindowDimension { type Domain = SessionDomain; fn write(&self, writer: &mut DimensionWriter<'_>) { @@ -490,6 +632,110 @@ mod tests { assert!(state.identity.return_cohort_anchor.is_none()); } + #[test] + fn recording_on_day_thirty_advances_only_the_identifier_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 10, 10); + let observed_day = completed_at.day(); + + let observation = state.observe_recording(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { + anchor: observed_day + } + ); + assert_eq!( + serialized, + state_with_anchors(KEY, "2026-10-10", "2026-08-11") + ); + } + + #[test] + fn recording_without_a_return_cohort_does_not_start_one() { + let source = state_without_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 10, 10); + + let observation = state.observe_recording(completed_at).unwrap(); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + assert!(state.identity.return_cohort_anchor.is_none()); + assert_eq!( + serialized, + state_without_return_cohort_at(KEY, "2026-10-10") + ); + } + + #[test] + fn recording_observation_binds_its_timestamp_day_and_window_scope() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let old_subject = state.identifier_window_scope().derive(&TestWindowDimension); + let completed_at = completion_time(2026, 10, 10); + + let observation = state.observe_recording(completed_at).unwrap(); + let observation = state.bind_recording_observation(observation).unwrap(); + let new_subject = observation + .identifier_window_scope() + .derive(&TestWindowDimension); + + assert_ne!(new_subject, old_subject); + assert_eq!(observation.completed_at(), completed_at); + assert_eq!(observation.day(), completed_at.day()); + assert!(matches!( + observation.identifier_window, + IdentifierWindowUpdate::Advanced { .. } + )); + } + + #[test] + fn recording_before_the_window_anchor_is_rejected_without_mutation() { + let source = state_with_return_cohort(KEY); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + + let result = state.observe_recording(completion_time(2026, 9, 9)); + let serialized = toml::to_string_pretty(&state).unwrap(); + + assert_eq!( + result, + Err(RecordingObservationError { + observed_day: day(2026, 9, 9), + window_anchor: day(2026, 9, 10), + }) + ); + assert_eq!(serialized, source); + } + + #[test] + fn recording_binding_rejects_an_observation_from_an_older_window() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-08-11"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let older_observation = state + .observe_recording(completion_time(2026, 9, 11)) + .unwrap(); + let _current_observation = state + .observe_recording(completion_time(2026, 10, 25)) + .unwrap(); + + let result = state.bind_recording_observation(older_observation); + + assert_eq!( + result.err(), + Some(RecordingObservationBindingError { + selected_anchor: day(2026, 9, 10), + current_anchor: day(2026, 10, 25), + }) + ); + } + #[test] fn observations_on_days_zero_through_twenty_nine_keep_the_window() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); @@ -500,10 +746,10 @@ mod tests { let observation = state.observe_session(completed_at).unwrap(); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Current { anchor } ); - assert_eq!(observation.identifier_window.anchor(), anchor); + assert_eq!(observation.recording.identifier_window.anchor(), anchor); assert_eq!(state.identity.identifier_window_anchor, anchor); } } @@ -518,7 +764,7 @@ mod tests { let observation = state.observe_session(completed_at).unwrap(); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { anchor: observed_day } @@ -537,7 +783,7 @@ mod tests { let observation = state.observe_session(completed_at).unwrap(); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { anchor: observed_day } @@ -556,9 +802,7 @@ mod tests { fn observed_session_binds_both_scopes_to_the_selected_anchors() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); - let old_session = state - .identifier_window_scope() - .derive(&TestSessionDimension); + let old_session = state.identifier_window_scope().derive(&TestWindowDimension); let old_retention = state .return_cohort_scope() .expect("fixture has an observed-session cohort") @@ -570,7 +814,7 @@ mod tests { let observation = state.bind_session_observation(observation).unwrap(); let new_session = observation .identifier_window_scope() - .derive(&TestSessionDimension); + .derive(&TestWindowDimension); let new_retention = observation .return_cohort_scope() .derive(&RetentionDimension); @@ -580,7 +824,7 @@ mod tests { assert_eq!(observation.completed_at(), completed_at); assert_eq!(observation.cohort_day(), CohortDay::D0); assert!(matches!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { .. } )); assert!(matches!( @@ -603,10 +847,12 @@ mod tests { assert!(matches!( result, - Err(SessionObservationBindingError::IdentifierWindowChanged { - selected_anchor, - current_anchor, - }) if selected_anchor == day(2026, 9, 10) && current_anchor == current_day + Err(SessionObservationBindingError::IdentifierWindowChanged( + RecordingObservationBindingError { + selected_anchor, + current_anchor, + } + )) if selected_anchor == day(2026, 9, 10) && current_anchor == current_day )); } @@ -664,7 +910,7 @@ mod tests { let expected = state_with_anchors(KEY, "2026-10-10", "2026-09-10"); assert!(matches!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { .. } )); assert_eq!(serialized, expected); @@ -712,7 +958,7 @@ mod tests { let expected = state_with_anchors(KEY, "2026-10-25", "2026-10-25"); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { anchor: observed_day } @@ -745,7 +991,7 @@ mod tests { CohortDay::try_from(expected_day).unwrap() ); assert!(matches!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Current { .. } )); assert_eq!(state.identity.return_cohort_anchor, Some(anchor)); @@ -763,7 +1009,7 @@ mod tests { let expected = state_with_anchors(KEY, "2026-09-01", "2026-09-11"); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Current { anchor: day(2026, 9, 1) } @@ -784,7 +1030,7 @@ mod tests { let expected = state_with_anchors(KEY, "2026-09-11", "2026-09-11"); assert_eq!( - observation.identifier_window, + observation.recording.identifier_window, IdentifierWindowUpdate::Advanced { anchor: observed_day } From 6a4f83abc918e598c785c82763f651df1bd49f64 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 19:36:18 +0300 Subject: [PATCH 54/57] Add command telemetry event schema Define typed built-in and public plugin command coordinates, validate the versioned row on read, and derive command subjects from the coordinate. Build each row from a bound recording observation so its timestamp, day, and identity window cannot disagree. Pin CLI mappings and published examples with tests. --- src/telemetry/schema/command.rs | 241 ++++++++++++++++++++++++++++++-- src/telemetry/schema/mod.rs | 29 ++-- src/telemetry/state/mod.rs | 2 +- 3 files changed, 246 insertions(+), 26 deletions(-) diff --git a/src/telemetry/schema/command.rs b/src/telemetry/schema/command.rs index 9df2b4df..fa0228a4 100644 --- a/src/telemetry/schema/command.rs +++ b/src/telemetry/schema/command.rs @@ -5,12 +5,16 @@ use std::fmt; use serde::{Deserialize, Serialize}; use super::{ + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, deserialize_version_one, extension::{PublicExtensionName, PublicExtensionNameError, PublicExtensionSource}, name::{InitialByteRule, validated_string_newtype}, }; use crate::{ cli::{Commands, PluginCommand}, - telemetry::identity::{CommandDomain, DimensionWriter, IdentityDimension}, + telemetry::{ + identity::{CommandDomain, CommandSubject, DimensionWriter, IdentityDimension}, + state::BoundRecordingObservation, + }, }; const MAX_PUBLIC_COMMAND_NAME_BYTES: usize = 64; @@ -220,18 +224,133 @@ impl IdentityDimension for CommandCoordinate { } } +/// Version 1 record of one completed eligible top-level command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawCommandV1")] +pub(in crate::telemetry) struct CommandV1 { + #[serde(rename = "v")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + command_subject: CommandSubject, +} + +impl CommandV1 { + /// Create a record for one completed eligible top-level command. + #[must_use] + pub(in crate::telemetry) fn new( + observation: &BoundRecordingObservation<'_>, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + ) -> Self { + let at = observation.completed_at(); + let day = observation.day(); + let command_subject = observation.identifier_window_scope().derive(&command); + + Self { + version: SchemaVersion::V1, + kind: RowKind::Command, + event_id: EventId::new(), + day, + at, + symposium: SymposiumVersion::current(), + command, + duration_ms, + outcome, + command_subject, + } + } +} + +/// Strict wire representation validated before becoming a command 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 RawCommandV1 { + #[serde(rename = "v", deserialize_with = "deserialize_version_one")] + version: SchemaVersion, + kind: RowKind, + event_id: EventId, + day: UtcDay, + at: UtcSecond, + symposium: SymposiumVersion, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + command_subject: CommandSubject, +} + +impl TryFrom for CommandV1 { + type Error = CommandError; + + fn try_from(raw: RawCommandV1) -> Result { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(CommandError::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, + command: raw.command, + duration_ms: raw.duration_ms, + outcome: raw.outcome, + command_subject: raw.command_subject, + }) + } +} + +/// Invalid relationship between fields in a command row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandError { + DayDoesNotMatchTimestamp { stored: UtcDay, timestamp: UtcDay }, +} + +impl fmt::Display for CommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DayDoesNotMatchTimestamp { stored, timestamp } => write!( + formatter, + "stored command day {stored} does not match timestamp day {timestamp}" + ), + } + } +} + +impl std::error::Error for CommandError {} + #[cfg(test)] mod tests { + use chrono::{TimeZone, Utc}; use clap::Parser as _; use super::super::{ IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, - recorded_data_example_block_at, + recorded_data_example_block_at, recorded_data_example_row, }; use super::*; use crate::{ cli::Cli, - telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}, + telemetry::{ + identity::encode_dimension_for_test, + state::{BoundRecordingObservation, TelemetryStateV1}, + }, }; fn parse_command(arguments: &[&str]) -> Commands { @@ -251,6 +370,22 @@ mod tests { ) } + fn command_time() -> UtcSecond { + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 10, 2, 11).unwrap()) + } + + fn command_observation(state: &mut TelemetryStateV1) -> BoundRecordingObservation<'_> { + let observation = state.observe_recording(command_time()).unwrap(); + state.bind_recording_observation(observation).unwrap() + } + + fn command_row(command: CommandCoordinate) -> CommandV1 { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = command_observation(&mut state); + + CommandV1::new(&observation, command, 820, CommandOutcome::Ok) + } + #[test] fn cli_commands_map_exhaustively_to_telemetry_builtins() { let cases: &[(&[&str], Option)] = &[ @@ -447,15 +582,13 @@ mod tests { #[test] fn command_subject_derivation_matches_independent_vector() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); let command = plugin_command( PublicExtensionSource::SymposiumRecommendations, "example-tools", "example-check", ); - let subject = identity.derive(&command); + let subject = command_row(command).command_subject; // Cross-checked with .NET's HMACSHA256 over the contract header, // identifier window, command type, source, plugin name, and command @@ -469,8 +602,6 @@ mod tests { #[test] fn command_subject_changes_with_the_typed_coordinate() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); let baseline = plugin_command( PublicExtensionSource::SymposiumRecommendations, "example-tools", @@ -495,16 +626,100 @@ mod tests { ), ]; - let baseline_subject = identity.derive(&baseline); - let use_subject = identity.derive(&CommandCoordinate::builtin(BuiltinCommand::Use)); - let remove_subject = identity.derive(&CommandCoordinate::builtin(BuiltinCommand::Remove)); + let baseline_subject = command_row(baseline).command_subject; + let use_subject = + command_row(CommandCoordinate::builtin(BuiltinCommand::Use)).command_subject; + let remove_subject = + command_row(CommandCoordinate::builtin(BuiltinCommand::Remove)).command_subject; - for coordinate in &changed_coordinates { - assert_ne!(identity.derive(coordinate), baseline_subject); + for coordinate in changed_coordinates { + assert_ne!(command_row(coordinate).command_subject, baseline_subject); } assert_ne!(use_subject, remove_subject); } + #[test] + fn command_example_round_trips_in_contract_shape() { + let source = recorded_data_example_row("command"); + + let row = serde_json::from_str::(source).unwrap(); + let serialized = serde_json::to_string(&row).unwrap(); + + assert_eq!(serialized, source); + } + + #[test] + fn new_command_derives_fixed_fields_day_and_subject() { + let at = command_time(); + let command = CommandCoordinate::builtin(BuiltinCommand::Use); + // Cross-checked in the same independent .NET calculation as the + // plugin-command vector. The complete digest is + // 0b0899a55d1cbe757b8b505091f6e1f3a4646f26a9a05de1769faaea84f249ca. + let expected_subject = "cmd_0b0899a55d1cbe757b8b505091f6e1f3".parse().unwrap(); + + let row = command_row(command.clone()); + + assert_eq!(row.version, SchemaVersion::V1); + assert_eq!(row.kind, RowKind::Command); + assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); + assert_eq!(row.day, at.day()); + assert_eq!(row.at, at); + assert_eq!(row.symposium, SymposiumVersion::current()); + assert_eq!(row.command, command); + assert_eq!(row.duration_ms, 820); + assert_eq!(row.outcome, CommandOutcome::Ok); + assert_eq!(row.command_subject, expected_subject); + } + + #[test] + fn command_rejects_a_day_that_disagrees_with_its_timestamp() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["day"] = serde_json::Value::String("2026-08-04".to_owned()); + + let result = serde_json::from_value::(value); + + assert!( + result + .unwrap_err() + .to_string() + .contains("stored command day 2026-08-04 does not match timestamp day 2026-08-03") + ); + } + + #[test] + fn command_rejects_future_version() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["v"] = serde_json::Value::from(2); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + + #[test] + fn command_rejects_unknown_fields() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["arguments"] = serde_json::Value::String("example-tools".to_owned()); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + + #[test] + fn command_requires_every_field() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value.as_object_mut().unwrap().remove("duration_ms"); + + let result = serde_json::from_value::(value); + + assert!(result.is_err()); + } + #[test] fn plugin_command_coordinate_validates_both_names() { let invalid_plugin = PublicPluginCommandCoordinate::try_new( diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 58cacb9c..33a61c36 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -463,6 +463,22 @@ fn recorded_data_example_block_at( example_block } +#[cfg(test)] +fn recorded_data_example_row(requested_kind: &str) -> &'static str { + let example_block = + recorded_data_example_block("## Example JSONL for every row kind", "```jsonl"); + + example_block + .lines() + .filter_map(|line| { + serde_json::from_str::(line) + .ok() + .map(|envelope| (line, envelope)) + }) + .find_map(|(line, envelope)| (envelope.kind == requested_kind).then_some(line)) + .unwrap_or_else(|| panic!("missing {requested_kind} example in recorded-data contract")) +} + #[cfg(test)] fn assert_contract_names(cases: &[(T, &str)]) where @@ -494,18 +510,7 @@ mod tests { use super::*; fn example_row(requested_kind: &str) -> &'static str { - let example_block = - recorded_data_example_block("## Example JSONL for every row kind", "```jsonl"); - - example_block - .lines() - .filter_map(|line| { - serde_json::from_str::(line) - .ok() - .map(|envelope| (line, envelope)) - }) - .find_map(|(line, envelope)| (envelope.kind == requested_kind).then_some(line)) - .unwrap_or_else(|| panic!("missing {requested_kind} example in recorded-data contract")) + recorded_data_example_row(requested_kind) } #[test] diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index 7b62a46e..3bc4e574 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -16,7 +16,7 @@ use super::{ mod lifecycle; -pub(in crate::telemetry) use lifecycle::BoundSessionObservation; +pub(in crate::telemetry) use lifecycle::{BoundRecordingObservation, BoundSessionObservation}; /// The initial schema version of `telemetry-state.toml`. /// From 5f19dd2fe255a3fc7a7fef311858ea5872185603 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 19:49:42 +0300 Subject: [PATCH 55/57] Bind agent configuration to recording state Build daily agent configuration rows from the recording context that selected their identity window. Session-start operations can reuse the same context for their configuration snapshot. Keep raw session identity construction private and exercise identity derivation through complete rows in the tests. --- src/telemetry/schema/agent.rs | 85 ++++++++++++++++++-------------- src/telemetry/state/lifecycle.rs | 6 +++ 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 6551d17a..e628c1a2 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -15,7 +15,7 @@ use crate::{ AgentDomain, AgentSubject, DimensionWriter, IdentifierWindowScope, IdentityDimension, RetentionDimension, RetentionSubject, SessionDomain, SessionId, }, - state::BoundSessionObservation, + state::{BoundRecordingObservation, BoundSessionObservation}, }, }; @@ -174,7 +174,7 @@ pub(in crate::telemetry) struct AgentSessionIdentity { impl AgentSessionIdentity { /// Derive the identifier for one agent session in an identifier window. #[must_use] - pub(in crate::telemetry) fn new( + fn new( identity: &IdentifierWindowScope<'_>, agent: HookAgent, vendor_session_id: Option<&VendorSessionId>, @@ -433,19 +433,18 @@ impl AgentConfigurationV1 { /// Create one agent entry in a daily configuration snapshot. #[must_use] pub(in crate::telemetry) fn new( - identity: &IdentifierWindowScope<'_>, - day: UtcDay, + observation: &BoundRecordingObservation<'_>, os: OperatingSystem, arch: Architecture, fields: AgentConfigurationFields, ) -> Self { - let agent_subject = identity.derive(&fields.agent); + let agent_subject = observation.identifier_window_scope().derive(&fields.agent); Self { version: SchemaVersion::V1, kind: RowKind::AgentConfiguration, event_id: EventId::new(), - day, + day: observation.day(), symposium: SymposiumVersion::current(), agent: fields.agent, configured: fields.configured, @@ -458,7 +457,7 @@ impl AgentConfigurationV1 { #[cfg(test)] mod tests { - use chrono::{NaiveDate, TimeZone, Utc}; + use chrono::{TimeZone, Utc}; use super::super::{ IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, @@ -467,9 +466,12 @@ mod tests { use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; - fn session_start_fields(vendor_session_id: Option<&VendorSessionId>) -> SessionStartFields<'_> { + fn session_start_fields( + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartFields<'_> { SessionStartFields { - agent: HookAgent::Claude, + agent, os: OperatingSystem::Linux, arch: Architecture::X86_64, start: SessionStartKind::Fresh, @@ -484,22 +486,29 @@ mod tests { fn session_start( completed_at: UtcSecond, vendor_session_id: Option<&VendorSessionId>, + ) -> SessionStartV1 { + session_start_for_agent(completed_at, HookAgent::Claude, vendor_session_id) + } + + fn session_start_for_agent( + completed_at: UtcSecond, + agent: HookAgent, + vendor_session_id: Option<&VendorSessionId>, ) -> SessionStartV1 { let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); let observation = state.observe_session(completed_at).unwrap(); let observation = state.bind_session_observation(observation).unwrap(); - SessionStartV1::new(session_start_fields(vendor_session_id), &observation) + SessionStartV1::new(session_start_fields(agent, vendor_session_id), &observation) } fn agent_configuration(agent: SupportedAgent) -> AgentConfigurationV1 { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); - let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = state.observe_session(session_start_time()).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); AgentConfigurationV1::new( - &identity, - day, + observation.recording(), OperatingSystem::Linux, Architecture::X86_64, AgentConfigurationFields { @@ -541,49 +550,51 @@ mod tests { #[test] fn session_subject_derivation_matches_independent_vector() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); let vendor_session_id = VendorSessionId::new("vendor-session-123".to_owned()); - let session = - AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&vendor_session_id)); + let row = session_start(session_start_time(), Some(&vendor_session_id)); // Cross-checked with .NET's HMACSHA256 over the contract header, // identifier window, agent, and vendor session id. The complete // digest is // 2f77ea40740f4be8e85ba05e7924e1ad054037d26629db2ef7dc7097dddf723a. assert_eq!( - session.session_id(), + row.session_id, Some("sess_2f77ea40740f4be8e85ba05e7924e1ad".parse().unwrap()) ); } #[test] fn session_subject_changes_with_the_agent_or_vendor_session_id() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); let first_vendor_id = VendorSessionId::new("vendor-session-123".to_owned()); let second_vendor_id = VendorSessionId::new("vendor-session-456".to_owned()); - let first = AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&first_vendor_id)); - let other_agent = - AgentSessionIdentity::new(&identity, HookAgent::Codex, Some(&first_vendor_id)); - let other_vendor_id = - AgentSessionIdentity::new(&identity, HookAgent::Claude, Some(&second_vendor_id)); + let first = session_start_for_agent( + session_start_time(), + HookAgent::Claude, + Some(&first_vendor_id), + ); + let other_agent = session_start_for_agent( + session_start_time(), + HookAgent::Codex, + Some(&first_vendor_id), + ); + let other_vendor_id = session_start_for_agent( + session_start_time(), + HookAgent::Claude, + Some(&second_vendor_id), + ); - assert_ne!(first.session_id(), other_agent.session_id()); - assert_ne!(first.session_id(), other_vendor_id.session_id()); + assert_ne!(first.session_id, other_agent.session_id); + assert_ne!(first.session_id, other_vendor_id.session_id); } #[test] fn agent_session_without_vendor_id_is_unidentified() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); - - let session = AgentSessionIdentity::new(&identity, HookAgent::Copilot, None); + let row = session_start_for_agent(session_start_time(), HookAgent::Copilot, None); - assert_eq!(session.agent(), HookAgent::Copilot); - assert_eq!(session.session_id(), None); + assert_eq!(row.agent, HookAgent::Copilot); + assert_eq!(row.session_id, None); } #[test] @@ -768,7 +779,7 @@ mod tests { let observation = state.observe_session(completed_at).unwrap(); let observation = state.bind_session_observation(observation).unwrap(); - let row = SessionStartV1::new(session_start_fields(None), &observation); + let row = SessionStartV1::new(session_start_fields(HookAgent::Claude, None), &observation); let stored_state = toml::to_string(&state).unwrap(); let stored_state = toml::from_str::(&stored_state).unwrap(); let cohort_anchor = stored_state["identity"]["return-cohort-anchor"] @@ -783,7 +794,7 @@ mod tests { #[test] fn new_agent_configuration_derives_subject_from_its_agent() { - let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let day = session_start_time().day(); // Cross-checked with .NET's HMACSHA256 over the contract header, // identifier window, and agent. The complete digest is // e346647f3c83e0f8bea71a0ff04bfb6fa601f0967a92713894dcea7f793214b0. diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs index d0dc8a70..221aeddc 100644 --- a/src/telemetry/state/lifecycle.rs +++ b/src/telemetry/state/lifecycle.rs @@ -368,6 +368,12 @@ impl BoundSessionObservation<'_> { self.recording.completed_at() } + /// Return the recording context shared by every row from this session-start + /// operation. + pub(in crate::telemetry) fn recording(&self) -> &BoundRecordingObservation<'_> { + &self.recording + } + /// Return identity material bound to the selected identifier window. #[must_use] pub(in crate::telemetry) fn identifier_window_scope(&self) -> &IdentifierWindowScope<'_> { From 4e1d700a468d840194465d9d14b712707f32d270 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 20:30:44 +0300 Subject: [PATCH 56/57] Bind telemetry rows to recording state Build resolution and configuration rows from one recording context so their day and scoped identifiers use the selected identity window. Keep raw identity scopes private, share the test recording fixture, and route command rows through versioned classification. --- md/design/module-structure.md | 2 +- .../contract/recorded-data.md | 4 ++- src/telemetry/schema/agent.rs | 17 +++++----- src/telemetry/schema/command.rs | 34 +++++++------------ src/telemetry/schema/mod.rs | 18 ++++++++++ src/telemetry/schema/resolution.rs | 34 +++++++++++++------ src/telemetry/schema/resolution/extension.rs | 33 ++++++++---------- src/telemetry/schema/resolution/package.rs | 18 +++++----- src/telemetry/state/lifecycle.rs | 22 ++++++++++++ src/telemetry/state/mod.rs | 4 +-- 10 files changed, 114 insertions(+), 72 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index b2df6d59..3242d81b 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Subject-bearing row constructors derive identifiers from their own source fields: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, and session start derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns identifier-window, return-cohort, and identifier-reset transitions. A completed session transition is a single-use value; binding it against unchanged anchors exposes both identity scopes together. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. +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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, command derives `command_subject` from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/md/rfds/telemetry-recording/contract/recorded-data.md b/md/rfds/telemetry-recording/contract/recorded-data.md index 40bbad3b..e9ea7e88 100644 --- a/md/rfds/telemetry-recording/contract/recorded-data.md +++ b/md/rfds/telemetry-recording/contract/recorded-data.md @@ -433,7 +433,9 @@ 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 uses the UTC day from the completion timestamp and rejects it when it precedes 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 from the same timestamp and transition. 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. +Under that lock, a recording operation captures its completion timestamp once, uses its UTC day, and rejects it when it precedes the latest-opened-day high-water mark. It calculates the identifier-window transition, applies it with any high-water advancement to one in-memory state, atomically replaces private state once, and only then binds the timestamp, day, and identifier window for row construction. The replacement also occurs when the selected window remains current, so every operation has the same persist-before-bind boundary. Every row in one operation reuses that bound recording context. In particular, a full resolution uses one context for its summary, package, and extension rows. + +Session recording also calculates the return-cohort transition before mutating either anchor. The identifier-window transition, return-cohort transition, and high-water advancement are persisted together. The `session_start` row is then derived from the same bound session context. If its append fails after a new cohort is stored, later rows for the cohort remain ineligible for Q1 unless a D0 row was stored. This failure mode undercounts returns rather than creating unstable identity. Private state keeps the latest opened UTC day as a high-water mark. Observing a later day permanently closes earlier daily files. An observation dated before the high-water mark is dropped rather than modifying a closed day. Raw inspection still preserves every stored line. Typed reading of a closed day returns only recognized rows that pass their versioned schema and file/day invariants, and reports malformed, invalid, and unknown-version lines separately. It rejects an oversized or incompletely read day as a whole rather than returning a partial validated result. diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index e628c1a2..9a831d2f 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -166,7 +166,7 @@ impl IdentityDimension for SessionDimension<'_> { /// that do not supply a vendor session identifier remain explicitly /// unidentified. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::telemetry) struct AgentSessionIdentity { +struct AgentSessionIdentity { agent: HookAgent, session_id: Option, } @@ -188,12 +188,13 @@ impl AgentSessionIdentity { /// Return the agent whose session this identity describes. #[must_use] - pub(in crate::telemetry) const fn agent(self) -> HookAgent { + const fn agent(self) -> HookAgent { self.agent } /// Return the scoped identifier when the agent supplied a vendor id. - pub(in crate::telemetry) const fn session_id(self) -> Option { + #[must_use] + const fn session_id(self) -> Option { self.session_id } } @@ -461,7 +462,7 @@ mod tests { use super::super::{ IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, - assert_contract_names_with_labels, classify_row, + assert_contract_names_with_labels, classify_row, recording_observation, }; use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; @@ -504,11 +505,10 @@ mod tests { fn agent_configuration(agent: SupportedAgent) -> AgentConfigurationV1 { let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let observation = state.observe_session(session_start_time()).unwrap(); - let observation = state.bind_session_observation(observation).unwrap(); + let observation = recording_observation(&mut state); AgentConfigurationV1::new( - observation.recording(), + &observation, OperatingSystem::Linux, Architecture::X86_64, AgentConfigurationFields { @@ -794,7 +794,6 @@ mod tests { #[test] fn new_agent_configuration_derives_subject_from_its_agent() { - let day = session_start_time().day(); // Cross-checked with .NET's HMACSHA256 over the contract header, // identifier window, and agent. The complete digest is // e346647f3c83e0f8bea71a0ff04bfb6fa601f0967a92713894dcea7f793214b0. @@ -805,7 +804,7 @@ mod tests { 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.day.to_string(), "2026-08-03"); assert_eq!(row.symposium, SymposiumVersion::current()); assert_eq!(row.agent, SupportedAgent::Claude); assert!(row.configured); diff --git a/src/telemetry/schema/command.rs b/src/telemetry/schema/command.rs index fa0228a4..1527bed6 100644 --- a/src/telemetry/schema/command.rs +++ b/src/telemetry/schema/command.rs @@ -337,20 +337,17 @@ impl std::error::Error for CommandError {} #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; use clap::Parser as _; use super::super::{ - IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, - recorded_data_example_block_at, recorded_data_example_row, + IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names, + assert_contract_names_with_labels, classify_row, recorded_data_example_block_at, + recorded_data_example_row, recording_observation, }; use super::*; use crate::{ cli::Cli, - telemetry::{ - identity::encode_dimension_for_test, - state::{BoundRecordingObservation, TelemetryStateV1}, - }, + telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}, }; fn parse_command(arguments: &[&str]) -> Commands { @@ -370,18 +367,9 @@ mod tests { ) } - fn command_time() -> UtcSecond { - UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 10, 2, 11).unwrap()) - } - - fn command_observation(state: &mut TelemetryStateV1) -> BoundRecordingObservation<'_> { - let observation = state.observe_recording(command_time()).unwrap(); - state.bind_recording_observation(observation).unwrap() - } - fn command_row(command: CommandCoordinate) -> CommandV1 { let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let observation = command_observation(&mut state); + let observation = recording_observation(&mut state); CommandV1::new(&observation, command, 820, CommandOutcome::Ok) } @@ -642,7 +630,9 @@ mod tests { fn command_example_round_trips_in_contract_shape() { let source = recorded_data_example_row("command"); - let row = serde_json::from_str::(source).unwrap(); + let RowClassification::Supported(TelemetryRow::Command(row)) = classify_row(source) else { + panic!("documented command row was not classified as supported"); + }; let serialized = serde_json::to_string(&row).unwrap(); assert_eq!(serialized, source); @@ -650,7 +640,6 @@ mod tests { #[test] fn new_command_derives_fixed_fields_day_and_subject() { - let at = command_time(); let command = CommandCoordinate::builtin(BuiltinCommand::Use); // Cross-checked in the same independent .NET calculation as the // plugin-command vector. The complete digest is @@ -662,8 +651,11 @@ mod tests { assert_eq!(row.version, SchemaVersion::V1); assert_eq!(row.kind, RowKind::Command); assert_eq!(row.event_id.0.get_version(), Some(uuid::Version::Random)); - assert_eq!(row.day, at.day()); - assert_eq!(row.at, at); + assert_eq!(row.day, row.at.day()); + assert_eq!( + serde_json::to_string(&row.at).unwrap(), + r#""2026-08-03T10:02:11Z""# + ); assert_eq!(row.symposium, SymposiumVersion::current()); assert_eq!(row.command, command); assert_eq!(row.duration_ms, 820); diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 33a61c36..880da4e4 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -21,6 +21,7 @@ use serde::{ use uuid::Uuid; use agent::{AgentConfigurationV1, SessionStartV1}; +use command::CommandV1; use resolution::{ ResolutionSummaryV1, extension::ExtensionResolutionV1, package::PackageResolutionV1, }; @@ -80,6 +81,7 @@ pub(super) enum TelemetryRow { ResolutionSummary(ResolutionSummaryV1), PackageResolution(PackageResolutionV1), ExtensionResolution(ExtensionResolutionV1), + Command(CommandV1), StorageLimit(StorageLimitV1), } @@ -94,6 +96,7 @@ impl Serialize for TelemetryRow { Self::ResolutionSummary(row) => row.serialize(serializer), Self::PackageResolution(row) => row.serialize(serializer), Self::ExtensionResolution(row) => row.serialize(serializer), + Self::Command(row) => row.serialize(serializer), Self::StorageLimit(row) => row.serialize(serializer), } } @@ -408,6 +411,7 @@ pub(super) fn classify_row(line: &str) -> RowClassification { ("extension_resolution", 1) => { deserialize_supported_row(line, TelemetryRow::ExtensionResolution) } + ("command", 1) => deserialize_supported_row(line, TelemetryRow::Command), ("storage_limit", 1) => deserialize_supported_row(line, TelemetryRow::StorageLimit), _ => RowClassification::UnknownSchema, } @@ -435,6 +439,20 @@ key = "4242424242424242424242424242424242424242424242424242424242424242" identifier-window-anchor = "2026-08-03" "#; +/// Build a recording context that remains inside the fixture's identifier +/// window, so schema tests do not depend on separate timestamp choices. +#[cfg(test)] +fn recording_observation( + state: &mut crate::telemetry::state::TelemetryStateV1, +) -> crate::telemetry::state::BoundRecordingObservation<'_> { + use chrono::TimeZone as _; + + let completed_at = + UtcSecond::from_datetime(Utc.with_ymd_and_hms(2026, 8, 3, 10, 2, 11).unwrap()); + let observation = state.observe_recording(completed_at).unwrap(); + state.bind_recording_observation(observation).unwrap() +} + #[cfg(test)] fn recorded_data_example_block(section_heading: &str, opening_fence: &str) -> &'static str { recorded_data_example_block_at(section_heading, opening_fence, 0) diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index 4760baf9..b6895cd3 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -11,7 +11,7 @@ use super::{ DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, deserialize_version_one, }; -use crate::telemetry::identity::SessionId; +use crate::telemetry::{identity::SessionId, state::BoundRecordingObservation}; /// Operation that caused a full resolution and sync. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -164,7 +164,7 @@ impl ResolutionSummaryV1 { /// Returns [`ResolutionSummaryError::UnnamedPackageCountOverflow`] when /// the unnamed-package reason counters cannot be represented by `u64`. pub(in crate::telemetry) fn new( - day: UtcDay, + observation: &BoundRecordingObservation<'_>, fields: ResolutionSummaryFields, ) -> Result { let unnamed_packages = fields @@ -176,7 +176,7 @@ impl ResolutionSummaryV1 { version: SchemaVersion::V1, kind: RowKind::ResolutionSummary, event_id: EventId::new(), - day, + day: observation.day(), symposium: SymposiumVersion::current(), trigger: fields.trigger, outcome: fields.outcome, @@ -286,8 +286,11 @@ impl TryFrom for ResolutionSummaryV1 { mod tests { use chrono::NaiveDate; - use super::super::assert_contract_names; + use super::super::{ + IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, recording_observation, + }; use super::*; + use crate::telemetry::state::TelemetryStateV1; fn example_reasons() -> UnnamedPackageReasons { UnnamedPackageReasons { @@ -304,6 +307,15 @@ mod tests { UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()) } + fn resolution_summary( + fields: ResolutionSummaryFields, + ) -> Result { + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + + ResolutionSummaryV1::new(&observation, fields) + } + fn summary_fields(session_id: Option) -> ResolutionSummaryFields { ResolutionSummaryFields { trigger: ResolutionTrigger::SessionStart, @@ -353,7 +365,7 @@ mod tests { let session_id = "sess_31d8b1916028f65a0c0521dc1f4c86fb".parse().unwrap(); let fields = summary_fields(Some(session_id)); - let row = ResolutionSummaryV1::new(summary_day(), fields).unwrap(); + let row = resolution_summary(fields).unwrap(); assert_eq!(row.version, SchemaVersion::V1); assert_eq!(row.kind, RowKind::ResolutionSummary); @@ -383,7 +395,7 @@ mod tests { ..UnnamedPackageReasons::default() }; - let result = ResolutionSummaryV1::new(summary_day(), fields); + let result = resolution_summary(fields); assert_eq!( result, @@ -393,7 +405,7 @@ mod tests { #[test] fn direct_resolution_summary_deserialization_rejects_future_version() { - let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let row = resolution_summary(summary_fields(None)).unwrap(); let json = serde_json::to_string(&row).unwrap(); let future = json.replacen(r#""v":1"#, r#""v":2"#, 1); @@ -404,7 +416,7 @@ mod tests { #[test] fn resolution_summary_rejects_unknown_fields() { - let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let row = resolution_summary(summary_fields(None)).unwrap(); let json = serde_json::to_string(&row).unwrap(); let unknown = json.replacen(r#""trigger""#, r#""future_field":true,"trigger""#, 1); @@ -415,7 +427,7 @@ mod tests { #[test] fn resolution_summary_requires_every_top_level_field() { - let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let row = resolution_summary(summary_fields(None)).unwrap(); let json = serde_json::to_string(&row).unwrap(); let missing = json.replacen(r#","plugins":1"#, "", 1); @@ -426,7 +438,7 @@ mod tests { #[test] fn resolution_summary_json_rejects_mismatched_unnamed_count() { - let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let row = resolution_summary(summary_fields(None)).unwrap(); let json = serde_json::to_string(&row).unwrap(); let mismatched = json.replacen(r#""unnamed_packages":1"#, r#""unnamed_packages":2"#, 1); @@ -473,7 +485,7 @@ mod tests { #[test] fn resolution_summary_without_session_id_round_trips_without_the_field() { - let row = ResolutionSummaryV1::new(summary_day(), summary_fields(None)).unwrap(); + let row = resolution_summary(summary_fields(None)).unwrap(); let json = serde_json::to_string(&row).unwrap(); let value = serde_json::from_str::(&json).unwrap(); diff --git a/src/telemetry/schema/resolution/extension.rs b/src/telemetry/schema/resolution/extension.rs index 01e5f439..03f57da4 100644 --- a/src/telemetry/schema/resolution/extension.rs +++ b/src/telemetry/schema/resolution/extension.rs @@ -16,6 +16,7 @@ use super::{ use crate::telemetry::identity::{ DimensionWriter, ExtensionDomain, ExtensionSubject, IdentifierWindowScope, IdentityDimension, }; +use crate::telemetry::state::BoundRecordingObservation; /// Maximum root-to-leaf depth of a recorded resolution path. const MAX_RESOLUTION_PATH_DEPTH: usize = 8; @@ -161,18 +162,17 @@ impl ExtensionResolutionV1 { /// Create a record for one public extension and its safe resolution path. #[must_use] pub(in crate::telemetry) fn new( - identity: &IdentifierWindowScope<'_>, - day: UtcDay, + observation: &BoundRecordingObservation<'_>, target: PublicExtensionCoordinate, path: ResolutionPath, ) -> Self { - let extension_subject = path.derive_subject(identity, &target); + let extension_subject = path.derive_subject(observation.identifier_window_scope(), &target); Self { version: SchemaVersion::V1, kind: RowKind::ExtensionResolution, event_id: EventId::new(), - day, + day: observation.day(), symposium: SymposiumVersion::current(), target, path, @@ -351,11 +351,10 @@ impl OpaqueResolutionReason { #[cfg(test)] mod tests { - use chrono::NaiveDate; - use super::super::super::{ IDENTIFIER_WINDOW_TEST_STATE, RowClassification, TelemetryRow, assert_contract_names_with_labels, classify_row, recorded_data_example_block, + recording_observation, }; use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; @@ -557,8 +556,8 @@ mod tests { #[test] fn extension_subject_derivation_matches_independent_vector() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); let target = public_target(); let path = resolution_path_with_every_node_variant(); // Cross-checked with .NET's HMACSHA256 over the contract header, @@ -567,21 +566,21 @@ mod tests { // 63872efd4737ec84179b4e8b0662c1212e9e3295e1940387c4a4e2cca0a9090e. let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); - let subject = path.derive_subject(&identity, &target); + let subject = path.derive_subject(observation.identifier_window_scope(), &target); assert_eq!(subject, expected_subject); } #[test] fn new_extension_resolution_derives_subject_from_its_target_and_path() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); - let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); + let day = observation.day(); let target = public_target(); let path = resolution_path_with_every_node_variant(); let expected_subject = "ext_63872efd4737ec84179b4e8b0662c121".parse().unwrap(); - let row = ExtensionResolutionV1::new(&identity, day, target, path); + let row = ExtensionResolutionV1::new(&observation, target, path); assert_eq!(row.version, SchemaVersion::V1); assert_eq!(row.kind, RowKind::ExtensionResolution); @@ -595,12 +594,10 @@ mod tests { #[test] fn nested_extension_resolution_round_trips_through_the_classifier() { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); - let day = UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); let row = ExtensionResolutionV1::new( - &identity, - day, + &observation, public_target(), resolution_path_with_every_node_variant(), ); diff --git a/src/telemetry/schema/resolution/package.rs b/src/telemetry/schema/resolution/package.rs index bb49e1bf..409dac15 100644 --- a/src/telemetry/schema/resolution/package.rs +++ b/src/telemetry/schema/resolution/package.rs @@ -10,8 +10,9 @@ use super::super::{ name::{InitialByteRule, validated_string_newtype}, }; use crate::telemetry::identity::{ - DimensionWriter, IdentifierWindowScope, IdentityDimension, PackageDomain, PackageSubject, + DimensionWriter, IdentityDimension, PackageDomain, PackageSubject, }; +use crate::telemetry::state::BoundRecordingObservation; const MAX_PUBLIC_PACKAGE_NAME_BYTES: usize = 64; @@ -260,18 +261,17 @@ impl PackageResolutionV1 { /// Create a record for one eligible public resolution-input package. #[must_use] pub(in crate::telemetry) fn new( - identity: &IdentifierWindowScope<'_>, - day: UtcDay, + observation: &BoundRecordingObservation<'_>, package: PublicPackageCoordinate, extension_match: ExtensionMatch, ) -> Self { - let package_subject = identity.derive(&package); + let package_subject = observation.identifier_window_scope().derive(&package); Self { version: SchemaVersion::V1, kind: RowKind::PackageResolution, event_id: EventId::new(), - day, + day: observation.day(), symposium: SymposiumVersion::current(), package, extension_match, @@ -286,6 +286,7 @@ mod tests { use super::super::super::{ IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, assert_contract_names_with_labels, + recording_observation, }; use super::*; use crate::telemetry::{identity::encode_dimension_for_test, state::TelemetryStateV1}; @@ -303,12 +304,11 @@ mod tests { } fn package_resolution_for(package_name: &str) -> PackageResolutionV1 { - let state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); - let identity = state.identifier_window_scope(); + let mut state: TelemetryStateV1 = toml::from_str(IDENTIFIER_WINDOW_TEST_STATE).unwrap(); + let observation = recording_observation(&mut state); PackageResolutionV1::new( - &identity, - UtcDay::from_date(NaiveDate::from_ymd_opt(2026, 8, 3).unwrap()), + &observation, PublicPackageCoordinate::try_new(PackageEcosystem::Cargo, package_name, "1.2.3") .unwrap(), ExtensionMatch::Public, diff --git a/src/telemetry/state/lifecycle.rs b/src/telemetry/state/lifecycle.rs index 221aeddc..3c464914 100644 --- a/src/telemetry/state/lifecycle.rs +++ b/src/telemetry/state/lifecycle.rs @@ -839,6 +839,28 @@ mod tests { )); } + #[test] + fn bound_session_exposes_its_recording_context() { + let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); + let mut state: TelemetryStateV1 = toml::from_str(&source).unwrap(); + let completed_at = completion_time(2026, 9, 11); + let observation = state.observe_session(completed_at).unwrap(); + let observation = state.bind_session_observation(observation).unwrap(); + + let recording = observation.recording(); + + assert_eq!(recording.completed_at(), completed_at); + assert_eq!(recording.day(), completed_at.day()); + assert_eq!( + recording + .identifier_window_scope() + .derive(&TestWindowDimension), + observation + .identifier_window_scope() + .derive(&TestWindowDimension) + ); + } + #[test] fn binding_rejects_an_observation_from_an_older_identifier_window() { let source = state_with_anchors(KEY, "2026-09-10", "2026-09-10"); diff --git a/src/telemetry/state/mod.rs b/src/telemetry/state/mod.rs index 3bc4e574..cce6d0d0 100644 --- a/src/telemetry/state/mod.rs +++ b/src/telemetry/state/mod.rs @@ -96,7 +96,7 @@ impl TelemetryStateV1 { /// operation, so every derived identifier uses the state that will be /// persisted before its row is appended. #[must_use] - pub(super) fn identifier_window_scope(&self) -> IdentifierWindowScope<'_> { + fn identifier_window_scope(&self) -> IdentifierWindowScope<'_> { IdentifierWindowScope::new( &self.identity.key, self.identity.identifier_window_anchor.to_string(), @@ -109,7 +109,7 @@ impl TelemetryStateV1 { /// session observation. Call this after applying that observation so a D31 /// rollover uses the newly selected anchor. #[must_use] - pub(super) fn return_cohort_scope(&self) -> Option> { + fn return_cohort_scope(&self) -> Option> { let anchor = self.identity.return_cohort_anchor?; Some(ReturnCohortScope::new( &self.identity.key, From d7d80307519de14ff8d5e6e7871183ef12998767 Mon Sep 17 00:00:00 2001 From: Awesome Rustacean Date: Sun, 13 Sep 2026 21:26:34 +0300 Subject: [PATCH 57/57] Refactor strict telemetry row deserialization Session start, resolution summary, and command rows repeated the same raw schema and field-by-field conversion. Move that mechanical work into a private macro while keeping each validation function visible. Keep each row kind in one generated constant and apply field attributes to both serialized and raw forms. This preserves strict version, field, and kind checks without allowing the forms to drift. --- md/design/module-structure.md | 2 +- src/telemetry/schema/agent.rs | 101 +++++++--------------- src/telemetry/schema/command.rs | 104 ++++++++++------------- src/telemetry/schema/macros.rs | 105 +++++++++++++++++++++++ src/telemetry/schema/mod.rs | 1 + src/telemetry/schema/resolution.rs | 129 +++++++++-------------------- 6 files changed, 220 insertions(+), 222 deletions(-) create mode 100644 src/telemetry/schema/macros.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 3242d81b..072d684e 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, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, command derives `command_subject` from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. +The replacement recording contract is being built behind private `schema`, `identity`, and `state` submodules before it is connected to production callers. Within `schema`, `mod.rs` owns shared row primitives and version dispatch, `macros.rs` owns strict versioned-row boilerplate while each row module keeps its validation rules, `name.rs` owns validation shared by public name types, `agent.rs` owns agent vocabulary and agent-originated rows, `command.rs` owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, `extension.rs` owns validated public extension coordinates and vocabulary shared across event kinds, and `resolution.rs` owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in `resolution/package.rs`; `resolution/extension.rs` owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, and the versioned row that derives `extension_subject` from its target and path. Timestamped rows validate during deserialization that `day` is the UTC calendar day containing `at`; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives `package_subject` from its coordinate, agent configuration derives `agent_subject` from its agent, command derives `command_subject` from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and `cohort_day` from one bound session transition. Within `state`, `mod.rs` owns the versioned private-state file shape, while `state/lifecycle.rs` owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. The persistence layer will require a successful-write token for that binding so the contract's persist-before-derive order is structural rather than only documented. Their tests remain beside the responsibility they exercise. ### `report.rs` — structured report layer diff --git a/src/telemetry/schema/agent.rs b/src/telemetry/schema/agent.rs index 9a831d2f..5be29b18 100644 --- a/src/telemetry/schema/agent.rs +++ b/src/telemetry/schema/agent.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use super::{ CohortDay, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, - deserialize_version_one, + deserialize_version_one, macros::strict_versioned_row, }; use crate::{ agents::Agent, @@ -272,25 +272,25 @@ pub(in crate::telemetry) struct SessionStartFields<'a> { pub(in crate::telemetry) vendor_session_id: Option<&'a VendorSessionId>, } -/// Version 1 record of a completed registered session-start hook. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "RawSessionStartV1")] -pub(in crate::telemetry) struct SessionStartV1 { - #[serde(rename = "v")] - version: SchemaVersion, - kind: RowKind, - event_id: EventId, - day: UtcDay, - at: UtcSecond, - symposium: SymposiumVersion, - agent: HookAgent, - os: OperatingSystem, - arch: Architecture, - start: SessionStartKind, - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - retention_subject: RetentionSubject, - cohort_day: CohortDay, +strict_versioned_row! { + /// Version 1 record of a completed registered session-start hook. + pub(in crate::telemetry) struct SessionStartV1 { + at: UtcSecond, + symposium: SymposiumVersion, + agent: HookAgent, + os: OperatingSystem, + arch: Architecture, + start: SessionStartKind, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + retention_subject: RetentionSubject, + cohort_day: CohortDay, + } + + kind: RowKind::SessionStart, + raw: RawSessionStartV1, + error: SessionStartError, + validate: validate_session_start, } impl SessionStartV1 { @@ -315,7 +315,7 @@ impl SessionStartV1 { Self { version: SchemaVersion::V1, - kind: RowKind::SessionStart, + kind: Self::KIND, event_id: EventId::new(), day: at.day(), at, @@ -350,57 +350,16 @@ impl fmt::Display for SessionStartError { 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, - }) +fn validate_session_start(raw: &RawSessionStartV1) -> Result<(), SessionStartError> { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(SessionStartError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); } + + Ok(()) } /// Fields that vary for each entry in a daily agent configuration snapshot. diff --git a/src/telemetry/schema/command.rs b/src/telemetry/schema/command.rs index 1527bed6..661ead97 100644 --- a/src/telemetry/schema/command.rs +++ b/src/telemetry/schema/command.rs @@ -5,8 +5,9 @@ use std::fmt; use serde::{Deserialize, Serialize}; use super::{ - EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, deserialize_version_one, + EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, UtcSecond, extension::{PublicExtensionName, PublicExtensionNameError, PublicExtensionSource}, + macros::strict_versioned_row, name::{InitialByteRule, validated_string_newtype}, }; use crate::{ @@ -224,21 +225,21 @@ impl IdentityDimension for CommandCoordinate { } } -/// Version 1 record of one completed eligible top-level command. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "RawCommandV1")] -pub(in crate::telemetry) struct CommandV1 { - #[serde(rename = "v")] - version: SchemaVersion, - kind: RowKind, - event_id: EventId, - day: UtcDay, - at: UtcSecond, - symposium: SymposiumVersion, - command: CommandCoordinate, - duration_ms: u64, - outcome: CommandOutcome, - command_subject: CommandSubject, +strict_versioned_row! { + /// Version 1 record of one completed eligible top-level command. + pub(in crate::telemetry) struct CommandV1 { + at: UtcSecond, + symposium: SymposiumVersion, + command: CommandCoordinate, + duration_ms: u64, + outcome: CommandOutcome, + command_subject: CommandSubject, + } + + kind: RowKind::Command, + raw: RawCommandV1, + error: CommandError, + validate: validate_command, } impl CommandV1 { @@ -256,7 +257,7 @@ impl CommandV1 { Self { version: SchemaVersion::V1, - kind: RowKind::Command, + kind: Self::KIND, event_id: EventId::new(), day, at, @@ -269,51 +270,16 @@ impl CommandV1 { } } -/// Strict wire representation validated before becoming a command 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 RawCommandV1 { - #[serde(rename = "v", deserialize_with = "deserialize_version_one")] - version: SchemaVersion, - kind: RowKind, - event_id: EventId, - day: UtcDay, - at: UtcSecond, - symposium: SymposiumVersion, - command: CommandCoordinate, - duration_ms: u64, - outcome: CommandOutcome, - command_subject: CommandSubject, -} - -impl TryFrom for CommandV1 { - type Error = CommandError; - - fn try_from(raw: RawCommandV1) -> Result { - let timestamp_day = raw.at.day(); - if raw.day != timestamp_day { - return Err(CommandError::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, - command: raw.command, - duration_ms: raw.duration_ms, - outcome: raw.outcome, - command_subject: raw.command_subject, - }) +fn validate_command(raw: &RawCommandV1) -> Result<(), CommandError> { + let timestamp_day = raw.at.day(); + if raw.day != timestamp_day { + return Err(CommandError::DayDoesNotMatchTimestamp { + stored: raw.day, + timestamp: timestamp_day, + }); } + + Ok(()) } /// Invalid relationship between fields in a command row. @@ -690,6 +656,22 @@ mod tests { assert!(result.is_err()); } + #[test] + fn command_rejects_another_row_kind() { + let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); + let mut value = serde_json::to_value(row).unwrap(); + value["kind"] = serde_json::Value::String("session_start".to_owned()); + + let result = serde_json::from_value::(value); + + assert!( + result + .unwrap_err() + .to_string() + .contains("expected Command row kind, found SessionStart") + ); + } + #[test] fn command_rejects_unknown_fields() { let row = command_row(CommandCoordinate::builtin(BuiltinCommand::Use)); diff --git a/src/telemetry/schema/macros.rs b/src/telemetry/schema/macros.rs new file mode 100644 index 00000000..373515db --- /dev/null +++ b/src/telemetry/schema/macros.rs @@ -0,0 +1,105 @@ +//! Declarative helpers for strict versioned telemetry rows. + +/// Define a versioned row and its strict, validation-first wire form. +/// +/// The field list is the source of truth for the row's declaration, its raw +/// deserialization type, and the mechanical transfer between them. Validation +/// remains an ordinary function in the row module so its rules stay visible. +/// Serde field attributes are applied to both representations. The macro owns +/// container attributes, so callers can supply row documentation but not a +/// second, potentially conflicting set of derives or Serde rules. +macro_rules! strict_versioned_row { + ( + $(#[doc = $row_doc:literal])* + $visibility:vis struct $row:ident { + $( + $(#[$field_metadata:meta])* + $field:ident: $field_type:ty, + )* + } + + kind: $kind:path, + raw: $raw:ident, + error: $error:ty, + validate: $validate:path, + ) => { + $(#[doc = $row_doc])* + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] + $visibility struct $row { + #[serde(rename = "v")] + version: $crate::telemetry::schema::SchemaVersion, + kind: $crate::telemetry::schema::RowKind, + event_id: $crate::telemetry::schema::EventId, + day: $crate::telemetry::schema::UtcDay, + $( + $(#[$field_metadata])* + $field: $field_type, + )* + } + + impl $row { + const KIND: $crate::telemetry::schema::RowKind = $kind; + } + + // Serde's `try_from` attribute requires a string literal, which + // `macro_rules!` cannot construct from `$raw`. + impl<'de> serde::Deserialize<'de> for $row { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = <$raw as serde::Deserialize>::deserialize(deserializer)?; + + if raw.kind != Self::KIND { + return Err(serde::de::Error::custom(format_args!( + "expected {:?} row kind, found {:?}", + Self::KIND, + raw.kind + ))); + } + + Self::try_from(raw).map_err(serde::de::Error::custom) + } + } + + #[doc = concat!( + "Strict wire representation validated before becoming `", + stringify!($row), + "`." + )] + #[derive(Debug, serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct $raw { + #[serde( + rename = "v", + deserialize_with = "crate::telemetry::schema::deserialize_version_one" + )] + version: $crate::telemetry::schema::SchemaVersion, + kind: $crate::telemetry::schema::RowKind, + event_id: $crate::telemetry::schema::EventId, + day: $crate::telemetry::schema::UtcDay, + $( + $(#[$field_metadata])* + $field: $field_type, + )* + } + + impl TryFrom<$raw> for $row { + type Error = $error; + + fn try_from(raw: $raw) -> Result { + $validate(&raw)?; + + Ok(Self { + version: raw.version, + kind: Self::KIND, + event_id: raw.event_id, + day: raw.day, + $($field: raw.$field,)* + }) + } + } + }; +} + +pub(super) use strict_versioned_row; diff --git a/src/telemetry/schema/mod.rs b/src/telemetry/schema/mod.rs index 880da4e4..bffb6b24 100644 --- a/src/telemetry/schema/mod.rs +++ b/src/telemetry/schema/mod.rs @@ -7,6 +7,7 @@ mod agent; mod command; mod extension; +mod macros; mod name; mod resolution; diff --git a/src/telemetry/schema/resolution.rs b/src/telemetry/schema/resolution.rs index b6895cd3..07655a52 100644 --- a/src/telemetry/schema/resolution.rs +++ b/src/telemetry/schema/resolution.rs @@ -8,8 +8,8 @@ use std::fmt; use serde::{Deserialize, Serialize}; use super::{ - DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, UtcDay, - deserialize_version_one, + DroppedOperation, EventId, RowKind, SchemaVersion, SymposiumVersion, + macros::strict_versioned_row, }; use crate::telemetry::{identity::SessionId, state::BoundRecordingObservation}; @@ -131,29 +131,29 @@ pub(in crate::telemetry) struct ResolutionSummaryFields { 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, +strict_versioned_row! { + /// Version 1 summary of one completed full resolution and sync. + pub(in crate::telemetry) struct ResolutionSummaryV1 { + symposium: SymposiumVersion, + trigger: ResolutionTrigger, + outcome: ResolutionOutcome, + duration_ms: u64, + public_packages: u64, + unnamed_packages: u64, + unnamed_package_reasons: UnnamedPackageReasons, + plugins: u64, + skills: u64, + installed: u64, + updated: u64, + reaped: u64, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + } + + kind: RowKind::ResolutionSummary, + raw: RawResolutionSummaryV1, + error: ResolutionSummaryError, + validate: validate_resolution_summary, } impl ResolutionSummaryV1 { @@ -174,7 +174,7 @@ impl ResolutionSummaryV1 { Ok(Self { version: SchemaVersion::V1, - kind: RowKind::ResolutionSummary, + kind: Self::KIND, event_id: EventId::new(), day: observation.day(), symposium: SymposiumVersion::current(), @@ -217,69 +217,20 @@ impl fmt::Display for ResolutionSummaryError { 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, - }) +fn validate_resolution_summary(raw: &RawResolutionSummaryV1) -> Result<(), ResolutionSummaryError> { + let derived = raw + .unnamed_package_reasons + .checked_total() + .ok_or(ResolutionSummaryError::UnnamedPackageCountOverflow)?; + + if raw.unnamed_packages != derived { + return Err(ResolutionSummaryError::UnnamedPackageCountMismatch { + stored: raw.unnamed_packages, + derived, + }); } + + Ok(()) } #[cfg(test)] @@ -287,7 +238,7 @@ mod tests { use chrono::NaiveDate; use super::super::{ - IDENTIFIER_WINDOW_TEST_STATE, assert_contract_names, recording_observation, + IDENTIFIER_WINDOW_TEST_STATE, UtcDay, assert_contract_names, recording_observation, }; use super::*; use crate::telemetry::state::TelemetryStateV1;