Skip to main content

mnemo_cma/
audit_bridge.rs

1//! Bridge CMA `audit.jsonl` rows into mnemo's HMAC chain (v0.4.1 P0-2).
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7/// Marks a mnemo `AuditEvent` as having been produced by a CMA-Memory
8/// write rather than a native `remember` call. Lives in the event's
9/// `metadata` payload.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum CmaSource {
12    /// The Anthropic CMA-Memory beta wrote this row through the
13    /// shim's `WriteThrough` path.
14    CmaBeta,
15    /// One-shot import of a pre-existing CMA tree.
16    CmaImport,
17}
18
19impl CmaSource {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            CmaSource::CmaBeta => "cma_beta",
23            CmaSource::CmaImport => "cma_import",
24        }
25    }
26}
27
28/// One CMA audit row that has been bridged into mnemo's chain.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct BridgedEvent {
31    pub source: CmaSource,
32    pub cma_path: String,
33    pub cma_op: String,
34    pub bytes: u64,
35    pub prev_hash: [u8; 32],
36    pub bridge_hash: [u8; 32],
37}
38
39#[derive(Debug, Error, PartialEq)]
40pub enum BridgeError {
41    #[error("malformed CMA audit row: {0}")]
42    Malformed(String),
43}
44
45/// Hash a CMA row into the chain. Pure fn so the audit-log writer
46/// can compute heads without touching any keys.
47pub fn bridge_event(
48    source: CmaSource,
49    cma_path: &str,
50    cma_op: &str,
51    bytes: u64,
52    prev_hash: [u8; 32],
53) -> BridgedEvent {
54    let mut h = Sha256::new();
55    h.update(prev_hash);
56    h.update(source.as_str().as_bytes());
57    h.update(b"|");
58    h.update(cma_path.as_bytes());
59    h.update(b"|");
60    h.update(cma_op.as_bytes());
61    h.update(b"|");
62    h.update(bytes.to_be_bytes());
63    let bridge_hash: [u8; 32] = h.finalize().into();
64    BridgedEvent {
65        source,
66        cma_path: cma_path.to_string(),
67        cma_op: cma_op.to_string(),
68        bytes,
69        prev_hash,
70        bridge_hash,
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn bridge_is_deterministic_for_fixed_input() {
80        let a = bridge_event(CmaSource::CmaBeta, "notes/x.md", "write", 42, [0u8; 32]);
81        let b = bridge_event(CmaSource::CmaBeta, "notes/x.md", "write", 42, [0u8; 32]);
82        assert_eq!(a, b);
83    }
84
85    #[test]
86    fn bridge_changes_with_path() {
87        let a = bridge_event(CmaSource::CmaBeta, "x.md", "write", 1, [0u8; 32]);
88        let b = bridge_event(CmaSource::CmaBeta, "y.md", "write", 1, [0u8; 32]);
89        assert_ne!(a.bridge_hash, b.bridge_hash);
90    }
91
92    #[test]
93    fn bridge_changes_with_op() {
94        let a = bridge_event(CmaSource::CmaBeta, "x.md", "write", 1, [0u8; 32]);
95        let b = bridge_event(CmaSource::CmaBeta, "x.md", "delete", 1, [0u8; 32]);
96        assert_ne!(a.bridge_hash, b.bridge_hash);
97    }
98
99    #[test]
100    fn bridge_chains_off_prev() {
101        let a = bridge_event(CmaSource::CmaBeta, "x.md", "write", 1, [0u8; 32]);
102        let b = bridge_event(CmaSource::CmaBeta, "x.md", "write", 1, a.bridge_hash);
103        assert_ne!(a.bridge_hash, b.bridge_hash);
104        assert_eq!(b.prev_hash, a.bridge_hash);
105    }
106}