Skip to main content

totalreclaw_core/
protobuf.rs

1//! Minimal protobuf encoder for TotalReclaw fact payloads.
2//!
3//! Hand-rolled wire format matching `mcp/src/subgraph/store.ts:encodeFactProtobuf()`.
4//!
5//! Field numbers match server/proto/totalreclaw.proto:
6//!   1: id (string), 2: timestamp (string), 3: owner (string),
7//!   4: encrypted_blob (bytes), 5: blind_indices (repeated string),
8//!   6: decay_score (double), 7: is_active (bool), 8: version (int32),
9//!   9: (removed in v3 — now encrypted inside field 4),
10//!   10: content_fp (string),
11//!   11: (removed in v3 — now encrypted inside field 4),
12//!   12: sequence_id (int64, server-assigned), 13: encrypted_embedding (string)
13
14/// A fact payload ready for protobuf encoding and on-chain submission.
15#[derive(Debug, Clone)]
16pub struct FactPayload {
17    pub id: String,
18    pub timestamp: String,
19    pub owner: String,
20    pub encrypted_blob_hex: String,
21    pub blind_indices: Vec<String>,
22    pub decay_score: f64,
23    pub source: String,
24    pub content_fp: String,
25    pub agent_id: String,
26    pub encrypted_embedding: Option<String>,
27    /// Outer protobuf schema version.
28    /// - 3 = legacy (inner blob is the pre-v1 binary format).
29    /// - 4 = Memory Taxonomy v1 (inner blob is a v1 JSON payload).
30    /// A value of 0 is treated as `DEFAULT_PROTOBUF_VERSION` for back-compat.
31    pub version: u32,
32}
33
34/// Default outer protobuf schema version (v3, for legacy callers).
35pub const DEFAULT_PROTOBUF_VERSION: u32 = 3;
36
37/// Memory Taxonomy v1 outer protobuf schema version.
38/// Signals that the inner encrypted blob is a v1 JSON payload.
39pub const PROTOBUF_VERSION_V4: u32 = 4;
40
41/// Encode a fact payload as minimal protobuf wire format.
42pub fn encode_fact_protobuf(fact: &FactPayload) -> Vec<u8> {
43    let mut buf = Vec::with_capacity(512);
44
45    // Field 1: id (string)
46    write_string(&mut buf, 1, &fact.id);
47    // Field 2: timestamp (string)
48    write_string(&mut buf, 2, &fact.timestamp);
49    // Field 3: owner (string)
50    write_string(&mut buf, 3, &fact.owner);
51    // Field 4: encrypted_blob (bytes) — stored as hex, decode to raw bytes
52    if let Ok(blob_bytes) = hex::decode(&fact.encrypted_blob_hex) {
53        write_bytes(&mut buf, 4, &blob_bytes);
54    }
55    // Field 5: blind_indices (repeated string)
56    for index in &fact.blind_indices {
57        write_string(&mut buf, 5, index);
58    }
59    // Field 6: decay_score (double)
60    write_double(&mut buf, 6, fact.decay_score);
61    // Field 7: is_active (bool = varint 1)
62    write_varint_field(&mut buf, 7, 1);
63    // Field 8: version (int32) — 3 legacy, 4 for v1 taxonomy. 0 → default 3.
64    let version = if fact.version == 0 {
65        DEFAULT_PROTOBUF_VERSION
66    } else {
67        fact.version
68    };
69    write_varint_field(&mut buf, 8, version);
70    // Fields 9 (source) and 11 (agent_id) removed in v3 — now encrypted inside field 4
71    // Field 10: content_fp (string)
72    write_string(&mut buf, 10, &fact.content_fp);
73    // Field 12: sequence_id — assigned by subgraph, not set client-side
74    // Field 13: encrypted_embedding (string)
75    if let Some(ref emb) = fact.encrypted_embedding {
76        write_string(&mut buf, 13, emb);
77    }
78
79    buf
80}
81
82/// Encode a tombstone protobuf for soft-deleting a fact.
83///
84/// `version`: outer protobuf schema version (3 legacy, 4 for v1 taxonomy).
85/// A value of 0 defaults to `DEFAULT_PROTOBUF_VERSION` (3) for back-compat.
86pub fn encode_tombstone_protobuf(fact_id: &str, owner: &str, version: u32) -> Vec<u8> {
87    let mut buf = Vec::with_capacity(128);
88
89    write_string(&mut buf, 1, fact_id);
90    write_string(&mut buf, 2, &chrono::Utc::now().to_rfc3339());
91    write_string(&mut buf, 3, owner);
92    // Empty encrypted blob
93    write_bytes(&mut buf, 4, &[]);
94    // decay_score = 0 (tombstone signal)
95    write_double(&mut buf, 6, 0.0);
96    // is_active = false
97    write_varint_field(&mut buf, 7, 0);
98    let v = if version == 0 {
99        DEFAULT_PROTOBUF_VERSION
100    } else {
101        version
102    };
103    write_varint_field(&mut buf, 8, v);
104    // Fields 9 (source) and 11 (agent_id) removed in v3
105
106    buf
107}
108
109// ---------------------------------------------------------------------------
110// Wire-format helpers
111// ---------------------------------------------------------------------------
112
113fn write_string(buf: &mut Vec<u8>, field: u32, value: &str) {
114    if value.is_empty() {
115        return;
116    }
117    let data = value.as_bytes();
118    let key = (field << 3) | 2; // wire type 2 = length-delimited
119    encode_varint(buf, key);
120    encode_varint(buf, data.len() as u32);
121    buf.extend_from_slice(data);
122}
123
124fn write_bytes(buf: &mut Vec<u8>, field: u32, value: &[u8]) {
125    let key = (field << 3) | 2;
126    encode_varint(buf, key);
127    encode_varint(buf, value.len() as u32);
128    buf.extend_from_slice(value);
129}
130
131fn write_double(buf: &mut Vec<u8>, field: u32, value: f64) {
132    let key = (field << 3) | 1; // wire type 1 = 64-bit
133    encode_varint(buf, key);
134    buf.extend_from_slice(&value.to_le_bytes());
135}
136
137fn write_varint_field(buf: &mut Vec<u8>, field: u32, value: u32) {
138    let key = (field << 3) | 0; // wire type 0 = varint
139    encode_varint(buf, key);
140    encode_varint(buf, value);
141}
142
143fn encode_varint(buf: &mut Vec<u8>, mut value: u32) {
144    loop {
145        if value <= 0x7f {
146            buf.push(value as u8);
147            break;
148        }
149        buf.push(((value & 0x7f) | 0x80) as u8);
150        value >>= 7;
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_varint_encoding() {
160        let mut buf = Vec::new();
161        encode_varint(&mut buf, 1);
162        assert_eq!(buf, vec![1]);
163
164        buf.clear();
165        encode_varint(&mut buf, 300);
166        assert_eq!(buf, vec![0xAC, 0x02]);
167    }
168
169    #[test]
170    fn test_encode_fact_protobuf() {
171        let payload = FactPayload {
172            id: "test-id".into(),
173            timestamp: "2026-01-01T00:00:00Z".into(),
174            owner: "0xABCD".into(),
175            encrypted_blob_hex: "deadbeef".into(),
176            blind_indices: vec!["hash1".into(), "hash2".into()],
177            decay_score: 0.8,
178            source: "zeroclaw_fact".into(),
179            content_fp: "fp123".into(),
180            agent_id: "zeroclaw".into(),
181            encrypted_embedding: None,
182            version: 0, // 0 → default (v3)
183        };
184        let encoded = encode_fact_protobuf(&payload);
185        assert!(!encoded.is_empty());
186        // Should contain the string "test-id" somewhere
187        assert!(encoded.windows(7).any(|w| w == b"test-id"));
188    }
189
190    #[test]
191    fn test_encode_fact_protobuf_v3_vs_v4() {
192        let base = FactPayload {
193            id: "test-id".into(),
194            timestamp: "2026-01-01T00:00:00Z".into(),
195            owner: "0xABCD".into(),
196            encrypted_blob_hex: "deadbeef".into(),
197            blind_indices: vec![],
198            decay_score: 0.8,
199            source: "".into(),
200            content_fp: "fp".into(),
201            agent_id: "".into(),
202            encrypted_embedding: None,
203            version: DEFAULT_PROTOBUF_VERSION,
204        };
205        let mut v4 = base.clone();
206        v4.version = PROTOBUF_VERSION_V4;
207        let encoded_v3 = encode_fact_protobuf(&base);
208        let encoded_v4 = encode_fact_protobuf(&v4);
209        assert_ne!(encoded_v3, encoded_v4);
210        // Field 8 tag byte = (8<<3)|0 = 0x40
211        assert!(encoded_v3.windows(2).any(|w| w == [0x40, 3]));
212        assert!(encoded_v4.windows(2).any(|w| w == [0x40, 4]));
213    }
214
215    #[test]
216    fn test_encode_tombstone_protobuf_version() {
217        // Note: encode_tombstone_protobuf uses `chrono::Utc::now()` internally
218        // so successive calls differ on the timestamp bytes. We verify the
219        // version tag byte rather than byte-for-byte equality across calls.
220        let ts_v3 = encode_tombstone_protobuf("id", "0xABCD", DEFAULT_PROTOBUF_VERSION);
221        let ts_v4 = encode_tombstone_protobuf("id", "0xABCD", PROTOBUF_VERSION_V4);
222        let ts_default = encode_tombstone_protobuf("id", "0xABCD", 0);
223        assert!(ts_v3.windows(2).any(|w| w == [0x40, 3]));
224        assert!(ts_v4.windows(2).any(|w| w == [0x40, 4]));
225        // Default (0) → v3 tag
226        assert!(ts_default.windows(2).any(|w| w == [0x40, 3]));
227        // v4 tag not present in v3 output
228        assert!(!ts_v3.windows(2).any(|w| w == [0x40, 4]));
229    }
230}