openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Super-properties builder — attached to every outbound event by the client.
//!
//! See `.brainstorms/2026-04-13-posthog-client-telemetry.md §5.1`.
//!
//! Explicitly excluded from super-properties: IP address (PostHog project
//! configured to drop it), hostname, username, working directory, file paths,
//! command arguments, environment variable names beyond the telemetry toggles.

use serde_json::{json, Map, Value};

/// Current event schema version. Bumped when any event shape changes so
/// PostHog consumers can filter by shape.
///
/// v3 added `environment` and `release` to every event. They arrived with the move
/// to PostHog error tracking, where they stopped being a crash-reporter
/// concept and became properties the whole event stream carries — which is exactly
/// the kind of shape change this constant exists to announce.
pub const EVENT_SCHEMA_VERSION: u32 = 3;

/// Super-properties snapshot, refreshed only at authentication transitions.
#[derive(Debug, Clone)]
pub struct SuperProps {
    pub client_version: &'static str,
    pub os: &'static str,
    /// `"production"` or `"development"` — see `network::environment`.
    pub environment: &'static str,
    /// The baked git sha.
    pub release: &'static str,
    pub agent_id: String,
    pub session_id: String,
    pub authenticated: bool,
    pub user_db_id: Option<String>,
    pub org_id: Option<String>,
}

impl SuperProps {
    /// Construct from the resolved `agent_id` (from `config::ensure_agent_id`)
    /// and the current auth state. `session_id` is generated fresh per process.
    pub fn new(agent_id: String, authenticated: bool) -> Self {
        Self {
            client_version: env!("OPENLATCH_VERSION"),
            os: os_tag(),
            environment: super::network::environment(),
            release: super::network::release(),
            agent_id,
            session_id: format!("sess_{}", uuid::Uuid::now_v7().simple()),
            authenticated,
            user_db_id: None,
            org_id: None,
        }
    }

    /// Merge the super-properties into an event's property map, plus a fresh
    /// `$insert_id` for PostHog /batch deduplication.
    pub fn merge_into(&self, props: &mut Map<String, Value>) {
        // Identity is frozen at capture time, not when a delayed batch is sent.
        // The alias event deliberately supplies its canonical distinct_id.
        props
            .entry("distinct_id".to_string())
            .or_insert_with(|| json!(self.user_db_id.as_deref().unwrap_or(&self.agent_id)));
        if let Some(org_id) = self.org_id.as_deref() {
            props.insert("$groups".into(), json!({ "organization": org_id }));
        } else {
            props.remove("$groups");
        }
        props.insert("client_version".into(), json!(self.client_version));
        props.insert("os".into(), json!(self.os));
        props.insert("environment".into(), json!(self.environment));
        props.insert("release".into(), json!(self.release));
        props.insert("agent_id".into(), json!(self.agent_id));
        props.insert("session_id".into(), json!(self.session_id));
        props.insert("authenticated".into(), json!(self.authenticated));
        props.insert("event_schema_version".into(), json!(EVENT_SCHEMA_VERSION));
        props.insert(
            "$insert_id".into(),
            json!(format!("ins_{}", uuid::Uuid::now_v7().simple())),
        );
    }
}

/// Compile-time OS tag in the same `{os}-{arch}` form used by the envelope.
const fn os_tag() -> &'static str {
    // target_os + target_arch combinations we explicitly support (CLAUDE.md).
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    {
        "darwin-arm64"
    }
    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
    {
        "darwin-x64"
    }
    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    {
        "linux-x64"
    }
    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    {
        "linux-arm64"
    }
    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
    {
        "win32-x64"
    }
    #[cfg(not(any(
        all(target_os = "macos", target_arch = "aarch64"),
        all(target_os = "macos", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "aarch64"),
        all(target_os = "windows", target_arch = "x86_64"),
    )))]
    {
        "unknown"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_merge_into_attaches_all_fields() {
        let sp = SuperProps::new("agt_deadbeef".into(), false);
        let mut props = Map::new();
        sp.merge_into(&mut props);

        assert!(props.contains_key("client_version"));
        assert!(props.contains_key("os"));
        assert_eq!(props["agent_id"], "agt_deadbeef");
        assert!(props.contains_key("session_id"));
        assert_eq!(props["authenticated"], false);
        assert_eq!(props["event_schema_version"], 3);
        assert!(props.contains_key("environment"));
        assert!(props.contains_key("release"));
        assert_eq!(props["distinct_id"], "agt_deadbeef");
        assert!(!props.contains_key("$groups"));
        assert!(props.contains_key("$insert_id"));
    }

    #[test]
    fn test_session_id_is_unique_per_instance() {
        let a = SuperProps::new("agt_x".into(), true);
        let b = SuperProps::new("agt_x".into(), true);
        assert_ne!(a.session_id, b.session_id);
    }

    #[test]
    fn test_os_tag_is_known_value() {
        // Every supported build target produces a non-"unknown" tag.
        let tag = os_tag();
        let supported = [
            "darwin-arm64",
            "darwin-x64",
            "linux-x64",
            "linux-arm64",
            "win32-x64",
        ];
        if tag == "unknown" {
            // Unsupported build target in CI — just check the constant is wired.
            assert_eq!(tag, "unknown");
        } else {
            assert!(supported.contains(&tag), "unexpected os tag: {tag}");
        }
    }
}