Skip to main content

dejadb_core/format/
serialize.rs

1use std::collections::BTreeMap;
2
3use rmpv::Value;
4use unicode_normalization::UnicodeNormalization;
5
6use crate::error::{DejaDbError, Hash, Result};
7use crate::format::field_map::{
8    compact_content_ref_field, compact_embedding_ref_field, compact_field,
9    compact_related_to_field, compact_workflow_edge_field,
10};
11use crate::format::header::{content_address, MgHeader};
12#[allow(clippy::wildcard_imports)]
13use crate::types::*;
14
15/// Encode an rmpv::Value to bytes.
16fn encode_value_to_vec(value: &Value) -> Result<Vec<u8>> {
17    let mut buf = Vec::new();
18    rmpv::encode::write_value(&mut buf, value)
19        .map_err(|e| DejaDbError::Serialization(format!("msgpack encode error: {}", e)))?;
20    Ok(buf)
21}
22
23/// Serialize a grain to .mg blob bytes and compute its content address.
24/// Returns (blob_bytes, content_address_hash).
25pub fn serialize_grain<G: Grain + 'static>(grain: &G) -> Result<(Vec<u8>, Hash)> {
26    let common = grain.common();
27    let created_at = common
28        .created_at
29        .unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
30
31    let mut map = BTreeMap::<String, Value>::new();
32
33    // Type field
34    map.insert(
35        compact_field("type").to_string(),
36        Value::String(grain.grain_type().as_str().into()),
37    );
38
39    // Add grain-type-specific fields
40    add_type_specific_fields(grain, &mut map);
41
42    // Add common fields
43    add_common_fields(common, created_at, &mut map);
44
45    // Encode to msgpack
46    let msgpack_value = btree_to_msgpack_map(map);
47    let payload = encode_value_to_vec(&msgpack_value)?;
48
49    // Build header
50    let mut header = MgHeader::new(grain.grain_type(), common.namespace.as_deref(), created_at);
51    if !common.content_refs.is_empty() {
52        header.set_has_content_refs(true);
53    }
54    if !common.embedding_refs.is_empty() {
55        header.set_has_embedding_refs(true);
56    }
57    if let Some(ref st) = common.source_type {
58        match st.as_str() {
59            "llm_generated" | "consolidated" | "inferred" | "a2a_recalled" => {
60                header.set_ai_generated(true);
61            }
62            _ => {}
63        }
64    }
65    header.set_sensitivity(detect_sensitivity(&common.tags));
66
67    // Assemble blob: header + payload
68    let header_bytes = header.to_bytes();
69    let mut blob = Vec::with_capacity(9 + payload.len());
70    blob.extend_from_slice(&header_bytes);
71    blob.extend_from_slice(&payload);
72
73    // Symmetry guard: never persist a grain we could not read back. Enforce the
74    // same size + framing limits the deserializer applies to untrusted blobs, so
75    // a successful write always round-trips (see deserialize::guard_msgpack_shape).
76    if blob.len() > crate::format::deserialize::MAX_GRAIN_BYTES {
77        return Err(crate::error::DejaDbError::Format(format!(
78            "grain too large ({} bytes, maximum {})",
79            blob.len(),
80            crate::format::deserialize::MAX_GRAIN_BYTES
81        )));
82    }
83    crate::format::deserialize::guard_msgpack_shape(&payload)?;
84
85    let hash = content_address(&blob);
86    Ok((blob, hash))
87}
88
89/// Serialize a grain, set the is_signed flag, and wrap in a COSE Sign1 envelope.
90///
91/// Returns `(cose_bytes, content_hash, inner_blob)` where:
92/// - `cose_bytes` is the COSE Sign1 envelope to store/transmit.
93/// - `content_hash` is SHA-256(inner_blob) — computed over the inner blob with is_signed=1.
94/// - `inner_blob` is the raw .mg blob with is_signed flag set.
95///
96/// `org_context` is bound into the COSE AAD to prevent cross-org replay attacks — pass
97/// `org_id.as_bytes()` whenever the org context is available. An empty slice is accepted
98/// but reduces the cross-context binding guarantee.
99#[cfg(feature = "signing")]
100pub fn serialize_grain_signed<G: crate::Grain + 'static>(
101    grain: &G,
102    signing_key: &crate::crypto::signing::GrainSigningKey,
103    org_context: &[u8],
104) -> crate::error::Result<(Vec<u8>, crate::error::Hash, Vec<u8>)> {
105    // Step 1: Normal serialization (is_signed flag is 0 at this point)
106    let (mut blob, _) = serialize_grain(grain)?;
107    // Step 2: Set is_signed flag (bit 0 of byte 1)
108    blob[1] |= 0x01;
109    // Step 3: Recompute content address over blob with is_signed=1
110    let hash = crate::format::header::content_address(&blob);
111    // Step 4: COSE Sign1 wrap with org-scoped AAD
112    let signed = crate::crypto::signing::sign_grain(&blob, signing_key, org_context)?;
113    Ok((signed.cose_bytes, hash, blob))
114}
115
116/// Detect sensitivity level from tags.
117fn detect_sensitivity(tags: &[String]) -> u8 {
118    for tag in tags {
119        if tag.starts_with("phi:") {
120            return 0x03; // PHI
121        }
122    }
123    for tag in tags {
124        if tag.starts_with("pii:") || tag.starts_with("sec:") || tag.starts_with("legal:") {
125            return 0x02; // PII
126        }
127    }
128    for tag in tags {
129        if tag.starts_with("reg:") {
130            return 0x01; // Regulated
131        }
132    }
133    0x00 // Public
134}
135
136/// Create an NFC-normalized msgpack string value.
137fn nfc_string(s: &str) -> Value {
138    Value::String(s.nfc().collect::<String>().into())
139}
140
141/// Convert BTreeMap to a msgpack Map value (keys already sorted by BTreeMap).
142fn btree_to_msgpack_map(map: BTreeMap<String, Value>) -> Value {
143    let pairs: Vec<(Value, Value)> = map
144        .into_iter()
145        .map(|(k, v)| (Value::String(k.into()), v))
146        .collect();
147    Value::Map(pairs)
148}
149
150/// Convert serde_json::Value to rmpv::Value.
151fn json_to_msgpack(json: &serde_json::Value) -> Value {
152    match json {
153        serde_json::Value::Null => Value::Nil,
154        serde_json::Value::Bool(b) => Value::Boolean(*b),
155        serde_json::Value::Number(n) => {
156            if let Some(i) = n.as_i64() {
157                Value::Integer(i.into())
158            } else if let Some(f) = n.as_f64() {
159                Value::F64(f)
160            } else {
161                Value::Nil
162            }
163        }
164        serde_json::Value::String(s) => nfc_string(s),
165        serde_json::Value::Array(arr) => Value::Array(arr.iter().map(json_to_msgpack).collect()),
166        serde_json::Value::Object(obj) => {
167            let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
168            for (k, v) in obj {
169                sorted.insert(k.clone(), json_to_msgpack(v));
170            }
171            btree_to_msgpack_map(sorted)
172        }
173    }
174}
175
176/// Convert serde_json::Value to rmpv::Value, compacting top-level map keys via FIELD_MAP.
177/// Used for the `context` map where keys like "int:base_url" should be compacted to "ib".
178/// Nested map values use standard json_to_msgpack (no key compaction) since they contain
179/// user-defined schemas, not OMS field names.
180fn json_to_msgpack_with_key_compaction(json: &serde_json::Value) -> Value {
181    match json {
182        serde_json::Value::Object(obj) => {
183            let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
184            for (k, v) in obj {
185                let compacted_key = compact_field(k).to_string();
186                sorted.insert(compacted_key, json_to_msgpack(v));
187            }
188            btree_to_msgpack_map(sorted)
189        }
190        // Non-object context values pass through as-is
191        other => json_to_msgpack(other),
192    }
193}
194
195/// Add grain-type-specific fields to the map using downcasting.
196fn add_type_specific_fields<G: Grain + 'static>(grain: &G, map: &mut BTreeMap<String, Value>) {
197    use std::any::Any;
198    let any = grain as &dyn Any;
199
200    if let Some(fact) = any.downcast_ref::<Fact>() {
201        map.insert(
202            compact_field("subject").to_string(),
203            nfc_string(&fact.subject),
204        );
205        map.insert(
206            compact_field("relation").to_string(),
207            nfc_string(&fact.relation),
208        );
209        // object can be string or map in OMS 1.2 — serialize as string for now
210        map.insert(
211            compact_field("object").to_string(),
212            nfc_string(&fact.object),
213        );
214    } else if let Some(ev) = any.downcast_ref::<Event>() {
215        map.insert("content".to_string(), nfc_string(&ev.content));
216        if let Some(ref s) = ev.subject {
217            map.insert(compact_field("subject").to_string(), nfc_string(s));
218        }
219        if let Some(ref o) = ev.object {
220            map.insert(compact_field("object").to_string(), nfc_string(o));
221        }
222        if let Some(r) = ev.role {
223            map.insert(compact_field("role").to_string(), nfc_string(r.as_str()));
224        }
225        if let Some(ref sid) = ev.session_id {
226            map.insert(compact_field("session_id").to_string(), nfc_string(sid));
227        }
228        if let Some(ref pmid) = ev.parent_message_id {
229            map.insert(
230                compact_field("parent_message_id").to_string(),
231                nfc_string(pmid),
232            );
233        }
234        if let Some(ref blocks) = ev.content_blocks {
235            // Serialize via serde_json → rmpv. No key compaction — `type`, `id`,
236            // `name`, `input`, `tool_use_id`, `is_error` are wire-format fixed
237            // strings, not OMS fields.
238            if let Ok(json) = serde_json::to_value(blocks) {
239                map.insert(
240                    compact_field("content_blocks").to_string(),
241                    json_to_msgpack(&json),
242                );
243            }
244        }
245        if let Some(ref m) = ev.model_id {
246            map.insert(compact_field("model_id").to_string(), nfc_string(m));
247        }
248        if let Some(ref sr) = ev.stop_reason {
249            map.insert(compact_field("stop_reason").to_string(), nfc_string(sr));
250        }
251        if let Some(ref tu) = ev.token_usage {
252            if let Ok(json) = serde_json::to_value(tu) {
253                map.insert(
254                    compact_field("token_usage").to_string(),
255                    json_to_msgpack(&json),
256                );
257            }
258        }
259        if let Some(ref rid) = ev.run_id {
260            map.insert(compact_field("run_id").to_string(), nfc_string(rid));
261        }
262    } else if let Some(st) = any.downcast_ref::<State>() {
263        map.insert(
264            compact_field("context").to_string(),
265            json_to_msgpack(&st.context_data),
266        );
267    } else if let Some(wf) = any.downcast_ref::<Workflow>() {
268        // nodes: Vec<String>
269        let nodes: Vec<Value> = wf.nodes.iter().map(|s| nfc_string(s)).collect();
270        map.insert("nodes".to_string(), Value::Array(nodes));
271        // edges: Vec<WorkflowEdge> — serialized as array of maps
272        if !wf.edges.is_empty() {
273            let edges: Vec<Value> = wf
274                .edges
275                .iter()
276                .map(|e| {
277                    let mut m = BTreeMap::new();
278                    m.insert("src".to_string(), nfc_string(&e.src));
279                    m.insert("dst".to_string(), nfc_string(&e.dst));
280                    if let Some(ref c) = e.cond {
281                        m.insert("cond".to_string(), nfc_string(c));
282                    }
283                    if let Some(mc) = e.max_cycles {
284                        m.insert(
285                            compact_workflow_edge_field("max_cycles").to_string(),
286                            Value::Integer(mc.into()),
287                        );
288                    }
289                    btree_to_msgpack_map(m)
290                })
291                .collect();
292            map.insert("edges".to_string(), Value::Array(edges));
293        }
294        // bindings: HashMap<String, String>
295        if !wf.bindings.is_empty() {
296            let mut bind_map = BTreeMap::new();
297            for (k, v) in &wf.bindings {
298                bind_map.insert(k.clone(), nfc_string(v));
299            }
300            map.insert(
301                compact_field("bindings").to_string(),
302                btree_to_msgpack_map(bind_map),
303            );
304        }
305        // retries: HashMap<String, u32>
306        if !wf.retries.is_empty() {
307            let mut retry_map = BTreeMap::new();
308            for (k, v) in &wf.retries {
309                retry_map.insert(k.clone(), Value::Integer((*v).into()));
310            }
311            map.insert("retries".to_string(), btree_to_msgpack_map(retry_map));
312        }
313        if let Some(ref trigger) = wf.trigger {
314            map.insert("trigger".to_string(), nfc_string(trigger));
315        }
316    } else if let Some(action) = any.downcast_ref::<Tool>() {
317        map.insert(
318            compact_field("tool_name").to_string(),
319            nfc_string(&action.tool_name),
320        );
321        // Phase 1 (2026-04-19): kind discriminator. Default Execution is
322        // omitted to keep legacy execution-record blobs byte-identical.
323        if action.kind != crate::types::ToolKind::Execution {
324            map.insert(
325                compact_field("kind").to_string(),
326                nfc_string(action.kind.as_str()),
327            );
328        }
329        if let Some(ref inp) = action.input {
330            map.insert(compact_field("input").to_string(), json_to_msgpack(inp));
331        }
332        if let Some(ref cnt) = action.content {
333            // OMS 1.2: "content" for Tool compacts to "cnt" to avoid collision with Event's uncompacted "content"
334            map.insert(compact_field("tool_content").to_string(), nfc_string(cnt));
335        }
336        if let Some(is_err) = action.is_error {
337            map.insert(
338                compact_field("is_error").to_string(),
339                Value::Boolean(is_err),
340            );
341        }
342        if let Some(ref err) = action.error {
343            map.insert(compact_field("error").to_string(), nfc_string(err));
344        }
345        if let Some(dur) = action.duration_ms {
346            map.insert(
347                compact_field("duration_ms").to_string(),
348                Value::Integer(dur.into()),
349            );
350        }
351        if let Some(ref ptid) = action.parent_task_id {
352            map.insert(
353                compact_field("parent_task_id").to_string(),
354                nfc_string(ptid),
355            );
356        }
357        if let Some(ref tcid) = action.tool_call_id {
358            map.insert(compact_field("tool_call_id").to_string(), nfc_string(tcid));
359        }
360        if let Some(ref cbid) = action.call_batch_id {
361            map.insert(compact_field("call_batch_id").to_string(), nfc_string(cbid));
362        }
363        // Phase 1 — definition fields promoted from extra_fields.
364        if let Some(ref tdesc) = action.tool_description {
365            map.insert(
366                compact_field("tool_description").to_string(),
367                nfc_string(tdesc),
368            );
369        }
370        if let Some(ref ischema) = action.input_schema {
371            map.insert(
372                compact_field("input_schema").to_string(),
373                json_to_msgpack(ischema),
374            );
375        }
376        // OMS 1.3: output_schema — JSON Schema for the action's return value
377        if let Some(ref osch) = action.output_schema {
378            map.insert(
379                compact_field("output_schema").to_string(),
380                json_to_msgpack(osch),
381            );
382        }
383        if let Some(strict) = action.strict {
384            map.insert(compact_field("strict").to_string(), Value::Boolean(strict));
385        }
386        if let Some(am) = action.async_mode {
387            map.insert(compact_field("async_mode").to_string(), Value::Boolean(am));
388        }
389        if let Some(ref axu) = action.executor_uri {
390            map.insert(compact_field("executor_uri").to_string(), nfc_string(axu));
391        }
392        if let Some(ref lprm) = action.locked_params {
393            map.insert(
394                compact_field("locked_params").to_string(),
395                json_to_msgpack(lprm),
396            );
397        }
398        if let Some(ref exmp) = action.examples {
399            let arr: Vec<Value> = exmp.iter().map(json_to_msgpack).collect();
400            map.insert(compact_field("examples").to_string(), Value::Array(arr));
401        }
402        if let Some(ref anno) = action.annotations {
403            if let Ok(json) = serde_json::to_value(anno) {
404                map.insert(
405                    compact_field("annotations").to_string(),
406                    json_to_msgpack(&json),
407                );
408            }
409        }
410        if let Some(ref shsh) = action.spec_hash {
411            map.insert(compact_field("spec_hash").to_string(), nfc_string(shsh));
412        }
413        // HPL Phase 4.1 (2026-04-22): emit `executor_kind` only when
414        // explicitly set AND not the default Host — keeps legacy
415        // binding blobs byte-identical across the rollout.
416        if let Some(ek) = action.executor_kind {
417            if ek != crate::types::executor_kind::ExecutorKind::Host {
418                map.insert(
419                    compact_field("executor_kind").to_string(),
420                    nfc_string(ek.as_str()),
421                );
422            }
423        }
424        // Async exec lifecycle. Every field is
425        // optional and omitted when `None` so legacy execution-record
426        // blobs stay byte-identical.
427        if let Some(st) = action.status {
428            map.insert(compact_field("status").to_string(), nfc_string(st.as_str()));
429        }
430        if let Some(ref cid) = action.correlation_id {
431            map.insert(compact_field("correlation_id").to_string(), nfc_string(cid));
432        }
433        if let Some(exp) = action.expires_at_sec {
434            map.insert(
435                compact_field("expires_at_sec").to_string(),
436                Value::Integer(exp.into()),
437            );
438        }
439        if let Some(ref tdh) = action.transient_definition_hash {
440            map.insert(
441                compact_field("transient_definition_hash").to_string(),
442                Value::Binary(tdh.to_vec()),
443            );
444        }
445        if let Some(fc) = action.failure_cause {
446            map.insert(
447                compact_field("failure_cause").to_string(),
448                nfc_string(fc.as_str()),
449            );
450        }
451        if let Some(ref fd) = action.failure_detail {
452            map.insert(compact_field("failure_detail").to_string(), nfc_string(fd));
453        }
454        if let Some(aex) = action.actor_execution_environment {
455            map.insert(
456                compact_field("actor_execution_environment").to_string(),
457                nfc_string(aex.as_str()),
458            );
459        }
460    } else if let Some(obs) = any.downcast_ref::<Observation>() {
461        map.insert(
462            compact_field("observer_id").to_string(),
463            nfc_string(&obs.observer_id),
464        );
465        map.insert(
466            compact_field("observer_type").to_string(),
467            nfc_string(&obs.observer_type),
468        );
469        if let Some(ref s) = obs.subject {
470            map.insert(compact_field("subject").to_string(), nfc_string(s));
471        }
472        if let Some(ref o) = obs.object {
473            map.insert(compact_field("object").to_string(), nfc_string(o));
474        }
475        if let Some(ref m) = obs.observer_model {
476            map.insert(compact_field("observer_model").to_string(), nfc_string(m));
477        }
478        if let Some(ref mode) = obs.observation_mode {
479            map.insert(
480                compact_field("observation_mode").to_string(),
481                nfc_string(mode.as_str()),
482            );
483        }
484        if let Some(ref scope) = obs.observation_scope {
485            map.insert(
486                compact_field("observation_scope").to_string(),
487                nfc_string(scope.as_str()),
488            );
489        }
490    } else if let Some(goal) = any.downcast_ref::<Goal>() {
491        map.insert(
492            compact_field("description").to_string(),
493            nfc_string(&goal.description),
494        );
495        map.insert(
496            compact_field("goal_state").to_string(),
497            nfc_string(goal.goal_state.as_str()),
498        );
499        if let Some(ref s) = goal.subject {
500            map.insert(compact_field("subject").to_string(), nfc_string(s));
501        }
502        if let Some(ref o) = goal.object {
503            map.insert(compact_field("object").to_string(), nfc_string(o));
504        }
505        if let Some(ref p) = goal.priority {
506            map.insert(
507                compact_field("priority").to_string(),
508                nfc_string(p.as_str()),
509            );
510        }
511        if let Some(ref c) = goal.criteria {
512            map.insert(compact_field("criteria").to_string(), nfc_string(c));
513        }
514        if let Some(ref pgs) = goal.parent_goals {
515            let arr: Vec<Value> = pgs.iter().map(|s| nfc_string(s)).collect();
516            map.insert(compact_field("parent_goals").to_string(), Value::Array(arr));
517        }
518        if let Some(ref sr) = goal.state_reason {
519            map.insert(compact_field("state_reason").to_string(), nfc_string(sr));
520        }
521        if let Some(ref se) = goal.satisfaction_evidence {
522            map.insert(
523                compact_field("satisfaction_evidence").to_string(),
524                json_to_msgpack(se),
525            );
526        }
527        if let Some(p) = goal.progress {
528            map.insert(compact_field("progress").to_string(), Value::F64(p));
529        }
530        if let Some(ref dto) = goal.delegate_to {
531            map.insert(compact_field("delegate_to").to_string(), nfc_string(dto));
532        }
533        if let Some(ref dfo) = goal.delegate_from {
534            map.insert(compact_field("delegate_from").to_string(), nfc_string(dfo));
535        }
536    } else if let Some(reasoning) = any.downcast_ref::<Reasoning>() {
537        if !reasoning.premises.is_empty() {
538            let arr: Vec<Value> = reasoning.premises.iter().map(|s| nfc_string(s)).collect();
539            map.insert(compact_field("premises").to_string(), Value::Array(arr));
540        }
541        if let Some(ref c) = reasoning.conclusion {
542            map.insert(compact_field("conclusion").to_string(), nfc_string(c));
543        }
544        if let Some(ref m) = reasoning.inference_method {
545            map.insert(compact_field("inference_method").to_string(), nfc_string(m));
546        }
547        if !reasoning.alternatives_considered.is_empty() {
548            let arr: Vec<Value> = reasoning
549                .alternatives_considered
550                .iter()
551                .map(|s| nfc_string(s))
552                .collect();
553            map.insert(
554                compact_field("alternatives_considered").to_string(),
555                Value::Array(arr),
556            );
557        }
558        if let Some(ref t) = reasoning.thinking_content {
559            map.insert(compact_field("thinking_content").to_string(), nfc_string(t));
560        }
561        if let Some(tr) = reasoning.thinking_redacted {
562            map.insert(
563                compact_field("thinking_redacted").to_string(),
564                Value::Boolean(tr),
565            );
566        }
567    } else if let Some(consensus) = any.downcast_ref::<Consensus>() {
568        if !consensus.participating_observers.is_empty() {
569            let arr: Vec<Value> = consensus
570                .participating_observers
571                .iter()
572                .map(|s| nfc_string(s))
573                .collect();
574            map.insert(
575                compact_field("participating_observers").to_string(),
576                Value::Array(arr),
577            );
578        }
579        if let Some(t) = consensus.threshold {
580            map.insert(compact_field("threshold").to_string(), Value::F64(t));
581        }
582        if let Some(ac) = consensus.agreement_count {
583            map.insert(
584                compact_field("agreement_count").to_string(),
585                Value::Integer(ac.into()),
586            );
587        }
588        if let Some(dc) = consensus.dissent_count {
589            map.insert(
590                compact_field("dissent_count").to_string(),
591                Value::Integer(dc.into()),
592            );
593        }
594        if !consensus.dissent_grains.is_empty() {
595            let arr: Vec<Value> = consensus
596                .dissent_grains
597                .iter()
598                .map(|s| nfc_string(s))
599                .collect();
600            map.insert(
601                compact_field("dissent_grains").to_string(),
602                Value::Array(arr),
603            );
604        }
605        if let Some(ref ac) = consensus.agreed_content {
606            map.insert(compact_field("agreed_content").to_string(), nfc_string(ac));
607        }
608    } else if let Some(consent) = any.downcast_ref::<Consent>() {
609        map.insert(
610            compact_field("subject_did").to_string(),
611            nfc_string(&consent.subject_did),
612        );
613        if let Some(ref g) = consent.grantee_did {
614            map.insert(compact_field("grantee_did").to_string(), nfc_string(g));
615        }
616        if let Some(ref s) = consent.scope {
617            map.insert(compact_field("scope").to_string(), nfc_string(s));
618        }
619        if let Some(iw) = consent.is_withdrawal {
620            map.insert(
621                compact_field("is_withdrawal").to_string(),
622                Value::Boolean(iw),
623            );
624        }
625        if let Some(ref b) = consent.basis {
626            map.insert(compact_field("basis").to_string(), nfc_string(b));
627        }
628        if let Some(ref j) = consent.jurisdiction {
629            map.insert(compact_field("jurisdiction").to_string(), nfc_string(j));
630        }
631        if let Some(ref pc) = consent.prior_consent {
632            map.insert(compact_field("prior_consent").to_string(), nfc_string(pc));
633        }
634        if !consent.witness_dids.is_empty() {
635            let arr: Vec<Value> = consent.witness_dids.iter().map(|s| nfc_string(s)).collect();
636            map.insert(compact_field("witness_dids").to_string(), Value::Array(arr));
637        }
638    } else if let Some(skill) = any.downcast_ref::<Skill>() {
639        // OMS 1.4 Skill (0x0B). Required name + description; optional
640        // definition + learned-competence fields. Empty vecs and None
641        // options are omitted. `proficiency` aliases `confidence` (D3) — the
642        // `prof` key is emitted from `common.confidence` ONLY for held
643        // instances; `confidence` is also carried under `c`, so the two read
644        // identically by construction.
645        map.insert(compact_field("name").to_string(), nfc_string(&skill.name));
646        // description reuses the SHARED `desc` key (same as Goal).
647        map.insert(
648            compact_field("description").to_string(),
649            nfc_string(&skill.description),
650        );
651        if let Some(ref instr) = skill.instructions {
652            map.insert(compact_field("instructions").to_string(), nfc_string(instr));
653        }
654        if let Some(ref wtu) = skill.when_to_use {
655            map.insert(compact_field("when_to_use").to_string(), nfc_string(wtu));
656        }
657        if let Some(ref sver) = skill.version {
658            map.insert(compact_field("version").to_string(), nfc_string(sver));
659        }
660        if !skill.allowed_tools.is_empty() {
661            let arr: Vec<Value> = skill.allowed_tools.iter().map(|s| nfc_string(s)).collect();
662            map.insert(
663                compact_field("allowed_tools").to_string(),
664                Value::Array(arr),
665            );
666        }
667        if !skill.resources.is_empty() {
668            let arr: Vec<Value> = skill.resources.iter().map(|s| nfc_string(s)).collect();
669            map.insert(compact_field("resources").to_string(), Value::Array(arr));
670        }
671        if !skill.dependencies.is_empty() {
672            let arr: Vec<Value> = skill.dependencies.iter().map(|s| nfc_string(s)).collect();
673            map.insert(compact_field("dependencies").to_string(), Value::Array(arr));
674        }
675        if !skill.input_modalities.is_empty() {
676            let arr: Vec<Value> = skill
677                .input_modalities
678                .iter()
679                .map(|s| nfc_string(s))
680                .collect();
681            map.insert(
682                compact_field("input_modalities").to_string(),
683                Value::Array(arr),
684            );
685        }
686        if !skill.output_modalities.is_empty() {
687            let arr: Vec<Value> = skill
688                .output_modalities
689                .iter()
690                .map(|s| nfc_string(s))
691                .collect();
692            map.insert(
693                compact_field("output_modalities").to_string(),
694                Value::Array(arr),
695            );
696        }
697        if let Some(ref dom) = skill.domain {
698            map.insert(compact_field("domain").to_string(), nfc_string(dom));
699        }
700        if let Some(ref hdid) = skill.holder_did {
701            map.insert(compact_field("holder_did").to_string(), nfc_string(hdid));
702        }
703        // proficiency (D3): emit `prof` from confidence only for held skills.
704        if skill.is_held() {
705            map.insert(
706                compact_field("proficiency").to_string(),
707                Value::F64(skill.common.confidence),
708            );
709        }
710        if let Some(prcnt) = skill.practice_count {
711            map.insert(
712                compact_field("practice_count").to_string(),
713                Value::Integer(prcnt.into()),
714            );
715        }
716        if let Some(lpa) = skill.last_practiced_at {
717            map.insert(
718                compact_field("last_practiced_at").to_string(),
719                Value::Integer(lpa.into()),
720            );
721        }
722        if !skill.strategies.is_empty() {
723            // Serialize via serde_json → rmpv. No key compaction — the
724            // SkillStrategy fields (`condition`, `workflow`, `description`)
725            // are serde field names, not OMS field-map entries.
726            if let Ok(json) = serde_json::to_value(&skill.strategies) {
727                map.insert(
728                    compact_field("strategies").to_string(),
729                    json_to_msgpack(&json),
730                );
731            }
732        }
733        if let Some(xfer) = skill.transferable {
734            map.insert(
735                compact_field("transferable").to_string(),
736                Value::Boolean(xfer),
737            );
738        }
739    }
740}
741
742/// Add common fields to the map (compacted, nulls omitted, sorted by BTreeMap).
743fn add_common_fields(common: &GrainCommon, created_at: i64, map: &mut BTreeMap<String, Value>) {
744    if let Some(ref adid) = common.author_did {
745        map.insert(compact_field("author_did").to_string(), nfc_string(adid));
746    }
747    map.insert(
748        compact_field("confidence").to_string(),
749        Value::F64(common.confidence),
750    );
751    map.insert(
752        compact_field("created_at").to_string(),
753        Value::Integer(created_at.into()),
754    );
755
756    if let Some(ref ns) = common.namespace {
757        map.insert(compact_field("namespace").to_string(), nfc_string(ns));
758    }
759    if let Some(ref uid) = common.user_id {
760        map.insert(compact_field("user_id").to_string(), nfc_string(uid));
761    }
762    if !common.tags.is_empty() {
763        let arr: Vec<Value> = common.tags.iter().map(|s| nfc_string(s)).collect();
764        map.insert(
765            compact_field("structural_tags").to_string(),
766            Value::Array(arr),
767        );
768    }
769    if let Some(ref st) = common.source_type {
770        map.insert(compact_field("source_type").to_string(), nfc_string(st));
771    }
772    if let Some(im) = common.importance {
773        map.insert(compact_field("importance").to_string(), Value::F64(im));
774    }
775    if let Some(ref tt) = common.temporal_type {
776        let s = match tt {
777            TemporalType::State => "state",
778            TemporalType::Event => "event",
779            TemporalType::Interval => "interval",
780        };
781        map.insert(compact_field("temporal_type").to_string(), nfc_string(s));
782    }
783    if let Some(vf) = common.valid_from {
784        map.insert(
785            compact_field("valid_from").to_string(),
786            Value::Integer(vf.into()),
787        );
788    }
789    if let Some(vt) = common.valid_to {
790        map.insert(
791            compact_field("valid_to").to_string(),
792            Value::Integer(vt.into()),
793        );
794    }
795    if let Some(svf) = common.system_valid_from {
796        map.insert(
797            compact_field("system_valid_from").to_string(),
798            Value::Integer(svf.into()),
799        );
800    }
801    if let Some(svt) = common.system_valid_to {
802        map.insert(
803            compact_field("system_valid_to").to_string(),
804            Value::Integer(svt.into()),
805        );
806    }
807    if let Some(ref df) = common.derived_from {
808        map.insert(compact_field("derived_from").to_string(), nfc_string(df));
809    }
810    if let Some(cl) = common.consolidation_level {
811        map.insert(
812            compact_field("consolidation_level").to_string(),
813            Value::Integer(cl.into()),
814        );
815    }
816    if let Some(ref odid) = common.origin_did {
817        map.insert(compact_field("origin_did").to_string(), nfc_string(odid));
818    }
819    if let Some(ref ons) = common.origin_namespace {
820        map.insert(
821            compact_field("origin_namespace").to_string(),
822            nfc_string(ons),
823        );
824    }
825    if let Some(ref sb) = common.superseded_by {
826        map.insert(compact_field("superseded_by").to_string(), nfc_string(sb));
827    }
828    if let Some(ref vs) = common.verification_status {
829        map.insert(
830            compact_field("verification_status").to_string(),
831            nfc_string(vs),
832        );
833    }
834
835    // Embedding text override
836    if let Some(ref et) = common.embedding_text {
837        map.insert(compact_field("embedding_text").to_string(), nfc_string(et));
838    }
839
840    // Context map — compact string keys using FIELD_MAP (supports int:* profile keys)
841    if let Some(ref ctx) = common.context {
842        map.insert(
843            compact_field("context").to_string(),
844            json_to_msgpack_with_key_compaction(ctx),
845        );
846    }
847
848    // Invalidation policy
849    if let Some(ref ip) = common.invalidation_policy {
850        let mut ip_map = BTreeMap::new();
851        if let Some(ref auth) = ip.authorized {
852            let arr: Vec<Value> = auth.iter().map(|s| nfc_string(s)).collect();
853            ip_map.insert("authorized".to_string(), Value::Array(arr));
854        }
855        if let Some(ref fb) = ip.fallback_mode {
856            ip_map.insert("fallback_mode".to_string(), nfc_string(fb));
857        }
858        if let Some(locked_until) = ip.locked_until {
859            ip_map.insert(
860                "locked_until".to_string(),
861                Value::Integer(locked_until.into()),
862            );
863        }
864        ip_map.insert("mode".to_string(), nfc_string(&ip.mode));
865        if let Some(ref reason) = ip.protection_reason {
866            ip_map.insert("protection_reason".to_string(), nfc_string(reason));
867        }
868        if let Some(ref scope) = ip.scope {
869            ip_map.insert("scope".to_string(), nfc_string(scope));
870        }
871        if let Some(threshold) = ip.threshold {
872            ip_map.insert("threshold".to_string(), Value::Integer(threshold.into()));
873        }
874        map.insert(
875            compact_field("invalidation_policy").to_string(),
876            btree_to_msgpack_map(ip_map),
877        );
878    }
879
880    // content_refs
881    if !common.content_refs.is_empty() {
882        let arr: Vec<Value> = common
883            .content_refs
884            .iter()
885            .map(|cr| {
886                let mut m = BTreeMap::new();
887                if let Some(ref ck) = cr.checksum {
888                    m.insert(
889                        compact_content_ref_field("checksum").to_string(),
890                        nfc_string(ck),
891                    );
892                }
893                if let Some(ref md) = cr.metadata {
894                    m.insert(
895                        compact_content_ref_field("metadata").to_string(),
896                        json_to_msgpack(md),
897                    );
898                }
899                if let Some(ref mt) = cr.mime_type {
900                    m.insert(
901                        compact_content_ref_field("mime_type").to_string(),
902                        nfc_string(mt),
903                    );
904                }
905                if let Some(ref mod_) = cr.modality {
906                    m.insert(
907                        compact_content_ref_field("modality").to_string(),
908                        nfc_string(mod_),
909                    );
910                }
911                if let Some(sz) = cr.size_bytes {
912                    m.insert(
913                        compact_content_ref_field("size_bytes").to_string(),
914                        Value::Integer(sz.into()),
915                    );
916                }
917                m.insert(
918                    compact_content_ref_field("uri").to_string(),
919                    nfc_string(&cr.uri),
920                );
921                btree_to_msgpack_map(m)
922            })
923            .collect();
924        map.insert(compact_field("content_refs").to_string(), Value::Array(arr));
925    }
926
927    // embedding_refs
928    if !common.embedding_refs.is_empty() {
929        let arr: Vec<Value> = common
930            .embedding_refs
931            .iter()
932            .map(|er| {
933                let mut m = BTreeMap::new();
934                if let Some(ref di) = er.distance_metric {
935                    m.insert(
936                        compact_embedding_ref_field("distance_metric").to_string(),
937                        nfc_string(di),
938                    );
939                }
940                if let Some(dm) = er.dimensions {
941                    m.insert(
942                        compact_embedding_ref_field("dimensions").to_string(),
943                        Value::Integer(dm.into()),
944                    );
945                }
946                if let Some(ref mo) = er.model {
947                    m.insert(
948                        compact_embedding_ref_field("model").to_string(),
949                        nfc_string(mo),
950                    );
951                }
952                if let Some(ref ms) = er.modality_source {
953                    m.insert(
954                        compact_embedding_ref_field("modality_source").to_string(),
955                        nfc_string(ms),
956                    );
957                }
958                m.insert(
959                    compact_embedding_ref_field("vector_id").to_string(),
960                    nfc_string(&er.vector_id),
961                );
962                btree_to_msgpack_map(m)
963            })
964            .collect();
965        map.insert(
966            compact_field("embedding_refs").to_string(),
967            Value::Array(arr),
968        );
969    }
970
971    // related_to
972    if !common.related_to.is_empty() {
973        let arr: Vec<Value> = common
974            .related_to
975            .iter()
976            .map(|rt| {
977                let mut m = BTreeMap::new();
978                m.insert(
979                    compact_related_to_field("hash").to_string(),
980                    nfc_string(&rt.hash),
981                );
982                m.insert(
983                    compact_related_to_field("relation_type").to_string(),
984                    nfc_string(&rt.relation_type),
985                );
986                if let Some(w) = rt.weight {
987                    m.insert(
988                        compact_related_to_field("weight").to_string(),
989                        Value::F64(w),
990                    );
991                }
992                btree_to_msgpack_map(m)
993            })
994            .collect();
995        map.insert(compact_field("related_to").to_string(), Value::Array(arr));
996    }
997
998    // Extra fields — custom fields not in the grain type's whitelist.
999    // Stored as-is (no compaction) since they're not in FIELD_MAP.
1000    for (key, value) in &common.extra_fields {
1001        // Skip if a known field somehow ended up here (defensive).
1002        if map.contains_key(key) || map.contains_key(&compact_field(key).to_string()) {
1003            continue;
1004        }
1005        map.insert(key.clone(), json_to_msgpack(value));
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn test_serialize_minimal_fact() {
1015        // Minimal Fact
1016        let fact = Fact::new("user", "likes", "coffee")
1017            .confidence(0.9)
1018            .source_type("user_explicit")
1019            .created_at(1768471200000_i64)
1020            .namespace("shared")
1021            .author_did("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK");
1022
1023        let (blob, hash) = serialize_grain(&fact).unwrap();
1024
1025        // Fact type string
1026        assert!(!hash.to_hex().is_empty());
1027        assert_eq!(blob[0], 0x01); // version
1028        assert_eq!(blob[2], 0x01); // fact type byte (0x01)
1029    }
1030
1031    #[test]
1032    fn test_serialize_protected_fact() {
1033        // Protected Fact with invalidation_policy
1034        let mut fact = Fact::new(
1035            "agent-007",
1036            "constraint",
1037            "never delete user files without confirmation",
1038        )
1039        .confidence(1.0)
1040        .source_type("user_explicit")
1041        .created_at(1768471200000_i64)
1042        .namespace("safety");
1043
1044        fact.common.invalidation_policy = Some(InvalidationPolicy {
1045            mode: "locked".to_string(),
1046            authorized: Some(vec![
1047                "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK".to_string(),
1048            ]),
1049            threshold: None,
1050            locked_until: None,
1051            fallback_mode: None,
1052            scope: None,
1053            protection_reason: None,
1054        });
1055
1056        let (blob, _hash) = serialize_grain(&fact).unwrap();
1057        assert!(!blob.is_empty());
1058        assert_eq!(blob[2], 0x01); // fact type byte
1059    }
1060
1061    #[test]
1062    fn test_serialize_tool_with_output_schema() {
1063        use crate::format::deserialize::deserialize_blob;
1064
1065        let schema = serde_json::json!({
1066            "type": "object",
1067            "properties": {
1068                "id": { "type": "integer" },
1069                "number": { "type": "integer" },
1070                "html_url": { "type": "string" }
1071            }
1072        });
1073
1074        let tool = Tool::new("github:create-issue")
1075            .output_schema(schema)
1076            .created_at(1768471200000_i64)
1077            .namespace("example:connectors:github");
1078
1079        let (blob, _hash) = serialize_grain(&tool).unwrap();
1080        assert_eq!(blob[2], 0x05); // Tool type byte
1081
1082        // Roundtrip: deserialize and verify output_schema is present
1083        let deserialized = deserialize_blob(&blob).unwrap();
1084        assert_eq!(deserialized.grain_type, GrainType::Tool);
1085        assert_eq!(
1086            deserialized.get_str("tool_name"),
1087            Some("github:create-issue")
1088        );
1089        let osch = deserialized.fields.get("output_schema");
1090        assert!(
1091            osch.is_some(),
1092            "output_schema should be present after deserialization"
1093        );
1094        let osch_obj = osch.unwrap().as_object().unwrap();
1095        assert_eq!(osch_obj.get("type").unwrap(), "object");
1096    }
1097
1098    #[test]
1099    fn test_serialize_tool_without_output_schema() {
1100        // output_schema=None should not appear in serialized blob
1101        let tool = Tool::new("calculator")
1102            .content("42")
1103            .created_at(1768471200000_i64);
1104
1105        let (blob, _hash) = serialize_grain(&tool).unwrap();
1106        // Verify it doesn't contain the "osch" key by checking the raw blob
1107        let blob_str = String::from_utf8_lossy(&blob);
1108        assert!(
1109            !blob_str.contains("osch"),
1110            "osch should be omitted when output_schema is None"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_serialize_context_with_integration_keys() {
1116        use crate::format::deserialize::deserialize_blob;
1117
1118        let mut tool = Tool::new("github:create-issue")
1119            .created_at(1768471200000_i64)
1120            .namespace("example:connectors:github");
1121
1122        // Set context map with int:* keys
1123        tool.common.context = Some(serde_json::json!({
1124            "int:base_url": "https://api.github.com",
1125            "int:http_method": "POST",
1126            "int:http_path": "/repos/{owner}/{repo}/issues",
1127            "int:connector": "github",
1128            "int:auth_type": "api_key:bearer"
1129        }));
1130
1131        let (blob, _hash) = serialize_grain(&tool).unwrap();
1132        let deserialized = deserialize_blob(&blob).unwrap();
1133
1134        // Context map keys should be expanded back to int:* names
1135        let ctx = deserialized
1136            .fields
1137            .get("context")
1138            .unwrap()
1139            .as_object()
1140            .unwrap();
1141        assert_eq!(ctx.get("int:base_url").unwrap(), "https://api.github.com");
1142        assert_eq!(ctx.get("int:http_method").unwrap(), "POST");
1143        assert_eq!(
1144            ctx.get("int:http_path").unwrap(),
1145            "/repos/{owner}/{repo}/issues"
1146        );
1147        assert_eq!(ctx.get("int:connector").unwrap(), "github");
1148        assert_eq!(ctx.get("int:auth_type").unwrap(), "api_key:bearer");
1149    }
1150
1151    #[test]
1152    fn test_serialize_event() {
1153        // Event
1154        let ev = Event::new("User discussed vacation plans for March")
1155            .namespace("conversations")
1156            .created_at(1768471200000_i64);
1157
1158        let (blob, _hash) = serialize_grain(&ev).unwrap();
1159        assert!(!blob.is_empty());
1160        assert_eq!(blob[0], 0x01); // version
1161        assert_eq!(blob[2], 0x02); // event type byte (0x02)
1162    }
1163
1164    #[test]
1165    fn test_extra_fields_round_trip() {
1166        use crate::format::deserialize::deserialize_blob;
1167
1168        // Goal with custom extra fields (the Atmatic use case)
1169        let mut goal = Goal::new("complete benchmark run")
1170            .confidence(0.95)
1171            .namespace("meta-agent")
1172            .created_at(1768471200000_i64);
1173
1174        goal.common_mut().extra_fields.insert(
1175            "output".to_string(),
1176            serde_json::json!("benchmark passed with 97% recall"),
1177        );
1178        goal.common_mut()
1179            .extra_fields
1180            .insert("total_tokens".to_string(), serde_json::json!(42500));
1181        goal.common_mut()
1182            .extra_fields
1183            .insert("steps_executed".to_string(), serde_json::json!(12));
1184        goal.common_mut()
1185            .extra_fields
1186            .insert("cost_micro_credits".to_string(), serde_json::json!(1500));
1187        // Nested object
1188        goal.common_mut().extra_fields.insert(
1189            "metrics".to_string(),
1190            serde_json::json!({"recall": 0.97, "precision": 0.92, "f1": 0.945}),
1191        );
1192
1193        let (blob, _hash) = serialize_grain(&goal).unwrap();
1194
1195        // Deserialize and verify all extra fields survived
1196        let deserialized = deserialize_blob(&blob).unwrap();
1197        assert_eq!(deserialized.grain_type, GrainType::Goal);
1198
1199        // Known fields present
1200        assert_eq!(
1201            deserialized.get_str("description"),
1202            Some("complete benchmark run")
1203        );
1204        assert_eq!(
1205            deserialized
1206                .fields
1207                .get("confidence")
1208                .and_then(|v| v.as_f64()),
1209            Some(0.95)
1210        );
1211
1212        // Extra fields present
1213        assert_eq!(
1214            deserialized.fields.get("output").and_then(|v| v.as_str()),
1215            Some("benchmark passed with 97% recall"),
1216        );
1217        assert_eq!(
1218            deserialized
1219                .fields
1220                .get("total_tokens")
1221                .and_then(|v| v.as_i64()),
1222            Some(42500),
1223        );
1224        assert_eq!(
1225            deserialized
1226                .fields
1227                .get("steps_executed")
1228                .and_then(|v| v.as_i64()),
1229            Some(12),
1230        );
1231        assert_eq!(
1232            deserialized
1233                .fields
1234                .get("cost_micro_credits")
1235                .and_then(|v| v.as_i64()),
1236            Some(1500),
1237        );
1238
1239        // Nested object
1240        let metrics = deserialized
1241            .fields
1242            .get("metrics")
1243            .unwrap()
1244            .as_object()
1245            .unwrap();
1246        assert_eq!(metrics.get("recall").and_then(|v| v.as_f64()), Some(0.97));
1247        assert_eq!(
1248            metrics.get("precision").and_then(|v| v.as_f64()),
1249            Some(0.92)
1250        );
1251    }
1252
1253    #[test]
1254    fn test_extra_fields_empty_no_overhead() {
1255        use crate::format::deserialize::deserialize_blob;
1256
1257        // Fact with no extra fields — blob should be identical to before
1258        let fact = Fact::new("user", "likes", "rust")
1259            .confidence(0.9)
1260            .created_at(1768471200000_i64);
1261
1262        assert!(fact.common().extra_fields.is_empty());
1263
1264        let (blob, _) = serialize_grain(&fact).unwrap();
1265        let deserialized = deserialize_blob(&blob).unwrap();
1266
1267        // No extra fields in deserialized output
1268        let known_keys: std::collections::HashSet<&str> = [
1269            "type",
1270            "subject",
1271            "relation",
1272            "object",
1273            "confidence",
1274            "created_at",
1275        ]
1276        .into_iter()
1277        .collect();
1278        for key in deserialized.fields.keys() {
1279            assert!(
1280                known_keys.contains(key.as_str()),
1281                "unexpected field in deserialized output: {key}"
1282            );
1283        }
1284    }
1285
1286    #[test]
1287    fn test_extra_field_builder() {
1288        use crate::format::deserialize::deserialize_blob;
1289
1290        // Test the .extra_field() builder method
1291        let event = Event::new("agent completed task")
1292            .namespace("tasks")
1293            .created_at(1768471200000_i64)
1294            .extra_field("task_id", serde_json::json!("task-42"))
1295            .extra_field("duration_sec", serde_json::json!(3.15));
1296
1297        let (blob, _) = serialize_grain(&event).unwrap();
1298        let deserialized = deserialize_blob(&blob).unwrap();
1299
1300        assert_eq!(
1301            deserialized.fields.get("task_id").and_then(|v| v.as_str()),
1302            Some("task-42"),
1303        );
1304        assert_eq!(
1305            deserialized
1306                .fields
1307                .get("duration_sec")
1308                .and_then(|v| v.as_f64()),
1309            Some(3.15),
1310        );
1311    }
1312
1313    #[test]
1314    fn test_event_chat_fields_roundtrip() {
1315        use crate::format::deserialize::deserialize_blob;
1316
1317        let blocks = vec![
1318            ContentBlock::Text {
1319                text: "let me check".into(),
1320            },
1321            ContentBlock::ToolUse {
1322                id: "toolu_01".into(),
1323                name: "calculator".into(),
1324                input: serde_json::json!({"x": 2, "y": 3}),
1325            },
1326        ];
1327        let tu = TokenUsage {
1328            input_tokens: 42,
1329            output_tokens: 17,
1330            cache_read_tokens: None,
1331            cache_creation_tokens: None,
1332        };
1333        let ev = Event::new("hello from assistant")
1334            .role(Role::Assistant)
1335            .session("conv-abc".into())
1336            .parent_message("deadbeef".into())
1337            .content_blocks(blocks.clone())
1338            .model("claude-opus-4.7".into())
1339            .stop_reason("end_turn".into())
1340            .token_usage(tu)
1341            .run_id("run-99".into());
1342        let mut ev = ev;
1343        ev.common.namespace = Some("harness:slug:conv-abc".into());
1344        ev.common.user_id = Some("alice".into());
1345        ev.common.created_at = Some(1_768_471_200_000);
1346
1347        let (blob, _) = serialize_grain(&ev).unwrap();
1348        let des = deserialize_blob(&blob).unwrap();
1349        let back = des.to_event().unwrap();
1350
1351        assert_eq!(back.content, "hello from assistant");
1352        assert_eq!(back.role, Some(Role::Assistant));
1353        assert_eq!(back.session_id.as_deref(), Some("conv-abc"));
1354        assert_eq!(back.parent_message_id.as_deref(), Some("deadbeef"));
1355        assert_eq!(back.model_id.as_deref(), Some("claude-opus-4.7"));
1356        assert_eq!(back.stop_reason.as_deref(), Some("end_turn"));
1357        assert_eq!(back.run_id.as_deref(), Some("run-99"));
1358        assert_eq!(back.token_usage, Some(tu));
1359        assert_eq!(back.content_blocks.as_ref().unwrap(), &blocks);
1360    }
1361
1362    #[test]
1363    fn test_event_compact_keys_present_in_blob() {
1364        let ev = Event::new("hi")
1365            .role(Role::User)
1366            .session("conv-x".into())
1367            .parent_message("abc".into())
1368            .model("gpt-5".into())
1369            .stop_reason("end_turn".into())
1370            .token_usage(TokenUsage::default());
1371
1372        let (blob, _) = serialize_grain(&ev).unwrap();
1373        // Raw msgpack payload starts after the 9-byte header.
1374        let payload = &blob[9..];
1375        // Verify compact names appear as-is (short forms, not long forms).
1376        assert!(has_str(payload, "role"), "role key missing");
1377        assert!(has_str(payload, "sid2"), "sid2 key missing");
1378        assert!(has_str(payload, "pmid"), "pmid key missing");
1379        assert!(has_str(payload, "mdl"), "mdl key missing");
1380        assert!(has_str(payload, "stopr"), "stopr key missing");
1381        assert!(has_str(payload, "toku"), "toku key missing");
1382    }
1383
1384    #[test]
1385    fn test_event_no_new_fields_legacy_shape_deserializes() {
1386        use crate::format::deserialize::deserialize_blob;
1387
1388        let ev = Event::new("legacy").subject("alice");
1389        let mut ev = ev;
1390        ev.common.namespace = Some("ns".into());
1391        ev.common.user_id = Some("u1".into());
1392        ev.common.created_at = Some(1_768_471_200_000);
1393
1394        let (blob, _) = serialize_grain(&ev).unwrap();
1395        let des = deserialize_blob(&blob).unwrap();
1396        let back = des.to_event().unwrap();
1397
1398        assert_eq!(back.content, "legacy");
1399        assert_eq!(back.subject.as_deref(), Some("alice"));
1400        assert!(back.role.is_none());
1401        assert!(back.session_id.is_none());
1402        assert!(back.content_blocks.is_none());
1403        assert!(back.token_usage.is_none());
1404    }
1405
1406    fn has_str(payload: &[u8], needle: &str) -> bool {
1407        payload
1408            .windows(needle.len())
1409            .any(|w| w == needle.as_bytes())
1410    }
1411}