Skip to main content

dejadb_core/format/
deserialize.rs

1use std::collections::HashMap;
2
3use rmpv::Value;
4
5use crate::error::{DejaDbError, Hash, Result};
6use crate::format::field_map::expand_field;
7use crate::format::header::MgHeader;
8#[allow(clippy::wildcard_imports)]
9use crate::types::*;
10
11/// Unwrap a COSE Sign1 envelope to get the inner .mg blob, if the bytes start with 0x84.
12///
13/// COSE Sign1 is encoded as a CBOR 4-element array, which always starts with 0x84.
14/// Raw .mg blobs start with 0x01 (version byte).
15/// If the input is not COSE-wrapped, returns `None` (caller uses raw bytes directly).
16fn unwrap_if_cose(raw_bytes: &[u8]) -> Result<Option<Vec<u8>>> {
17    if raw_bytes.first() != Some(&0x84) {
18        return Ok(None);
19    }
20    #[cfg(feature = "signing")]
21    {
22        use coset::{CborSerializable, CoseSign1};
23        let cose = CoseSign1::from_slice(raw_bytes)
24            .map_err(|e| DejaDbError::Format(format!("COSE Sign1 decode: {}", e)))?;
25        let inner = cose
26            .payload
27            .ok_or_else(|| DejaDbError::Format("COSE Sign1 envelope has no payload".into()))?;
28        Ok(Some(inner))
29    }
30    #[cfg(not(feature = "signing"))]
31    {
32        Err(DejaDbError::Format(
33            "blob is a COSE Sign1 envelope but the 'signing' feature is not enabled".into(),
34        ))
35    }
36}
37
38/// Deserialized grain data from an .mg blob.
39#[derive(Debug, Clone, serde::Serialize)]
40pub struct DeserializedGrain {
41    pub header: MgHeader,
42    pub grain_type: GrainType,
43    pub fields: HashMap<String, serde_json::Value>,
44    pub hash: Hash,
45}
46
47/// Deserialize an .mg blob into a DeserializedGrain.
48///
49/// Transparently handles COSE Sign1 envelopes: if the bytes start with 0x84,
50/// the inner .mg blob is extracted first (no signature verification — use
51/// `crypto::signing::verify_grain` separately if verification is required).
52pub fn deserialize_blob(blob: &[u8]) -> Result<DeserializedGrain> {
53    // Unwrap COSE Sign1 envelope if present (0x84 = CBOR 4-element array header).
54    // The inner blob is owned; we keep a reference to either the unwrapped or original bytes.
55    let unwrapped: Option<Vec<u8>> = unwrap_if_cose(blob)?;
56    let inner: &[u8] = match unwrapped.as_deref() {
57        Some(b) => b,
58        None => blob,
59    };
60
61    if inner.len() < 10 {
62        return Err(DejaDbError::Format(
63            "blob too short (minimum 10 bytes)".into(),
64        ));
65    }
66    if inner.len() > MAX_GRAIN_BYTES {
67        return Err(DejaDbError::Format(format!(
68            "blob too large ({} bytes, maximum {MAX_GRAIN_BYTES})",
69            inner.len()
70        )));
71    }
72
73    // Parse header
74    let header = MgHeader::from_bytes(&inner[..9])?;
75
76    // Parse grain type
77    let grain_type = GrainType::from_byte(header.grain_type).ok_or_else(|| {
78        DejaDbError::Format(format!("unknown grain type byte: {:#x}", header.grain_type))
79    })?;
80
81    // Parse msgpack payload. Guard the untrusted framing first: this rejects
82    // hostile grains that would otherwise stack-overflow the recursive decoder
83    // (deep nesting) or trigger a giant pre-allocation (a short header claiming
84    // a huge container/string length) before `read_value` ever touches them.
85    let payload = &inner[9..];
86    guard_msgpack_shape(payload)?;
87    let value: Value = rmpv::decode::read_value(&mut &payload[..])
88        .map_err(|e| DejaDbError::Serialization(format!("msgpack decode error: {}", e)))?;
89
90    // Convert to expanded field names
91    let fields = msgpack_to_json_expanded(&value)?;
92    let fields_map = match fields {
93        serde_json::Value::Object(m) => {
94            let mut map = HashMap::with_capacity(m.len().max(20));
95            map.extend(m);
96            map
97        }
98        _ => return Err(DejaDbError::Format("payload must be a map".into())),
99    };
100
101    // Content hash is always SHA-256(inner_blob), not SHA-256(cose_bytes)
102    let hash = crate::format::header::content_address(inner);
103
104    Ok(DeserializedGrain {
105        header,
106        grain_type,
107        fields: fields_map,
108        hash,
109    })
110}
111
112/// Maximum accepted size of a single `.mg` grain payload — defense against
113/// memory exhaustion from a crafted bundle/segment. Grains are small records.
114/// Enforced symmetrically at serialize time so a grain that can be written can
115/// always be read back.
116pub(crate) const MAX_GRAIN_BYTES: usize = 16 * 1024 * 1024;
117
118/// Maximum msgpack container nesting depth accepted from an untrusted blob —
119/// defense against stack overflow in the recursive decoder. Generous relative
120/// to real grains (which are shallow), so it does not reject legitimate data;
121/// enforced symmetrically at serialize time.
122pub(crate) const MAX_MSGPACK_DEPTH: usize = 256;
123
124/// Validate the framing of an untrusted msgpack payload *before* decoding it,
125/// iteratively (no recursion). Rejects two classes of hostile input:
126///
127/// 1. **Nesting deeper than [`MAX_MSGPACK_DEPTH`]** — the decoder
128///    (`rmpv::read_value`) and our own conversion are recursive, so a deeply
129///    nested value could overflow the stack. This walk is iterative and caps
130///    depth before that can happen.
131/// 2. **A container/string/binary whose declared length exceeds the bytes that
132///    remain** — e.g. a 5-byte `array32` header claiming four billion elements,
133///    which would make the decoder pre-allocate a huge buffer. Every declared
134///    length is checked against the actual remaining bytes.
135///
136/// Only the framing (markers + length prefixes) is examined; values are
137/// skipped, so this is O(n) in the payload size with no allocation.
138pub(crate) fn guard_msgpack_shape(buf: &[u8]) -> Result<()> {
139    fn need_bytes(buf: &[u8], pos: usize, n: usize) -> Result<()> {
140        match pos.checked_add(n) {
141            Some(end) if end <= buf.len() => Ok(()),
142            _ => Err(DejaDbError::Format("truncated msgpack payload".into())),
143        }
144    }
145    fn read_be(buf: &[u8], pos: usize, n: usize) -> u64 {
146        let mut v = 0u64;
147        for &b in &buf[pos..pos + n] {
148            v = (v << 8) | b as u64;
149        }
150        v
151    }
152    // Read an `n`-byte big-endian length prefix and advance past it.
153    fn len_prefixed(buf: &[u8], pos: &mut usize, n: usize) -> Result<usize> {
154        need_bytes(buf, *pos, n)?;
155        let v = read_be(buf, *pos, n);
156        *pos += n;
157        Ok(v as usize)
158    }
159    // Advance past `n` payload bytes, checking they are present.
160    fn skip(buf: &[u8], pos: &mut usize, n: usize) -> Result<()> {
161        need_bytes(buf, *pos, n)?;
162        *pos += n;
163        Ok(())
164    }
165    // Push a container of `count` child elements, enforcing depth and rejecting
166    // counts that cannot fit in the remaining bytes (each element needs >= 1).
167    fn push_container(stack: &mut Vec<u64>, buf: &[u8], pos: usize, count: u64) -> Result<()> {
168        if stack.len() >= MAX_MSGPACK_DEPTH {
169            return Err(DejaDbError::Format("msgpack nesting too deep".into()));
170        }
171        if count > (buf.len() - pos) as u64 {
172            return Err(DejaDbError::Format(
173                "msgpack container length exceeds payload".into(),
174            ));
175        }
176        stack.push(count);
177        Ok(())
178    }
179
180    let mut pos = 0usize;
181    // Each stack entry is the number of values still to read at that level.
182    let mut stack: Vec<u64> = vec![1]; // top level: exactly one value
183    while let Some(&top) = stack.last() {
184        if top == 0 {
185            stack.pop();
186            continue;
187        }
188        *stack.last_mut().unwrap() -= 1;
189
190        need_bytes(buf, pos, 1)?;
191        let marker = buf[pos];
192        pos += 1;
193
194        match marker {
195            // fixint (pos/neg), nil, false, true — no payload
196            0x00..=0x7f | 0xe0..=0xff | 0xc0 | 0xc2 | 0xc3 => {}
197            0xc1 => return Err(DejaDbError::Format("invalid msgpack marker 0xc1".into())),
198            // fixstr
199            0xa0..=0xbf => {
200                let len = (marker & 0x1f) as usize;
201                skip(buf, &mut pos, len)?;
202            }
203            // fixmap (0x80..=0x8f, pairs) / fixarray (0x90..=0x9f, elems)
204            0x80..=0x9f => {
205                let n = (marker & 0x0f) as u64;
206                let count = if marker < 0x90 { n * 2 } else { n };
207                push_container(&mut stack, buf, pos, count)?;
208            }
209            // fixed-width scalars
210            0xcc | 0xd0 => skip(buf, &mut pos, 1)?,
211            0xcd | 0xd1 => skip(buf, &mut pos, 2)?,
212            0xca | 0xce | 0xd2 => skip(buf, &mut pos, 4)?,
213            0xcb | 0xcf | 0xd3 => skip(buf, &mut pos, 8)?,
214            // str8/bin8, str16/bin16, str32/bin32
215            0xd9 | 0xc4 => {
216                let l = len_prefixed(buf, &mut pos, 1)?;
217                skip(buf, &mut pos, l)?;
218            }
219            0xda | 0xc5 => {
220                let l = len_prefixed(buf, &mut pos, 2)?;
221                skip(buf, &mut pos, l)?;
222            }
223            0xdb | 0xc6 => {
224                let l = len_prefixed(buf, &mut pos, 4)?;
225                skip(buf, &mut pos, l)?;
226            }
227            // ext8/16/32 (length prefix + 1 type byte + data)
228            0xc7 => {
229                let l = len_prefixed(buf, &mut pos, 1)?;
230                skip(buf, &mut pos, l + 1)?;
231            }
232            0xc8 => {
233                let l = len_prefixed(buf, &mut pos, 2)?;
234                skip(buf, &mut pos, l + 1)?;
235            }
236            0xc9 => {
237                let l = len_prefixed(buf, &mut pos, 4)?;
238                skip(buf, &mut pos, l + 1)?;
239            }
240            // fixext1/2/4/8/16 (1 type byte + N data)
241            0xd4 => skip(buf, &mut pos, 1 + 1)?,
242            0xd5 => skip(buf, &mut pos, 1 + 2)?,
243            0xd6 => skip(buf, &mut pos, 1 + 4)?,
244            0xd7 => skip(buf, &mut pos, 1 + 8)?,
245            0xd8 => skip(buf, &mut pos, 1 + 16)?,
246            // array16 / array32
247            0xdc => {
248                let c = len_prefixed(buf, &mut pos, 2)? as u64;
249                push_container(&mut stack, buf, pos, c)?;
250            }
251            0xdd => {
252                let c = len_prefixed(buf, &mut pos, 4)? as u64;
253                push_container(&mut stack, buf, pos, c)?;
254            }
255            // map16 / map32 (pairs -> 2x elements)
256            0xde => {
257                let c = len_prefixed(buf, &mut pos, 2)? as u64;
258                push_container(&mut stack, buf, pos, c * 2)?;
259            }
260            0xdf => {
261                let c = len_prefixed(buf, &mut pos, 4)? as u64;
262                push_container(&mut stack, buf, pos, c * 2)?;
263            }
264        }
265    }
266    Ok(())
267}
268
269#[cfg(test)]
270mod msgpack_guard_tests {
271    use super::guard_msgpack_shape;
272
273    #[test]
274    fn accepts_small_valid_values() {
275        assert!(guard_msgpack_shape(&[0x80]).is_ok()); // empty map
276        assert!(guard_msgpack_shape(&[0x90]).is_ok()); // empty array
277        assert!(guard_msgpack_shape(&[0xc0]).is_ok()); // nil
278        assert!(guard_msgpack_shape(&[0x81, 0xa1, b'a', 0xa1, b'b']).is_ok()); // {"a":"b"}
279    }
280
281    #[test]
282    fn rejects_excessive_depth() {
283        // Nesting beyond MAX_MSGPACK_DEPTH (256) is rejected.
284        let mut deep = vec![0x91u8; 300];
285        deep.push(0xc0);
286        assert!(guard_msgpack_shape(&deep).is_err());
287    }
288
289    #[test]
290    fn rejects_oversized_container_count() {
291        // array32 / map32 claiming ~4.29e9 elements in a 5-byte header.
292        assert!(guard_msgpack_shape(&[0xdd, 0xff, 0xff, 0xff, 0xff]).is_err());
293        assert!(guard_msgpack_shape(&[0xdf, 0xff, 0xff, 0xff, 0xff]).is_err());
294    }
295
296    #[test]
297    fn rejects_truncated_input() {
298        assert!(guard_msgpack_shape(&[0x92]).is_err()); // array of 2, no elements
299        assert!(guard_msgpack_shape(&[0xa5, b'h', b'i']).is_err()); // str len 5, 2 bytes
300        assert!(guard_msgpack_shape(&[]).is_err()); // needs exactly one value
301    }
302
303    #[test]
304    fn rejects_reserved_marker() {
305        assert!(guard_msgpack_shape(&[0xc1]).is_err());
306    }
307
308    #[test]
309    fn map_element_count_is_pairs_times_two() {
310        // map16 claiming 3 pairs = 6 children but only 5 present: the correct
311        // `pairs * 2` count must reject; pins the container-count arithmetic.
312        assert!(guard_msgpack_shape(&[0xde, 0x00, 0x03, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0]).is_err());
313        // map32 form.
314        assert!(guard_msgpack_shape(
315            &[0xdf, 0x00, 0x00, 0x00, 0x03, 0xc0, 0xc0, 0xc0, 0xc0, 0xc0]
316        )
317        .is_err());
318        // Exactly 2 pairs (4 children) present → accepted.
319        assert!(
320            guard_msgpack_shape(&[0xde, 0x00, 0x02, 0xa1, b'k', 0xc0, 0xa1, b'j', 0xc3]).is_ok()
321        );
322    }
323
324    #[test]
325    fn array_count_is_exact() {
326        // array16 with exactly 3 elements → accepted; one fewer byte → rejected.
327        assert!(guard_msgpack_shape(&[0xdc, 0x00, 0x03, 0xc0, 0xc0, 0xc0]).is_ok());
328        assert!(guard_msgpack_shape(&[0xdc, 0x00, 0x03, 0xc0, 0xc0]).is_err());
329    }
330
331    #[test]
332    fn multibyte_string_lengths_are_read_exactly() {
333        // str16 length 3 as the first of two array elements, then nil: the
334        // length prefix must be read exactly or the second element misaligns.
335        assert!(guard_msgpack_shape(&[0x92, 0xda, 0x00, 0x03, b'a', b'b', b'c', 0xc0]).is_ok());
336        // str16 claiming length 5 with only 3 bytes present → rejected.
337        assert!(guard_msgpack_shape(&[0xda, 0x00, 0x05, b'a', b'b', b'c']).is_err());
338    }
339}
340
341/// Convert a msgpack Value to JSON, expanding field names.
342fn msgpack_to_json_expanded(value: &Value) -> Result<serde_json::Value> {
343    match value {
344        Value::Nil => Ok(serde_json::Value::Null),
345        Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
346        Value::Integer(i) => {
347            if let Some(n) = i.as_i64() {
348                Ok(serde_json::Value::Number(n.into()))
349            } else if let Some(n) = i.as_u64() {
350                Ok(serde_json::Value::Number(n.into()))
351            } else {
352                Err(DejaDbError::Format("integer out of range".into()))
353            }
354        }
355        Value::F32(f) => Ok(serde_json::Value::Number(
356            serde_json::Number::from_f64(*f as f64)
357                .ok_or_else(|| DejaDbError::Format("invalid float".into()))?,
358        )),
359        Value::F64(f) => Ok(serde_json::Value::Number(
360            serde_json::Number::from_f64(*f)
361                .ok_or_else(|| DejaDbError::Format("invalid float (NaN/Inf)".into()))?,
362        )),
363        Value::String(s) => {
364            let str_val = s.as_str().unwrap_or("");
365            Ok(serde_json::Value::String(str_val.to_string()))
366        }
367        Value::Binary(b) => Ok(serde_json::Value::String(hex::encode(b))),
368        Value::Array(arr) => {
369            let items: Result<Vec<serde_json::Value>> =
370                arr.iter().map(msgpack_to_json_expanded).collect();
371            Ok(serde_json::Value::Array(items?))
372        }
373        Value::Map(pairs) => {
374            let mut map = serde_json::Map::new();
375            for (k, v) in pairs {
376                let key = match k {
377                    Value::String(s) => {
378                        let short = s.as_str().unwrap_or("");
379                        expand_field(short).to_string()
380                    }
381                    _ => return Err(DejaDbError::Format("map key must be string".into())),
382                };
383                let val = msgpack_to_json_expanded(v)?;
384                map.insert(key, val);
385            }
386            Ok(serde_json::Value::Object(map))
387        }
388        Value::Ext(_, _) => Err(DejaDbError::Format("ext type not supported".into())),
389    }
390}
391
392impl DeserializedGrain {
393    /// Get a string field value.
394    pub fn get_str(&self, field: &str) -> Option<&str> {
395        self.fields.get(field)?.as_str()
396    }
397
398    /// Base text used by the recall **reranker** (`query::extract_grain_text`
399    /// delegates here). This is a cross-encoder scoring projection, NOT the
400    /// embedding projection — it intentionally differs from the typed
401    /// [`crate::types::Grain::text`] for non-Fact types (it scans a fixed
402    /// content-field list rather than reconstructing each type's `text()`).
403    ///
404    /// `Fact` → `"subject relation object"`; every other type → the first
405    /// non-empty of a fixed content-field list, falling back to the grain-type
406    /// name. Do NOT route the embedding backfill through this — use
407    /// [`Self::embedding_text`], which reconstructs the typed grain and calls
408    /// the real [`crate::types::Grain::embedding_text`] for per-type parity
409    /// with the inline write path.
410    pub fn base_text(&self) -> String {
411        match self.grain_type {
412            GrainType::Fact => {
413                let s = self.get_str("subject").unwrap_or("");
414                let r = self.get_str("relation").unwrap_or("");
415                let o = self.get_str("object").unwrap_or("");
416                format!("{} {} {}", s, r, o).trim().to_string()
417            }
418            _ => {
419                for field in &[
420                    "content",
421                    "description",
422                    "title",
423                    "goal",
424                    "query",
425                    "result",
426                    "output",
427                ] {
428                    if let Some(v) = self.get_str(field) {
429                        if !v.trim().is_empty() {
430                            return v.to_string();
431                        }
432                    }
433                }
434                self.grain_type.as_str().to_string()
435            }
436        }
437    }
438
439    /// Reconstruct the embedding-input text for this stored grain, matching
440    /// [`crate::types::Grain::embedding_text`] of the live typed grain EXACTLY
441    /// so the backfill embeds the SAME text the inline write path embeds
442    /// (`write.rs` calls `grain.embedding_text()`; design §PR-3b step 4 / §R-6).
443    ///
444    /// Parity is guaranteed *by construction*: instead of hand-replicating each
445    /// type's `text()` projection (which silently drifts as types evolve), we
446    /// reconstruct the concrete typed grain from the deserialized fields and
447    /// delegate to its real `Grain::embedding_text()`. That trait method already
448    /// handles the explicit `embedding_text` override and the `text() + [tags] +
449    /// (namespace)` auto-enrichment uniformly for all 11 grain types.
450    ///
451    /// Returns an empty string when there is no text to embed (the caller then
452    /// skips the grain — an empty projection must never be embedded).
453    pub fn embedding_text(&self) -> String {
454        self.reconstruct_for_embedding().embedding_text()
455    }
456
457    /// Populate the three [`GrainCommon`] fields that
458    /// [`crate::types::Grain::embedding_text`] reads — `embedding_text`
459    /// override, `tags`, `namespace` — onto a freshly reconstructed grain.
460    ///
461    /// `GrainCommon.tags` serializes under the long name `structural_tags` (it
462    /// compacts to the wire key `tags`, then expands back to `structural_tags`
463    /// on deserialize — NOT `tags`). Reading the wrong key would silently drop
464    /// the tag enrichment, so the embedding projection diverges from the write
465    /// path. The other GrainCommon fields are irrelevant to `embedding_text()`.
466    fn fill_embedding_common(&self, common: &mut GrainCommon) {
467        if let Some(et) = self.get_str("embedding_text") {
468            common.embedding_text = Some(et.to_string());
469        }
470        if let Some(ns) = self.get_str("namespace") {
471            common.namespace = Some(ns.to_string());
472        }
473        if let Some(arr) = self
474            .fields
475            .get("structural_tags")
476            .and_then(|v| v.as_array())
477        {
478            common.tags = arr
479                .iter()
480                .filter_map(|t| t.as_str().map(str::to_string))
481                .collect();
482        }
483    }
484
485    /// Reconstruct a concrete typed grain carrying exactly the fields that feed
486    /// [`crate::types::Grain::embedding_text`]: each type's `text()` inputs plus
487    /// the three common fields populated by [`Self::fill_embedding_common`].
488    /// Used only to derive embedding text — not a full round-trip reconstruction
489    /// (see `to_fact`/`to_event`/`to_tool`/`to_skill` for those).
490    fn reconstruct_for_embedding(&self) -> Box<dyn Grain> {
491        let s = |f: &str| self.get_str(f).unwrap_or("").to_string();
492        let mut grain: Box<dyn Grain> = match self.grain_type {
493            GrainType::Fact => Box::new(Fact::new(&s("subject"), &s("relation"), &s("object"))),
494            GrainType::Event => Box::new(Event::new(&s("content"))),
495            GrainType::State => {
496                let context = self
497                    .fields
498                    .get("context")
499                    .cloned()
500                    .unwrap_or(serde_json::Value::Null);
501                Box::new(State::new(context))
502            }
503            GrainType::Workflow => {
504                let nodes = self
505                    .fields
506                    .get("nodes")
507                    .and_then(|v| v.as_array())
508                    .map(|arr| {
509                        arr.iter()
510                            .filter_map(|n| n.as_str().map(str::to_string))
511                            .collect()
512                    })
513                    .unwrap_or_default();
514                let mut wf = Workflow::new(nodes);
515                if let Some(t) = self.get_str("trigger") {
516                    wf = wf.trigger(t);
517                }
518                Box::new(wf)
519            }
520            GrainType::Tool => {
521                let mut tool = Tool::new(&s("tool_name"));
522                // Tool's content compacts to "cnt" → expands to "tool_content".
523                if let Some(c) = self
524                    .get_str("tool_content")
525                    .or_else(|| self.get_str("content"))
526                {
527                    tool.content = Some(c.to_string());
528                }
529                Box::new(tool)
530            }
531            GrainType::Observation => {
532                let mut obs = Observation::new(&s("observer_id"), &s("observer_type"));
533                if let Some(subj) = self.get_str("subject") {
534                    obs = obs.subject(subj);
535                }
536                if let Some(obj) = self.get_str("object") {
537                    obs = obs.object(obj);
538                }
539                Box::new(obs)
540            }
541            GrainType::Goal => {
542                let mut goal = Goal::new(&s("description"));
543                if let Some(c) = self.get_str("criteria") {
544                    goal.criteria = Some(c.to_string());
545                }
546                Box::new(goal)
547            }
548            GrainType::Reasoning => {
549                let mut r = Reasoning::new();
550                if let Some(c) = self.get_str("conclusion") {
551                    r.conclusion = Some(c.to_string());
552                }
553                if let Some(t) = self.get_str("thinking_content") {
554                    r.thinking_content = Some(t.to_string());
555                }
556                Box::new(r)
557            }
558            GrainType::Consensus => {
559                let mut c = Consensus::new();
560                if let Some(ac) = self.get_str("agreed_content") {
561                    c.agreed_content = Some(ac.to_string());
562                }
563                c.agreement_count = self.get_i64("agreement_count");
564                c.threshold = self.get_f64("threshold");
565                Box::new(c)
566            }
567            GrainType::Consent => {
568                let mut c = Consent::new(&s("subject_did"));
569                if let Some(g) = self.get_str("grantee_did") {
570                    c.grantee_did = Some(g.to_string());
571                }
572                c.is_withdrawal = self.get_bool("is_withdrawal");
573                Box::new(c)
574            }
575            GrainType::Skill => {
576                let mut sk = Skill::new(&s("name"), &s("description"));
577                if let Some(w) = self.get_str("when_to_use") {
578                    sk.when_to_use = Some(w.to_string());
579                }
580                if let Some(d) = self.get_str("domain") {
581                    sk.domain = Some(d.to_string());
582                }
583                Box::new(sk)
584            }
585        };
586        self.fill_embedding_common(grain.common_mut());
587        grain
588    }
589
590    /// Get an i64 field value.
591    pub fn get_i64(&self, field: &str) -> Option<i64> {
592        self.fields.get(field)?.as_i64()
593    }
594
595    /// Get an f64 field value.
596    pub fn get_f64(&self, field: &str) -> Option<f64> {
597        self.fields.get(field)?.as_f64()
598    }
599
600    /// Get a bool field value.
601    pub fn get_bool(&self, field: &str) -> Option<bool> {
602        self.fields.get(field)?.as_bool()
603    }
604
605    /// Get a u64 field value.
606    pub fn get_u64(&self, field: &str) -> Option<u64> {
607        self.fields.get(field)?.as_u64()
608    }
609
610    /// Reconstruct a Fact from deserialized fields.
611    pub fn to_fact(&self) -> Result<Fact> {
612        if self.grain_type != GrainType::Fact {
613            return Err(DejaDbError::Validation("not a Fact grain".into()));
614        }
615        let subject = self
616            .get_str("subject")
617            .ok_or_else(|| DejaDbError::Validation("missing subject".into()))?;
618        let relation = self
619            .get_str("relation")
620            .ok_or_else(|| DejaDbError::Validation("missing relation".into()))?;
621        let object = self
622            .get_str("object")
623            .ok_or_else(|| DejaDbError::Validation("missing object".into()))?;
624
625        let mut fact = Fact::new(subject, relation, object);
626
627        if let Some(c) = self.get_f64("confidence") {
628            fact.common.confidence = c;
629        }
630        if let Some(ns) = self.get_str("namespace") {
631            fact.common.namespace = Some(ns.to_string());
632        }
633        if let Some(uid) = self.get_str("user_id") {
634            fact.common.user_id = Some(uid.to_string());
635        }
636        if let Some(st) = self.get_str("source_type") {
637            fact.common.source_type = Some(st.to_string());
638        }
639        if let Some(ca) = self.get_i64("created_at") {
640            fact.common.created_at = Some(ca);
641        }
642        if let Some(adid) = self.get_str("author_did") {
643            fact.common.author_did = Some(adid.to_string());
644        }
645        if let Some(et) = self.get_str("embedding_text") {
646            fact.common.embedding_text = Some(et.to_string());
647        }
648
649        Ok(fact)
650    }
651
652    /// Reconstruct an Tool from deserialized fields. Phase 1 (2026-04-19):
653    /// reads typed definition fields (`input_schema`, `executor_uri`,
654    /// `locked_params`, `examples`, `annotations`, `spec_hash`,
655    /// `tool_description`, `strict`, `tool_call_id`, `call_batch_id`,
656    /// `kind`) plus existing execution-record fields (`tool_name`, `input`,
657    /// `content`/`tool_content`, `is_error`, `error`, `duration_ms`,
658    /// `parent_task_id`, `output_schema`).
659    ///
660    /// `kind` defaults to `Execution` when absent — preserves
661    /// backward-compatibility for any pre-Phase-1 grain that lacks the
662    /// discriminator.
663    pub fn to_tool(&self) -> Result<Tool> {
664        if self.grain_type != GrainType::Tool {
665            return Err(DejaDbError::Validation("not an Tool grain".into()));
666        }
667        let tool_name = self.get_str("tool_name").unwrap_or("").to_string();
668        let mut a = Tool::new(&tool_name);
669
670        if let Some(k) = self.get_str("kind").and_then(ToolKind::parse) {
671            a.kind = k;
672        }
673        if let Some(v) = self.fields.get("input") {
674            a.input = Some(v.clone());
675        }
676        // Tool's content compacts to "cnt" but expands back to "tool_content".
677        if let Some(s) = self
678            .get_str("tool_content")
679            .or_else(|| self.get_str("content"))
680        {
681            a.content = Some(s.to_string());
682        }
683        if let Some(b) = self.get_bool("is_error") {
684            a.is_error = Some(b);
685        }
686        if let Some(s) = self.get_str("error") {
687            a.error = Some(s.to_string());
688        }
689        if let Some(n) = self.get_u64("duration_ms") {
690            a.duration_ms = Some(n);
691        }
692        if let Some(s) = self.get_str("parent_task_id") {
693            a.parent_task_id = Some(s.to_string());
694        }
695        if let Some(s) = self.get_str("tool_call_id") {
696            a.tool_call_id = Some(s.to_string());
697        }
698        if let Some(s) = self.get_str("call_batch_id") {
699            a.call_batch_id = Some(s.to_string());
700        }
701        if let Some(s) = self.get_str("tool_description") {
702            a.tool_description = Some(s.to_string());
703        }
704        if let Some(v) = self.fields.get("input_schema") {
705            a.input_schema = Some(v.clone());
706        }
707        if let Some(v) = self.fields.get("output_schema") {
708            a.output_schema = Some(v.clone());
709        }
710        if let Some(b) = self.get_bool("strict") {
711            a.strict = Some(b);
712        }
713        if let Some(b) = self.get_bool("async_mode") {
714            a.async_mode = Some(b);
715        }
716        if let Some(s) = self.get_str("executor_uri") {
717            a.executor_uri = Some(s.to_string());
718        }
719        if let Some(v) = self.fields.get("locked_params") {
720            a.locked_params = Some(v.clone());
721        }
722        if let Some(arr) = self.fields.get("examples").and_then(|v| v.as_array()) {
723            a.examples = Some(arr.clone());
724        }
725        if let Some(v) = self.fields.get("annotations") {
726            if let Ok(anno) = serde_json::from_value::<ToolAnnotations>(v.clone()) {
727                a.annotations = Some(anno);
728            }
729        }
730        if let Some(s) = self.get_str("spec_hash") {
731            a.spec_hash = Some(s.to_string());
732        }
733        // HPL Phase 4.1: executor_kind. Absent → None; deserialize
734        // defaults to None, dispatch treats None as Host. Unknown wire
735        // strings are ignored (rather than erroring) so blobs authored
736        // by a newer code version remain readable by older cells.
737        if let Some(k) = self
738            .get_str("executor_kind")
739            .and_then(crate::types::executor_kind::ExecutorKind::parse)
740        {
741            a.executor_kind = Some(k);
742        }
743        // Async exec lifecycle. Absent fields stay None;
744        // `status=None` is treated as `Completed` at every read site.
745        // Unknown wire strings are ignored (forward-compat with newer
746        // cells that may emit additional variants).
747        if let Some(s) = self.get_str("status").and_then(ExecutionStatus::parse) {
748            a.status = Some(s);
749        }
750        if let Some(s) = self.get_str("correlation_id") {
751            a.correlation_id = Some(s.to_string());
752        }
753        if let Some(n) = self.get_i64("expires_at_sec") {
754            a.expires_at_sec = Some(n);
755        }
756        if let Some(hex_str) = self.get_str("transient_definition_hash") {
757            if let Ok(bytes) = hex::decode(hex_str) {
758                if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) {
759                    a.transient_definition_hash = Some(arr);
760                }
761            }
762        }
763        if let Some(fc) = self.get_str("failure_cause").and_then(FailureCause::parse) {
764            a.failure_cause = Some(fc);
765        }
766        if let Some(s) = self.get_str("failure_detail") {
767            a.failure_detail = Some(s.to_string());
768        }
769        if let Some(aex) = self
770            .get_str("actor_execution_environment")
771            .and_then(ActorExecutionEnvironment::parse)
772        {
773            a.actor_execution_environment = Some(aex);
774        }
775
776        // Common fields.
777        if let Some(c) = self.get_f64("confidence") {
778            a.common.confidence = c;
779        }
780        if let Some(ns) = self.get_str("namespace") {
781            a.common.namespace = Some(ns.to_string());
782        }
783        if let Some(uid) = self.get_str("user_id") {
784            a.common.user_id = Some(uid.to_string());
785        }
786        if let Some(st) = self.get_str("source_type") {
787            a.common.source_type = Some(st.to_string());
788        }
789        if let Some(ca) = self.get_i64("created_at") {
790            a.common.created_at = Some(ca);
791        }
792        if let Some(adid) = self.get_str("author_did") {
793            a.common.author_did = Some(adid.to_string());
794        }
795
796        Ok(a)
797    }
798
799    /// Reconstruct an Event from deserialized fields, including the
800    /// OMS 1.2 §8.2 chat-extension fields (role, session_id,
801    /// parent_message_id, content_blocks, model_id, stop_reason,
802    /// token_usage, run_id).
803    pub fn to_event(&self) -> Result<Event> {
804        if self.grain_type != GrainType::Event {
805            return Err(DejaDbError::Validation("not an Event grain".into()));
806        }
807        let content = self.get_str("content").unwrap_or("").to_string();
808        let mut ev = Event::new(&content);
809
810        if let Some(s) = self.get_str("subject") {
811            ev.subject = Some(s.to_string());
812        }
813        if let Some(o) = self.get_str("object") {
814            ev.object = Some(o.to_string());
815        }
816        if let Some(r) = self.get_str("role").and_then(Role::from_str) {
817            ev.role = Some(r);
818        }
819        if let Some(s) = self.get_str("session_id") {
820            ev.session_id = Some(s.to_string());
821        }
822        if let Some(p) = self.get_str("parent_message_id") {
823            ev.parent_message_id = Some(p.to_string());
824        }
825        if let Some(m) = self.get_str("model_id") {
826            ev.model_id = Some(m.to_string());
827        }
828        if let Some(sr) = self.get_str("stop_reason") {
829            ev.stop_reason = Some(sr.to_string());
830        }
831        if let Some(rid) = self.get_str("run_id") {
832            ev.run_id = Some(rid.to_string());
833        }
834        if let Some(blocks_json) = self.fields.get("content_blocks") {
835            if let Ok(blocks) = serde_json::from_value(blocks_json.clone()) {
836                ev.content_blocks = Some(blocks);
837            }
838        }
839        if let Some(tu_json) = self.fields.get("token_usage") {
840            if let Ok(tu) = serde_json::from_value(tu_json.clone()) {
841                ev.token_usage = Some(tu);
842            }
843        }
844
845        // Common fields (mirror to_fact).
846        if let Some(c) = self.get_f64("confidence") {
847            ev.common.confidence = c;
848        }
849        if let Some(ns) = self.get_str("namespace") {
850            ev.common.namespace = Some(ns.to_string());
851        }
852        if let Some(uid) = self.get_str("user_id") {
853            ev.common.user_id = Some(uid.to_string());
854        }
855        if let Some(st) = self.get_str("source_type") {
856            ev.common.source_type = Some(st.to_string());
857        }
858        if let Some(ca) = self.get_i64("created_at") {
859            ev.common.created_at = Some(ca);
860        }
861        if let Some(adid) = self.get_str("author_did") {
862            ev.common.author_did = Some(adid.to_string());
863        }
864        if let Some(et) = self.get_str("embedding_text") {
865            ev.common.embedding_text = Some(et.to_string());
866        }
867
868        Ok(ev)
869    }
870
871    /// Reconstruct a Skill from deserialized fields (OMS 1.4 §8.11).
872    ///
873    /// `proficiency` aliases `common.confidence` (D3): we read `prof` if
874    /// present, else fall back to `cf` (`confidence`), and write the result
875    /// into `common.confidence`. A writer that emits `prof ≠ cf`
876    /// is taken at its `prof` word for a Skill (spec is SHOULD, not MUST)
877    /// with a debug log — the only place the two could disagree.
878    pub fn to_skill(&self) -> Result<Skill> {
879        if self.grain_type != GrainType::Skill {
880            return Err(DejaDbError::Validation("not a Skill grain".into()));
881        }
882        let name = self.get_str("name").unwrap_or("").to_string();
883        let description = self.get_str("description").unwrap_or("").to_string();
884        let mut s = Skill::new(&name, &description);
885
886        if let Some(instr) = self.get_str("instructions") {
887            s.instructions = Some(instr.to_string());
888        }
889        if let Some(wtu) = self.get_str("when_to_use") {
890            s.when_to_use = Some(wtu.to_string());
891        }
892        if let Some(v) = self.get_str("version") {
893            s.version = Some(v.to_string());
894        }
895        if let Some(arr) = self.fields.get("allowed_tools").and_then(|v| v.as_array()) {
896            s.allowed_tools = arr
897                .iter()
898                .filter_map(|v| v.as_str().map(str::to_string))
899                .collect();
900        }
901        if let Some(arr) = self.fields.get("resources").and_then(|v| v.as_array()) {
902            s.resources = arr
903                .iter()
904                .filter_map(|v| v.as_str().map(str::to_string))
905                .collect();
906        }
907        if let Some(arr) = self.fields.get("dependencies").and_then(|v| v.as_array()) {
908            s.dependencies = arr
909                .iter()
910                .filter_map(|v| v.as_str().map(str::to_string))
911                .collect();
912        }
913        if let Some(arr) = self
914            .fields
915            .get("input_modalities")
916            .and_then(|v| v.as_array())
917        {
918            s.input_modalities = arr
919                .iter()
920                .filter_map(|v| v.as_str().map(str::to_string))
921                .collect();
922        }
923        if let Some(arr) = self
924            .fields
925            .get("output_modalities")
926            .and_then(|v| v.as_array())
927        {
928            s.output_modalities = arr
929                .iter()
930                .filter_map(|v| v.as_str().map(str::to_string))
931                .collect();
932        }
933        if let Some(dom) = self.get_str("domain") {
934            s.domain = Some(dom.to_string());
935        }
936        if let Some(hdid) = self.get_str("holder_did") {
937            s.holder_did = Some(hdid.to_string());
938        }
939        if let Some(pc) = self.get_u64("practice_count") {
940            s.practice_count = Some(pc as u32);
941        }
942        if let Some(lpa) = self.get_i64("last_practiced_at") {
943            s.last_practiced_at = Some(lpa);
944        }
945        if let Some(strat_json) = self.fields.get("strategies") {
946            if let Ok(strats) = serde_json::from_value(strat_json.clone()) {
947                s.strategies = strats;
948            }
949        }
950        if let Some(xfer) = self.get_bool("transferable") {
951            s.transferable = Some(xfer);
952        }
953
954        // Common fields.
955        if let Some(ns) = self.get_str("namespace") {
956            s.common.namespace = Some(ns.to_string());
957        }
958        if let Some(uid) = self.get_str("user_id") {
959            s.common.user_id = Some(uid.to_string());
960        }
961        if let Some(st) = self.get_str("source_type") {
962            s.common.source_type = Some(st.to_string());
963        }
964        if let Some(ca) = self.get_i64("created_at") {
965            s.common.created_at = Some(ca);
966        }
967        if let Some(adid) = self.get_str("author_did") {
968            s.common.author_did = Some(adid.to_string());
969        }
970        if let Some(odid) = self.get_str("origin_did") {
971            s.common.origin_did = Some(odid.to_string());
972        }
973        if let Some(df) = self.get_str("derived_from") {
974            s.common.derived_from = Some(df.to_string());
975        }
976        if let Some(et) = self.get_str("embedding_text") {
977            s.common.embedding_text = Some(et.to_string());
978        }
979
980        // proficiency aliases confidence (D3). `prof` wins over `cf` when
981        // both are present and disagree (inbound-interop edge only).
982        let cf = self.get_f64("confidence");
983        let prof = self.get_f64("proficiency");
984        match (prof, cf) {
985            (Some(p), Some(c)) if (p - c).abs() > f64::EPSILON => {
986                tracing::debug!(
987                    proficiency = p,
988                    confidence = c,
989                    "skill prof != cf on deserialize; taking prof as authoritative (D3)"
990                );
991                s.common.confidence = p;
992            }
993            (Some(p), _) => s.common.confidence = p,
994            (None, Some(c)) => s.common.confidence = c,
995            (None, None) => {}
996        }
997
998        Ok(s)
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use crate::format::serialize::serialize_grain;
1006
1007    /// R-6 / AC-2c (actual behavior, NOT as the design literally states) —
1008    /// PIN the true Unicode-composition contract of the existing serializer so a
1009    /// future change to NFC placement is caught.
1010    ///
1011    /// The design doc (§R-6, AC-2c) claims two grains differing only by Unicode
1012    /// composition form "still hash differently (different bytes → different
1013    /// grains)". That is FALSE against the current code: `serialize_grain`
1014    /// applies `nfc_string()` to the canonical field bytes BEFORE the hash, so
1015    /// two composition forms of the same logical text collapse to identical
1016    /// canonical bytes → identical content hash → they dedup as ONE grain. This
1017    /// test documents the real behavior. (Flagged to architect/PM — see the
1018    /// tester report. PR-3's own NFC projection at the embedding/query
1019    /// consumption sites is unaffected and correct either way.)
1020    #[test]
1021    fn test_nfc_composition_forms_collapse_to_same_hash() {
1022        // Same created_at + namespace so the 9-byte header is identical and only
1023        // the payload bytes (the object field) could differ.
1024        let composed = Fact::new("u", "likes", "caf\u{00e9}").created_at(1_700_000_000_000); // U+00E9
1025        let decomposed = Fact::new("u", "likes", "cafe\u{0301}").created_at(1_700_000_000_000); // e + U+0301
1026        let (b1, h1) = serialize_grain(&composed).unwrap();
1027        let (b2, h2) = serialize_grain(&decomposed).unwrap();
1028        assert_eq!(
1029            h1, h2,
1030            "serializer NFC-normalizes field bytes pre-hash, so composition \
1031             variants collapse to one content address (design §R-6 claims the \
1032             opposite — flagged)"
1033        );
1034        assert_eq!(b1, b2, "canonical bytes are byte-identical after NFC");
1035    }
1036
1037    #[test]
1038    fn test_roundtrip_fact() {
1039        let fact = Fact::new("john", "likes", "coffee")
1040            .confidence(0.95)
1041            .namespace("test")
1042            .source_type("user_explicit")
1043            .created_at(1768471200000);
1044
1045        let (blob, _hash) = serialize_grain(&fact).unwrap();
1046        let deserialized = deserialize_blob(&blob).unwrap();
1047
1048        assert_eq!(deserialized.grain_type, GrainType::Fact);
1049        assert_eq!(deserialized.get_str("subject"), Some("john"));
1050        assert_eq!(deserialized.get_str("relation"), Some("likes"));
1051        assert_eq!(deserialized.get_str("object"), Some("coffee"));
1052        assert_eq!(deserialized.get_f64("confidence"), Some(0.95));
1053        assert_eq!(deserialized.get_str("namespace"), Some("test"));
1054    }
1055
1056    /// PR-3b [C-R BLOCKING fix] — `DeserializedGrain::embedding_text()` must
1057    /// reproduce EXACTLY what the live `Grain::embedding_text()` projects for
1058    /// ALL 11 grain types, so the backfill embeds the same text the inline write
1059    /// path (`write.rs`) would have. Build one grain of each type, serialize via
1060    /// the real write projection, deserialize, and compare round-trip.
1061    #[test]
1062    fn test_embedding_text_matches_grain_trait_all_types() {
1063        use crate::types::{
1064            Consensus, Consent, Event, Goal, Grain, Observation, Reasoning, Skill, State, Tool,
1065            Workflow,
1066        };
1067
1068        /// Assert the deserialized embedding text equals the typed grain's
1069        /// `embedding_text()` (parity by construction). Also guards against the
1070        /// empty-projection trap where both sides are "" (which would pass
1071        /// trivially but means nothing was embedded).
1072        fn assert_parity<G: Grain + 'static>(g: G) {
1073            let expected = g.embedding_text();
1074            let (blob, _h) = serialize_grain(&g).unwrap();
1075            let dg = deserialize_blob(&blob).unwrap();
1076            assert_eq!(
1077                dg.embedding_text(),
1078                expected,
1079                "embedding_text parity failed for {:?}",
1080                g.grain_type()
1081            );
1082            assert!(
1083                !expected.trim().is_empty(),
1084                "test grain for {:?} produced empty embedding text",
1085                g.grain_type()
1086            );
1087        }
1088
1089        // 1. Fact — "subject relation object".
1090        assert_parity(Fact::new("john", "likes", "coffee").namespace("prefs"));
1091        // 2. Event — content.
1092        assert_parity(Event::new("user discussed vacation plans").tags(["travel"]));
1093        // 3. State — text() reads context_data keys (label/description/title/name).
1094        assert_parity(State::new(
1095            serde_json::json!({ "label": "checkpoint-7", "extra": 42 }),
1096        ));
1097        // 4. Workflow — "trigger | n1 -> n2".
1098        assert_parity(Workflow::new(vec!["plan".into(), "execute".into()]).trigger("on_request"));
1099        // 5. Tool — "tool_name content" (current base_text drops tool_name).
1100        assert_parity(Tool::new("calculator").content("computed 42"));
1101        // 6. Observation — "observer_id observer_type subject object".
1102        assert_parity(
1103            Observation::new("agent-1", "vision")
1104                .subject("scene")
1105                .object("cat"),
1106        );
1107        // 7. Goal — description + criteria (current base_text drops criteria).
1108        let mut goal = Goal::new("ship trilingual recall");
1109        goal.criteria = Some("all 11 types embed".into());
1110        assert_parity(goal);
1111        // 8. Reasoning — conclusion (falls back to thinking_content).
1112        assert_parity(Reasoning::new().conclusion("therefore X holds"));
1113        // 9. Consensus — agreed_content.
1114        let mut consensus = Consensus::new();
1115        consensus.agreed_content = Some("ratified the plan".into());
1116        assert_parity(consensus);
1117        // 10. Consent — "subj grants/withdraws grantee".
1118        let mut consent = Consent::new("did:key:subjectA");
1119        consent.grantee_did = Some("did:key:granteeB".into());
1120        assert_parity(consent);
1121        // 11. Skill — "name: description — when_to_use [domain]".
1122        assert_parity(
1123            Skill::new("code_review", "review code for defects")
1124                .when_to_use("before merge")
1125                .domain("swe"),
1126        );
1127    }
1128
1129    /// PR-3b — the enriched composed SHAPE and the explicit-override branch.
1130    /// Pins the exact string so a future refactor cannot silently change the
1131    /// `text() + [tags] + (namespace)` join.
1132    #[test]
1133    fn test_embedding_text_enrichment_and_override() {
1134        use crate::types::Grain;
1135
1136        // (a) Enriched path: Fact base text + tags + namespace.
1137        let fact = Fact::new("john", "likes", "coffee")
1138            .namespace("prefs")
1139            .tags(["a", "b"]);
1140        let (blob, _h) = serialize_grain(&fact).unwrap();
1141        let dg = deserialize_blob(&blob).unwrap();
1142        assert_eq!(dg.embedding_text(), fact.embedding_text());
1143        assert_eq!(dg.embedding_text(), "john likes coffee [a, b] (prefs)");
1144
1145        // (b) Explicit override wins over the enriched base text.
1146        let mut fact2 = Fact::new("a", "b", "c");
1147        fact2.common_mut().embedding_text = Some("rich custom context".to_string());
1148        let (blob2, _h2) = serialize_grain(&fact2).unwrap();
1149        let dg2 = deserialize_blob(&blob2).unwrap();
1150        assert_eq!(dg2.embedding_text(), "rich custom context");
1151        assert_eq!(dg2.embedding_text(), fact2.embedding_text());
1152    }
1153
1154    /// PR-3b [C-R BLOCKING] — the reranker `base_text()` projection (consumed by
1155    /// `query::extract_grain_text`) is DECOUPLED from `embedding_text()` and must
1156    /// keep its current `text()`-only-via-content-field-list behavior. This pins
1157    /// the divergence so the embed-parity fix did NOT regress the reranker input.
1158    #[test]
1159    fn test_base_text_reranker_projection_unchanged() {
1160        // Tool: the reranker scans the fixed content-field list, which does NOT
1161        // include Tool's `tool_content` key (Tool's content serializes under
1162        // `cnt`/`tool_content`, not `content`), so base_text() falls back to the
1163        // grain-type name "tool". The embedding projection, by contrast, routes
1164        // through Tool::text() = "tool_name content" and includes "calculator".
1165        // The two MUST differ — confirms base_text() was not retargeted onto the
1166        // typed text().
1167        let tool = Tool::new("calculator").content("computed 42");
1168        let (blob, _h) = serialize_grain(&tool).unwrap();
1169        let dg = deserialize_blob(&blob).unwrap();
1170        assert_eq!(dg.base_text(), "tool");
1171        assert!(dg.embedding_text().contains("calculator"));
1172        assert!(dg.embedding_text().contains("computed 42"));
1173        assert_ne!(dg.base_text(), dg.embedding_text());
1174
1175        // State: reranker falls back to the grain-type name (its content lives
1176        // under `context`, not a scanned top-level field) — embedding reads the
1177        // context label. Confirms base_text() was NOT changed to read context.
1178        let state = State::new(serde_json::json!({ "label": "checkpoint-7" }));
1179        let (sblob, _sh) = serialize_grain(&state).unwrap();
1180        let sdg = deserialize_blob(&sblob).unwrap();
1181        assert_eq!(sdg.base_text(), "state");
1182        assert_eq!(sdg.embedding_text(), "checkpoint-7");
1183
1184        // Fact: base_text() == "subject relation object" (unchanged).
1185        let fact = Fact::new("john", "likes", "coffee");
1186        let (fblob, _fh) = serialize_grain(&fact).unwrap();
1187        let fdg = deserialize_blob(&fblob).unwrap();
1188        assert_eq!(fdg.base_text(), "john likes coffee");
1189    }
1190
1191    /// HPL Phase 4.1 — `executor_kind` survives the .mg blob round-trip.
1192    /// `Client` is the non-default variant, so this pins the compact
1193    /// key `exk` + lowercase wire string in both serialize + deserialize.
1194    #[test]
1195    fn test_tool_executor_kind_round_trip() {
1196        use crate::types::executor_kind::ExecutorKind;
1197        let tool = Tool::new("slack.post").executor_kind(ExecutorKind::Client);
1198        let (blob, _hash) = serialize_grain(&tool).unwrap();
1199        let dg = deserialize_blob(&blob).unwrap();
1200        assert_eq!(dg.get_str("executor_kind"), Some("client"));
1201        let back = dg.to_tool().unwrap();
1202        assert_eq!(back.executor_kind, Some(ExecutorKind::Client));
1203    }
1204
1205    /// Legacy grains without `executor_kind` default to `None` on
1206    /// deserialize (dispatch treats that as Host). The default
1207    /// Host variant is also skipped at serialize time, so a grain
1208    /// authored with `ExecutorKind::Host` still has no `exk` key in
1209    /// the blob — legacy callers see identical bytes.
1210    #[test]
1211    fn test_action_executor_kind_default_host_omits_field() {
1212        use crate::types::executor_kind::ExecutorKind;
1213        let tool = Tool::new("slack.post").executor_kind(ExecutorKind::Host);
1214        let (blob, _hash) = serialize_grain(&tool).unwrap();
1215        let dg = deserialize_blob(&blob).unwrap();
1216        assert_eq!(dg.get_str("executor_kind"), None);
1217        let back = dg.to_tool().unwrap();
1218        assert_eq!(back.executor_kind, None);
1219    }
1220
1221    #[test]
1222    fn test_get_bool_roundtrip_tool() {
1223        let tool = Tool::new("calculator")
1224            .is_error(true)
1225            .duration_ms(250)
1226            .error("divide by zero");
1227
1228        let (blob, _hash) = serialize_grain(&tool).unwrap();
1229        let deserialized = deserialize_blob(&blob).unwrap();
1230
1231        assert_eq!(deserialized.grain_type, GrainType::Tool);
1232        assert_eq!(deserialized.get_bool("is_error"), Some(true));
1233        assert_eq!(deserialized.get_u64("duration_ms"), Some(250));
1234        assert_eq!(deserialized.get_str("error"), Some("divide by zero"));
1235        assert_eq!(deserialized.get_str("tool_name"), Some("calculator"));
1236    }
1237
1238    #[test]
1239    fn test_get_bool_returns_none_for_missing_field() {
1240        let fact = Fact::new("john", "likes", "coffee");
1241        let (blob, _hash) = serialize_grain(&fact).unwrap();
1242        let deserialized = deserialize_blob(&blob).unwrap();
1243
1244        // Facts don't have is_error — should return None, not panic
1245        assert_eq!(deserialized.get_bool("is_error"), None);
1246        assert_eq!(deserialized.get_u64("duration_ms"), None);
1247    }
1248
1249    #[test]
1250    fn test_get_bool_false_roundtrip() {
1251        let tool = Tool::new("browser").is_error(false);
1252        let (blob, _hash) = serialize_grain(&tool).unwrap();
1253        let deserialized = deserialize_blob(&blob).unwrap();
1254
1255        assert_eq!(deserialized.get_bool("is_error"), Some(false));
1256    }
1257}