//! # signal-schema //! //! The shared, versioned wire format for the ambient-companions signal bus. //! //! ## The privacy boundary, made structural //! //! Everything in this suite is a footnote to one invariant (spec §1.5): //! //! > No process persists, transmits, or exposes any representation from which //! > the content or identity of an individual keystroke, command argument, or //! > typed character can be recovered. Only order-free aggregates (counts, //! > rates, durations, codes) leave the terminal collector. //! //! This crate makes the *schema-level half* of that invariant true **by //! construction**: the only payload channel is [`Value`], a newtype over //! `f64`. There is no `text`, `bytes`, `payload`, or `content` field. A //! collector *cannot* emit typed content because the wire format has nowhere //! to put it. //! //! The two audited exceptions are non-content identifiers carried in [`Tag`] //! (a bundle id, an absolute repo path, or an SSH host) and are allow-listed //! per [`SignalName`]. Everything else is `tag == None`, enforced at the //! daemon boundary. //! //! The structural guarantee is not asserted in prose alone — see //! `tests/privacy_invariant.rs`, which fails the build if a content-carrying //! field is ever added. /// Bump on **any** field change to [`Signal`]. Renderers negotiate on connect /// and ignore records they do not understand. /// /// v3 (0.3.0): added the aggregate [`SignalName::CpuLoad`] emitted by the macOS /// IOKit hardware collector (`macos-collector/`, a sibling Swift package). pub const SCHEMA_VERSION: u16 = 3; /// The collector domain a signal originated from. /// /// A small closed enum — never free text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Source { Terminal, Git, Macos, Hardware, } impl Source { /// Stable wire discriminant. pub fn to_u8(self) -> u8 { match self { Source::Terminal => 0, Source::Git => 1, Source::Macos => 2, Source::Hardware => 3, } } /// Inverse of [`Source::to_u8`]; `None` for an unknown discriminant. pub fn from_u8(v: u8) -> Option { Some(match v { 0 => Source::Terminal, 1 => Source::Git, 2 => Source::Macos, 3 => Source::Hardware, _ => return None, }) } } /// The **enum-constrained** metric name. /// /// A fixed allow-list. Unknown names are dropped at the daemon boundary. This /// prevents a future careless collector from inventing `last_command` as a /// name and shoving a string through the tag. New metrics require a new /// variant here (and a schema-version bump), which is a reviewed change. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignalName { // --- terminal (aggregates only) --- KeysPerMin, InterArrivalVariance, SessionSeconds, CommandsPerMin, ExitCodeRatio, TodBucket, // --- git --- Commits5m, LinesAdded, LinesRemoved, BranchLastCommitAge, DirtyWorktree, /// Commits within the collector's rolling window (last N days). CommitsWindow, /// Commits since local midnight. CommitsToday, /// Number of local branches in the repo. BranchCount, /// Whole days since the most recent commit on `HEAD`. DaysSinceLastCommit, // --- macos system --- AppForegroundSeconds, IdleSeconds, BatteryPct, Charging, ThermalState, // --- hardware --- /// Aggregate CPU busy fraction across all cores in `[0.0, 1.0]`, from Mach /// `host_processor_info` tick deltas. Emitted by the macOS IOKit collector /// as an untagged scalar (the per-core, tagged variant is [`CpuCoreLoad`]). CpuLoad, CpuCoreLoad, NetRxBps, NetTxBps, FanRpm, DiskReadBps, DiskWriteBps, GpuUtil, BatteryDrawW, } impl SignalName { /// Whether this metric is permitted to carry a [`Tag`]. Only a small, /// audited set may — everything else must have `tag == None`. pub fn allows_tag(self) -> bool { matches!( self, SignalName::BranchLastCommitAge // tag: repo/branch identifier | SignalName::CommitsWindow // tag: repo path (audited, spec §1.2) | SignalName::CommitsToday // tag: repo path | SignalName::BranchCount // tag: repo path | SignalName::DaysSinceLastCommit // tag: repo path | SignalName::AppForegroundSeconds // tag: bundle id | SignalName::CpuCoreLoad // tag: core index ) } /// Stable wire discriminant. New metrics append here (a reviewed change /// that also bumps [`SCHEMA_VERSION`]). pub fn to_u8(self) -> u8 { match self { SignalName::KeysPerMin => 0, SignalName::InterArrivalVariance => 1, SignalName::SessionSeconds => 2, SignalName::CommandsPerMin => 3, SignalName::ExitCodeRatio => 4, SignalName::TodBucket => 5, SignalName::Commits5m => 6, SignalName::LinesAdded => 7, SignalName::LinesRemoved => 8, SignalName::BranchLastCommitAge => 9, SignalName::DirtyWorktree => 10, SignalName::CommitsWindow => 11, SignalName::CommitsToday => 12, SignalName::BranchCount => 13, SignalName::DaysSinceLastCommit => 14, SignalName::AppForegroundSeconds => 15, SignalName::IdleSeconds => 16, SignalName::BatteryPct => 17, SignalName::Charging => 18, SignalName::ThermalState => 19, SignalName::CpuCoreLoad => 20, SignalName::NetRxBps => 21, SignalName::NetTxBps => 22, SignalName::FanRpm => 23, SignalName::DiskReadBps => 24, SignalName::DiskWriteBps => 25, SignalName::GpuUtil => 26, SignalName::BatteryDrawW => 27, // Appended in v3 (0.3.0); discriminants are append-only. SignalName::CpuLoad => 28, } } /// Inverse of [`SignalName::to_u8`]. An unknown discriminant returns `None` /// so the daemon can drop names it does not understand at the boundary. pub fn from_u8(v: u8) -> Option { Some(match v { 0 => SignalName::KeysPerMin, 1 => SignalName::InterArrivalVariance, 2 => SignalName::SessionSeconds, 3 => SignalName::CommandsPerMin, 4 => SignalName::ExitCodeRatio, 5 => SignalName::TodBucket, 6 => SignalName::Commits5m, 7 => SignalName::LinesAdded, 8 => SignalName::LinesRemoved, 9 => SignalName::BranchLastCommitAge, 10 => SignalName::DirtyWorktree, 11 => SignalName::CommitsWindow, 12 => SignalName::CommitsToday, 13 => SignalName::BranchCount, 14 => SignalName::DaysSinceLastCommit, 15 => SignalName::AppForegroundSeconds, 16 => SignalName::IdleSeconds, 17 => SignalName::BatteryPct, 18 => SignalName::Charging, 19 => SignalName::ThermalState, 20 => SignalName::CpuCoreLoad, 21 => SignalName::NetRxBps, 22 => SignalName::NetTxBps, 23 => SignalName::FanRpm, 24 => SignalName::DiskReadBps, 25 => SignalName::DiskWriteBps, 26 => SignalName::GpuUtil, 27 => SignalName::BatteryDrawW, 28 => SignalName::CpuLoad, _ => return None, }) } } /// The **only** payload channel: a single `f64`. /// /// This newtype is the load-bearing privacy primitive. There is deliberately /// no constructor that accepts a `String`, `&[u8]`, or any content-shaped /// type. If you find yourself wanting to widen this, stop: that is the /// privacy boundary you would be dismantling. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Value(pub f64); /// A structurally validated, non-content identifier (spec §1.2). /// /// Never free text. Constructed only via [`Tag::bundle_id`], /// [`Tag::repo_path`], or [`Tag::ssh_host`], each of which validates shape. /// M0: validation is stubbed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tag(String); impl Tag { /// A macOS bundle id, e.g. `com.apple.Terminal`. Validated against a /// bundle-id shape at the daemon boundary. pub fn bundle_id(_s: &str) -> Option { todo!("validate bundle-id shape (reverse-DNS); reject anything else") } /// An absolute repo path. Validated as a shape (absolute, non-empty, no /// interior NUL) — a structural identifier, never free text. Per-watched- /// root prefix enforcement is layered on at the daemon boundary later. pub fn repo_path(s: &str) -> Option { if s.starts_with('/') && !s.is_empty() && !s.contains('\0') { Some(Tag(s.to_string())) } else { None } } /// An SSH host present in `known_hosts`. pub fn ssh_host(_s: &str) -> Option { todo!("validate against ~/.ssh/known_hosts") } /// Read-only view of the validated identifier. pub fn as_str(&self) -> &str { &self.0 } } /// One flat, versioned record. Every field is a named scalar, a small enum, or /// an audited non-content identifier. /// /// Note what is absent: there is no field capable of carrying a character or a /// string of typed content. That absence is the point. #[derive(Debug, Clone, PartialEq)] pub struct Signal { /// Bump on any field change; see [`SCHEMA_VERSION`]. pub schema_version: u16, /// Unix millis, monotonic-corrected. pub ts: u64, /// Originating collector domain. pub source: Source, /// Enum-constrained metric name. pub name: SignalName, /// The only payload channel. pub value: Value, /// Optional, audited, non-content identifier. `Some` only for names where /// [`SignalName::allows_tag`] is true. pub tag: Option, } impl Signal { /// Validate a record against the schema's structural rules: a tag is /// present only where the name allows it. This is the daemon-boundary /// check; renderers can trust records that pass it. pub fn is_well_formed(&self) -> bool { self.schema_version == SCHEMA_VERSION && (self.tag.is_none() || self.name.allows_tag()) } } /// Length-prefixed wire encoding (spec §1.3: "length-prefixed framing over the /// Unix socket"). /// /// The frame is a little-endian `u32` body length followed by the body: /// /// ```text /// [u32 body_len] [u16 schema_version] [u64 ts] [u8 source] [u8 name] /// [f64 value] [u8 tag_present] [ (u16 tag_len) (tag_len bytes utf8) ]? /// ``` /// /// The payload channel is still exactly the `f64` `value` — the framing adds /// nowhere to put typed content. The one string on the wire is the audited /// `tag` identifier, and only when [`SignalName::allows_tag`] permits it. pub mod wire { use super::{Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION}; /// Encode a signal to its length-prefixed wire bytes. pub fn encode(s: &Signal) -> Vec { let mut body = Vec::with_capacity(24); body.extend_from_slice(&s.schema_version.to_le_bytes()); body.extend_from_slice(&s.ts.to_le_bytes()); body.push(s.source.to_u8()); body.push(s.name.to_u8()); body.extend_from_slice(&s.value.0.to_le_bytes()); match &s.tag { None => body.push(0), Some(tag) => { body.push(1); let bytes = tag.as_str().as_bytes(); body.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); body.extend_from_slice(bytes); } } let mut frame = Vec::with_capacity(4 + body.len()); frame.extend_from_slice(&(body.len() as u32).to_le_bytes()); frame.extend_from_slice(&body); frame } /// Decode one length-prefixed frame from the front of `buf`. Returns `None` /// if the buffer is short or the frame is malformed. Trailing bytes are /// ignored, so this is safe to call on a read buffer holding one frame. pub fn decode(buf: &[u8]) -> Option { if buf.len() < 4 { return None; } let body_len = u32::from_le_bytes(buf[0..4].try_into().ok()?) as usize; let body = buf.get(4..4 + body_len)?; decode_body(body) } /// Read exactly one frame from a stream. `Ok(None)` signals a clean EOF at a /// frame boundary; a partial or malformed frame is an error. pub fn read_frame(r: &mut impl std::io::Read) -> std::io::Result> { use std::io::{Error, ErrorKind}; let mut len_buf = [0u8; 4]; match r.read_exact(&mut len_buf) { Ok(()) => {} Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(None), Err(e) => return Err(e), } let body_len = u32::from_le_bytes(len_buf) as usize; let mut body = vec![0u8; body_len]; r.read_exact(&mut body)?; decode_body(&body) .map(Some) .ok_or_else(|| Error::new(ErrorKind::InvalidData, "malformed signal frame")) } /// Encode `s` and write the whole frame to a stream. pub fn write_frame(w: &mut impl std::io::Write, s: &Signal) -> std::io::Result<()> { w.write_all(&encode(s)) } fn decode_body(body: &[u8]) -> Option { // schema_version(2) + ts(8) + source(1) + name(1) + value(8) + tag_flag(1) if body.len() < 21 { return None; } let schema_version = u16::from_le_bytes(body[0..2].try_into().ok()?); let ts = u64::from_le_bytes(body[2..10].try_into().ok()?); let source = Source::from_u8(body[10])?; let name = SignalName::from_u8(body[11])?; let value = Value(f64::from_le_bytes(body[12..20].try_into().ok()?)); let tag = match body[20] { 0 => None, 1 => { let len = u16::from_le_bytes(body.get(21..23)?.try_into().ok()?) as usize; let bytes = body.get(23..23 + len)?; Some(Tag(std::str::from_utf8(bytes).ok()?.to_string())) } _ => return None, }; let signal = Signal { schema_version, ts, source, name, value, tag, }; // Only accept records this build understands and that obey the tag rule. if signal.schema_version != SCHEMA_VERSION || !signal.is_well_formed() { return None; } Some(signal) } } #[cfg(test)] mod wire_tests { use super::*; fn sig(name: SignalName, value: f64, tag: Option) -> Signal { Signal { schema_version: SCHEMA_VERSION, ts: 1_723_100_000_000, source: Source::Git, name, value: Value(value), tag, } } #[test] fn round_trip_no_tag() { let s = sig(SignalName::CommitsToday, 7.0, None); let bytes = wire::encode(&s); let back = wire::decode(&bytes).expect("decodes"); assert_eq!(s, back); } #[test] fn round_trip_with_tag() { let tag = Tag::repo_path("/Users/x/git/repo").expect("valid repo path"); let s = Signal { source: Source::Git, ..sig(SignalName::BranchLastCommitAge, 3.0, Some(tag)) }; let bytes = wire::encode(&s); let back = wire::decode(&bytes).expect("decodes"); assert_eq!(s, back); } #[test] fn round_trip_preserves_float_payload() { let s = sig(SignalName::DaysSinceLastCommit, 12.5, None); let back = wire::decode(&wire::encode(&s)).expect("decodes"); assert_eq!(back.value, Value(12.5)); } #[test] fn stream_read_frame_round_trips_multiple() { let a = sig(SignalName::CommitsWindow, 4.0, None); let b = sig(SignalName::BranchCount, 2.0, None); let mut buf = Vec::new(); wire::write_frame(&mut buf, &a).unwrap(); wire::write_frame(&mut buf, &b).unwrap(); let mut cursor = std::io::Cursor::new(buf); let ra = wire::read_frame(&mut cursor).unwrap().unwrap(); let rb = wire::read_frame(&mut cursor).unwrap().unwrap(); let end = wire::read_frame(&mut cursor).unwrap(); assert_eq!(ra, a); assert_eq!(rb, b); assert!(end.is_none(), "clean EOF at frame boundary"); } #[test] fn decode_rejects_short_buffer() { assert!(wire::decode(&[0, 1, 2]).is_none()); } }