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
//! The `$exception` wire types for native (Rust) frames.
//!
//! Transcribed from PostHog's own symbolication service, `cymbal`
//! (`rust/cymbal/src/core/types/`), read 2026-09-14 at commit e0970fef. THIS SHAPE IS NOT
//! IN THE PUBLIC DOCUMENTATION — the docs say `platform` must be `"custom"`, which is
//! wrong for native frames. Re-check against that source, not the docs, on any change.
//!
//! Hand-written rather than generated on purpose: there is no published schema, so a
//! hand-written copy carrying the provenance of every struct is the honest shape.

use serde::Serialize;

/// Mirrors `cymbal::core::types::exception::Exception`.
///
/// `id` is server-populated and never sent; `module` is unused for panics.
#[derive(Debug, Clone, Serialize)]
pub struct Exception {
    /// Wire name `type`. Required. We always send "panic".
    #[serde(rename = "type")]
    pub exception_type: String,
    /// Wire name `value`. The panic message, AFTER scrubbing.
    pub value: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mechanism: Option<Mechanism>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<i32>,
    pub stacktrace: Stacktrace,
}

/// Mirrors `cymbal::core::types::Mechanism`. For a panic the values are fixed.
#[derive(Debug, Clone, Serialize)]
pub struct Mechanism {
    #[serde(rename = "type")]
    pub mechanism_type: String,
    pub handled: bool,
    pub synthetic: bool,
}

impl Mechanism {
    /// The one mechanism this module ever emits: an unhandled, real panic.
    pub fn panic() -> Self {
        Self {
            mechanism_type: "panic".to_string(),
            handled: false,
            synthetic: false,
        }
    }
}

/// Mirrors `cymbal::core::types::stacktrace::Stacktrace`, the `Raw` variant.
/// Serialized as `{"type": "raw", "frames": [...]}` — the tag is load-bearing.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Stacktrace {
    Raw { frames: Vec<Frame> },
}

impl Stacktrace {
    /// Borrow the frames without matching at every call site. One variant today; a
    /// second would have to answer this too rather than silently skip it.
    pub fn frames(&self) -> &[Frame] {
        match self {
            Stacktrace::Raw { frames } => frames,
        }
    }

    /// Mutable twin of [`Stacktrace::frames`], for the scrubber.
    pub fn frames_mut(&mut self) -> &mut [Frame] {
        match self {
            Stacktrace::Raw { frames } => frames,
        }
    }
}

/// Mirrors `cymbal::core::types::langs::native::RawNativeFrame`, plus the
/// `#[serde(tag = "platform")]` discriminant from the enclosing `RawFrame` enum.
///
/// `platform` MUST be "native" (the server also accepts the legacy alias "rust").
#[derive(Debug, Clone, Serialize)]
pub struct Frame {
    /// The enum discriminant, flattened in by hand because we only ever emit one variant.
    pub platform: &'static str,

    /// Absolute address. Required for server-side symbolication; a frame without one
    /// passes through carrying only the client-side fields.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instruction_addr: Option<String>,
    /// Load address of the containing module. Used for the exact image match.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_addr: Option<String>,

    /// Display hint only — the resolved filename extension wins when symbolication works.
    pub lang: &'static str,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub module: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub lineno: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub colno: Option<u32>,

    /// True when WE resolved function/filename/lineno in-process.
    pub client_resolved: bool,

    /// ALWAYS SERIALIZED, never skipped. The server defaults an absent `in_app` to
    /// **true**, which would mark every std and dependency frame as ours and destroy
    /// issue grouping.
    pub in_app: bool,

    pub synthetic: bool,
}

impl Frame {
    /// A frame carrying an address and nothing else: stripped, or unresolvable
    /// in-process. The normal case for a release build, and the case symbolication
    /// exists to fix.
    pub fn address_only(instruction_addr: Option<String>, image_addr: Option<String>) -> Self {
        Self {
            platform: "native",
            instruction_addr,
            image_addr,
            lang: "rust",
            module: None,
            function: None,
            filename: None,
            lineno: None,
            colno: None,
            client_resolved: false,
            // Unknown provenance is not ours. Never omitted.
            in_app: false,
            synthetic: false,
        }
    }
}

/// Mirrors `cymbal::core::types::langs::native::DebugImage`.
///
/// NOTE: cymbal has no `code_id` field. `posthog-rs` emits one and it is ignored on
/// ingest; we omit it rather than send a field nothing reads.
#[derive(Debug, Clone, Serialize)]
pub struct DebugImage {
    /// REQUIRED. The symbol-set lookup key. Case is format-dependent and load-bearing —
    /// see `images.rs`.
    pub debug_id: String,
    /// REQUIRED. The ACTUAL load address (preferred base + ASLR slide).
    pub image_addr: String,
    /// Decimal integer, NOT hex — the committed server snapshot shows `4096`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_size: Option<u64>,
    /// Stated load address; informational, not used in the address math.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_vmaddr: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code_file: Option<String>,
    /// Wire name `type`. "elf" | "macho" | "pe".
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub image_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arch: Option<String>,
}

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

    fn resolved_frame() -> Frame {
        Frame {
            platform: "native",
            instruction_addr: Some("0x7f3a9c041b2d".into()),
            image_addr: Some("0x7f3a9c000000".into()),
            lang: "rust",
            module: Some("openlatch_client".into()),
            function: Some("openlatch_client::daemon::run".into()),
            filename: Some("src/daemon/mod.rs".into()),
            lineno: Some(42),
            colno: Some(9),
            client_resolved: true,
            in_app: true,
            synthetic: false,
        }
    }

    /// THE CONTRACT TEST. Field-for-field against cymbal's names — this is what fails
    /// when someone renames a field to something that reads better.
    #[test]
    fn exception_serializes_to_the_cymbal_wire_shape() {
        let exc = Exception {
            exception_type: "panic".into(),
            value: "index out of bounds".into(),
            mechanism: Some(Mechanism::panic()),
            thread_id: None,
            stacktrace: Stacktrace::Raw {
                frames: vec![
                    resolved_frame(),
                    Frame::address_only(
                        Some("0x7f3a9c0410aa".into()),
                        Some("0x7f3a9c000000".into()),
                    ),
                ],
            },
        };

        let got = serde_json::to_value(&exc).expect("exception serializes");
        let want = json!({
            "type": "panic",
            "value": "index out of bounds",
            "mechanism": { "type": "panic", "handled": false, "synthetic": false },
            "stacktrace": {
                "type": "raw",
                "frames": [
                    {
                        "platform": "native",
                        "instruction_addr": "0x7f3a9c041b2d",
                        "image_addr": "0x7f3a9c000000",
                        "lang": "rust",
                        "module": "openlatch_client",
                        "function": "openlatch_client::daemon::run",
                        "filename": "src/daemon/mod.rs",
                        "lineno": 42,
                        "colno": 9,
                        "client_resolved": true,
                        "in_app": true,
                        "synthetic": false
                    },
                    {
                        "platform": "native",
                        "instruction_addr": "0x7f3a9c0410aa",
                        "image_addr": "0x7f3a9c000000",
                        "lang": "rust",
                        "client_resolved": false,
                        "in_app": false,
                        "synthetic": false
                    }
                ]
            }
        });
        assert_eq!(got, want);
    }

    /// An absent optional must be omitted, never serialized as null.
    #[test]
    fn absent_optionals_are_omitted_not_nulled() {
        let exc = Exception {
            exception_type: "panic".into(),
            value: String::new(),
            mechanism: None,
            thread_id: None,
            stacktrace: Stacktrace::Raw { frames: vec![] },
        };
        let got = serde_json::to_value(&exc).expect("serializes");
        let obj = got.as_object().expect("object");
        assert!(!obj.contains_key("thread_id"));
        assert!(!obj.contains_key("mechanism"));
        assert!(
            obj.contains_key("value"),
            "value is required, never skipped"
        );
    }

    #[test]
    fn debug_image_serializes_with_the_snapshot_field_names() {
        let img = DebugImage {
            debug_id: "67E9247C-814E-392B-A027-DBDE6748FCBF".into(),
            image_addr: "0x100000000".into(),
            image_size: Some(4096),
            image_vmaddr: Some("0x0".into()),
            code_file: Some("openlatch".into()),
            image_type: Some("macho".into()),
            arch: Some("arm64".into()),
        };
        let got = serde_json::to_value(&img).expect("image serializes");
        assert_eq!(
            got,
            json!({
                "debug_id": "67E9247C-814E-392B-A027-DBDE6748FCBF",
                "image_addr": "0x100000000",
                "image_size": 4096,
                "image_vmaddr": "0x0",
                "code_file": "openlatch",
                "type": "macho",
                "arch": "arm64"
            })
        );
        assert!(
            got["image_size"].is_number(),
            "image_size is a decimal integer, never a hex string"
        );
    }

    /// The silent trap: an absent `in_app` means `true` server-side.
    #[test]
    fn every_frame_serializes_in_app_explicitly() {
        for frame in [resolved_frame(), Frame::address_only(None, None)] {
            let v = serde_json::to_value(&frame).expect("frame serializes");
            assert!(
                v.as_object().is_some_and(|o| o.contains_key("in_app")),
                "in_app must never be skipped: {v}"
            );
        }
    }
}