Skip to main content

obj_core/codec/
dynamic.rs

1//! [`Dynamic`] — reflective value tree for schema migration.
2//!
3//! `Dynamic` is the bridge that lets a [`Migrate`](crate::codec::Migrate)
4//! impl read fields out of an older stored record without round-tripping
5//! through every intermediate type. It is intentionally heavyweight (one
6//! allocation per node); migration is a cold path and we trade speed for
7//! reflective access.
8//!
9//! # Wire format — design choice
10//!
11//! `postcard` is **not self-describing** — there is no way to reconstruct
12//! field names or even the structural shape of a previously-written
13//! postcard payload from the bytes alone. Two paths are available:
14//!
15//! 1. Pair every `Document` with a hand-written
16//!    `postcard::experimental::schema::Schema` description and use
17//!    that to walk the bytes.
18//! 2. Define an obj-internal "tagged Dynamic" wire format and round-trip
19//!    via that.
20//!
21//! M5 picks (2). Rationale: option (1) couples obj to postcard's
22//! experimental-schema API which has neither stability nor MSRV
23//! guarantees; option (2) is fully under our control, is auditable in
24//! 100 lines of code, and never leaks onto the disk image of normal
25//! documents (only migration code paths see it). The on-disk shape is
26//! documented in `docs/format.md` § Postcard pin — Obj-internal
27//! extension.
28//!
29//! `Dynamic` is *not* an on-disk encoding for application documents.
30//! Application code that writes documents through
31//! [`codec::encode`](crate::codec::encode) writes **native postcard**, NOT
32//! the tagged format. `Dynamic` only appears in flight during a migration
33//! and is discarded as soon as the migrated record reaches its
34//! caller-visible type.
35//!
36//! # M10: schema-driven decode
37//!
38//! [`Dynamic::from_postcard_bytes`] decodes a **native-postcard** payload
39//! into a structured `Dynamic` using a caller-supplied
40//! [`DynamicSchema`]. This is the
41//! path the codec takes when reading a v1 record through a v2
42//! reader: it consults `Document::historical_schemas()` (M10 #82)
43//! for the v1 schema, walks the bytes per that schema, and hands
44//! the resulting `Dynamic` to `Migrate::migrate` so the v2 author
45//! can read fields by name. The walker is iterative
46//! (`Vec<Frame>` stack — Rule 1) and bounded by
47//! [`MAX_SCHEMA_DEPTH`].
48//!
49//! The obj-internal tagged-Dynamic format remains available via
50//! [`Dynamic::from_tagged_bytes`] / [`Dynamic::to_postcard_bytes`]
51//! for forensic logging of an in-flight `Dynamic`.
52//!
53//! # Power-of-ten posture
54//!
55//! - **Rule 1.** Recursive walks (`get`, `set`, [`Dynamic::deserialize`])
56//!   use an explicit stack — no Rust-language recursion. Depth is bounded
57//!   by [`MAX_DYNAMIC_DEPTH`] (32); exceeding it returns
58//!   [`Error::Corruption`].
59//! - **Rule 2.** Each tagged-decode call reads at most [`MAX_DYNAMIC_NODES`]
60//!   nodes (defense-in-depth against a forged payload claiming a huge
61//!   sequence length).
62//! - **Rule 5.** Tag bytes are validated against the documented set; any
63//!   unknown tag is [`Error::Corruption`].
64//! - **Rule 7.** No `unwrap` / `expect` on any error-bearing path.
65
66#![forbid(unsafe_code)]
67
68use std::collections::BTreeMap;
69
70use serde::de::DeserializeOwned;
71use serde::Serialize;
72
73use crate::codec::schema::{DynamicSchema, EnumVariantSchema, MAX_SCHEMA_DEPTH};
74use crate::error::{Error, Result};
75
76/// Maximum depth of a [`Dynamic`] tree. Defense-in-depth bound that
77/// stops a forged payload from triggering unbounded growth.
78pub const MAX_DYNAMIC_DEPTH: usize = 32;
79
80/// Maximum total node count in a [`Dynamic`] tree. Defense-in-depth
81/// bound on the worst-case allocation a malformed payload could
82/// trigger.
83pub const MAX_DYNAMIC_NODES: usize = 65_536;
84
85// Wire-format tag bytes — see `docs/format.md` § Postcard pin —
86// Obj-internal extension.
87const TAG_NULL: u8 = 0x00;
88const TAG_BOOL: u8 = 0x01;
89const TAG_U64: u8 = 0x02;
90const TAG_I64: u8 = 0x03;
91const TAG_F64: u8 = 0x04;
92const TAG_STRING: u8 = 0x05;
93const TAG_BYTES: u8 = 0x06;
94const TAG_SEQ: u8 = 0x07;
95const TAG_MAP: u8 = 0x08;
96const TAG_ENUM: u8 = 0x09;
97
98/// A postcard-decoded view of a stored record.
99///
100/// `Dynamic` is intentionally simple. It is the input shape every
101/// `Migrate::migrate` impl receives; the migration code reads the
102/// fields it cares about via [`Dynamic::get`] or
103/// [`Dynamic::deserialize`] and constructs the target type by hand.
104#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub enum Dynamic {
107    /// JSON-like null / Rust-like `()`.
108    Null,
109    /// Boolean.
110    Bool(bool),
111    /// Unsigned 64-bit integer.
112    U64(u64),
113    /// Signed 64-bit integer.
114    I64(i64),
115    /// 64-bit floating-point. NaN values do not compare equal to
116    /// themselves; equality for `Dynamic::F64` follows the IEEE-754
117    /// definition (so `Dynamic::F64(f64::NAN) != Dynamic::F64(f64::NAN)`).
118    F64(f64),
119    /// UTF-8 string.
120    String(String),
121    /// Raw byte sequence.
122    Bytes(Vec<u8>),
123    /// Ordered sequence of nested `Dynamic` values.
124    Seq(Vec<Dynamic>),
125    /// String-keyed map of nested `Dynamic` values. Iteration order
126    /// is the `BTreeMap`'s sorted-by-key order.
127    Map(BTreeMap<String, Dynamic>),
128    /// Tagged-enum value. `variant` carries the Rust variant name
129    /// (verbatim from
130    /// [`EnumVariantSchema::name`](crate::codec::schema::EnumVariantSchema));
131    /// `payload` carries the decoded inner value (a
132    /// [`Dynamic::Null`] for unit variants, a [`Dynamic::Map`] for
133    /// tuple / struct variants, or the inner type's `Dynamic` for
134    /// newtype variants). Migration code distinguishes variants by
135    /// `variant` and pulls fields out of `payload`.
136    Enum {
137        /// The Rust variant name.
138        variant: String,
139        /// The decoded inner payload.
140        payload: Box<Dynamic>,
141    },
142}
143
144impl Dynamic {
145    /// Decode `bytes` as a tagged-`Dynamic` payload produced by
146    /// [`Dynamic::to_postcard_bytes`].
147    ///
148    /// This is **not** the inverse of `postcard::to_allocvec(&doc)`
149    /// for an arbitrary `Document` — see the module-level docs.
150    /// [`Dynamic::deserialize`] round-trips through native postcard
151    /// when the wire shapes match.
152    ///
153    /// # Errors
154    ///
155    /// - [`Error::Codec`] if the underlying postcard decode fails.
156    /// - [`Error::Corruption`] on an unknown tag, a length field that
157    ///   exceeds the input, or a tree depth or node count beyond the
158    ///   defense-in-depth bounds.
159    pub fn from_tagged_bytes(bytes: &[u8]) -> Result<Self> {
160        let (value, _rest) = Self::decode_value(bytes, 0, &mut 0)?;
161        Ok(value)
162    }
163
164    /// Decode a **native-postcard** payload according to `schema`.
165    ///
166    /// This is the M10 migration entry point: given the on-disk
167    /// bytes of an older record + a [`DynamicSchema`] describing
168    /// that older type's shape, the walker produces a structured
169    /// `Dynamic` view that a [`Migrate::migrate`](crate::codec::Migrate)
170    /// impl can read fields out of.
171    ///
172    /// Walks the byte stream iteratively with an explicit
173    /// `Vec<Frame>` stack per power-of-ten Rule 1. Depth is bounded
174    /// by [`MAX_SCHEMA_DEPTH`].
175    ///
176    /// # Errors
177    ///
178    /// - [`Error::SchemaDepthExceeded`] if the schema is deeper than
179    ///   [`MAX_SCHEMA_DEPTH`].
180    /// - [`Error::SchemaTypeMismatch`] if the bytes do not match the
181    ///   schema (truncation, non-UTF-8 string, non-`0|1` bool, etc.).
182    /// - [`Error::Corruption`] on a varint that overflows `u64`.
183    pub fn from_postcard_bytes(bytes: &[u8], schema: &DynamicSchema) -> Result<Self> {
184        let (value, rest) = walk_schema(bytes, schema)?;
185        debug_assert!(rest.len() <= bytes.len(), "walker consumed more than input");
186        if !rest.is_empty() {
187            // Trailing bytes after a complete decode are a schema /
188            // bytes disagreement — surface rather than silently drop.
189            return Err(Error::SchemaTypeMismatch {
190                expected: "exact",
191                found: "trailing-bytes",
192                path: String::new(),
193            });
194        }
195        Ok(value)
196    }
197
198    /// Encode `self` as a tagged-`Dynamic` payload.
199    ///
200    /// # Errors
201    ///
202    /// - [`Error::Codec`] if the underlying postcard encode fails.
203    pub fn to_postcard_bytes(&self) -> Result<Vec<u8>> {
204        let mut buf = Vec::new();
205        self.encode_into(&mut buf)?;
206        Ok(buf)
207    }
208
209    /// Decode a native-postcard payload into the target type `T` via
210    /// the `Dynamic` round-trip. Used by [`Dynamic::deserialize`].
211    ///
212    /// # Errors
213    ///
214    /// Propagates postcard errors as [`Error::Codec`].
215    fn deserialize_postcard<T: DeserializeOwned>(payload: &[u8]) -> Result<T> {
216        postcard::from_bytes::<T>(payload).map_err(Error::from)
217    }
218
219    /// Look up `field` in a [`Dynamic::Map`]. Returns `None` for any
220    /// non-map variant, mirroring JSON's "missing field" semantics.
221    #[must_use]
222    pub fn get(&self, field: &str) -> Option<&Dynamic> {
223        if let Dynamic::Map(m) = self {
224            m.get(field)
225        } else {
226            None
227        }
228    }
229
230    /// Typed accessor for `String` fields. Errors if `field` is
231    /// missing OR carries a non-string value — distinguishes
232    /// "absent" from "wrong shape" so migration code can fail
233    /// loudly on a schema mismatch.
234    ///
235    /// # Errors
236    ///
237    /// - [`Error::SchemaTypeMismatch`] with `expected = "String"`
238    ///   when the field is absent OR the field's variant is not
239    ///   [`Dynamic::String`].
240    pub fn get_str(&self, field: &str) -> Result<&str> {
241        match self.get(field) {
242            Some(Dynamic::String(s)) => Ok(s.as_str()),
243            Some(other) => Err(Error::SchemaTypeMismatch {
244                expected: "String",
245                found: variant_name(other),
246                path: field.to_owned(),
247            }),
248            None => Err(Error::SchemaTypeMismatch {
249                expected: "String",
250                found: "absent",
251                path: field.to_owned(),
252            }),
253        }
254    }
255
256    /// Set `field` to `value` in a [`Dynamic::Map`].
257    ///
258    /// On a non-map variant the method replaces `self` with a fresh
259    /// `Map` containing only `(field, value)`. This matches the
260    /// "best-effort upgrade" shape `Migrate` impls typically need
261    /// when adding a brand-new field to a previously-scalar payload.
262    ///
263    /// Accepts any `impl Into<Dynamic>` — call sites can write
264    /// `doc.set("tier", "gold")` or `doc.set("count", 42u64)`
265    /// without explicitly wrapping the value.
266    pub fn set<S, V>(&mut self, field: S, value: V)
267    where
268        S: Into<String>,
269        V: Into<Dynamic>,
270    {
271        let key = field.into();
272        let val = value.into();
273        if let Dynamic::Map(m) = self {
274            m.insert(key, val);
275        } else {
276            let mut m = BTreeMap::new();
277            m.insert(key, val);
278            *self = Dynamic::Map(m);
279        }
280    }
281
282    /// Remove `field` from a [`Dynamic::Map`].
283    ///
284    /// Map-only. On a non-Map variant returns
285    /// [`Error::DynamicPathNotMap`]. On a Map, returns the removed
286    /// value (or `None` if absent) — distinguishes "field absent"
287    /// from "called remove on the wrong shape".
288    ///
289    /// Typical use is inside a `Migrate::migrate` body when the
290    /// new-version type has dropped a field that the old-version
291    /// payload carried:
292    ///
293    /// ```ignore
294    /// fn migrate(mut doc: Dynamic, _from: u32) -> Result<Self> {
295    ///     doc.remove("deprecated_field")?; // drop, do not roundtrip
296    ///     doc.set("new_field", "default");
297    ///     doc.deserialize()
298    /// }
299    /// ```
300    ///
301    /// # Errors
302    ///
303    /// - [`Error::DynamicPathNotMap`] if `self` is not a `Map`.
304    pub fn remove(&mut self, field: &str) -> Result<Option<Dynamic>> {
305        match self {
306            Dynamic::Map(m) => Ok(m.remove(field)),
307            _ => Err(Error::DynamicPathNotMap {
308                path: field.to_owned(),
309            }),
310        }
311    }
312
313    /// If `self` is a [`Dynamic::Enum`], return the variant name;
314    /// otherwise `None`. Mirrors [`Dynamic::get`]'s "missing field"
315    /// semantics: a non-Enum value silently returns `None` rather
316    /// than erroring.
317    #[must_use]
318    pub fn enum_variant(&self) -> Option<&str> {
319        match self {
320            Dynamic::Enum { variant, .. } => Some(variant.as_str()),
321            _ => None,
322        }
323    }
324
325    /// If `self` is a [`Dynamic::Enum`], return the decoded payload;
326    /// otherwise `None`. Pairs with [`Dynamic::enum_variant`] in a
327    /// `Migrate::migrate` body — match on the variant name, then
328    /// pull fields out of the payload.
329    #[must_use]
330    pub fn enum_payload(&self) -> Option<&Dynamic> {
331        match self {
332            Dynamic::Enum { payload, .. } => Some(payload.as_ref()),
333            _ => None,
334        }
335    }
336
337    /// Round-trip `self` through native postcard and deserialise as
338    /// `T`. Useful for types whose postcard wire shape happens to
339    /// match the `Dynamic` shape (primarily other map-shaped types
340    /// or types that are themselves `Dynamic`-valued).
341    ///
342    /// **Caveat.** `Dynamic`'s `Serialize` impl emits a serde Map
343    /// for its `Map` variant. postcard encodes a Rust struct as a
344    /// field-ordered tuple (no key names) — so deserialising a
345    /// `Dynamic::Map` payload as a `struct` will read the map's
346    /// length as the first field. For struct migration the
347    /// recommended pattern is per-field extraction via
348    /// [`Dynamic::get`]; see the `dynamic_migration_shape` test in
349    /// this module for the worked example.
350    ///
351    /// # Errors
352    ///
353    /// - [`Error::Codec`] on any postcard error.
354    pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T> {
355        let bytes = postcard::to_allocvec(self)?;
356        Self::deserialize_postcard::<T>(&bytes)
357    }
358
359    // ---------- internal: tagged wire format ----------
360
361    /// Walk-driven decode. `depth` is the current tree depth;
362    /// `nodes` is the running count of decoded nodes.
363    fn decode_value<'a>(
364        bytes: &'a [u8],
365        depth: usize,
366        nodes: &mut usize,
367    ) -> Result<(Self, &'a [u8])> {
368        if depth >= MAX_DYNAMIC_DEPTH {
369            return Err(Error::Corruption { page_id: 0 });
370        }
371        *nodes = nodes
372            .checked_add(1)
373            .ok_or(Error::Corruption { page_id: 0 })?;
374        if *nodes > MAX_DYNAMIC_NODES {
375            return Err(Error::Corruption { page_id: 0 });
376        }
377        let (tag, rest) = split_first(bytes)?;
378        Self::decode_body(tag, rest, depth, nodes)
379    }
380
381    fn decode_body<'a>(
382        tag: u8,
383        rest: &'a [u8],
384        depth: usize,
385        nodes: &mut usize,
386    ) -> Result<(Self, &'a [u8])> {
387        match tag {
388            TAG_NULL => Ok((Dynamic::Null, rest)),
389            TAG_BOOL => decode_bool(rest),
390            TAG_U64 => decode_u64(rest),
391            TAG_I64 => decode_i64(rest),
392            TAG_F64 => decode_f64(rest),
393            TAG_STRING => decode_string(rest),
394            TAG_BYTES => decode_bytes(rest),
395            TAG_SEQ => Self::decode_seq(rest, depth + 1, nodes),
396            TAG_MAP => Self::decode_map(rest, depth + 1, nodes),
397            TAG_ENUM => Self::decode_enum(rest, depth + 1, nodes),
398            _ => Err(Error::Corruption { page_id: 0 }),
399        }
400    }
401
402    /// Decode a tagged-enum body: varint length + UTF-8 variant name,
403    /// then a nested `Dynamic` payload. Mirrors [`Self::decode_map`]
404    /// in its depth-bound accounting so a forged payload cannot
405    /// trigger unbounded recursion via Enum-of-Enum-of-Enum… chains.
406    fn decode_enum<'a>(
407        bytes: &'a [u8],
408        depth: usize,
409        nodes: &mut usize,
410    ) -> Result<(Self, &'a [u8])> {
411        let (name_bytes, after_name) = take_len_prefixed(bytes)?;
412        let name = std::str::from_utf8(name_bytes)
413            .map_err(|_| Error::Corruption { page_id: 0 })?
414            .to_owned();
415        let (payload, after_payload) = Self::decode_value(after_name, depth, nodes)?;
416        Ok((
417            Dynamic::Enum {
418                variant: name,
419                payload: Box::new(payload),
420            },
421            after_payload,
422        ))
423    }
424
425    fn decode_seq<'a>(
426        bytes: &'a [u8],
427        depth: usize,
428        nodes: &mut usize,
429    ) -> Result<(Self, &'a [u8])> {
430        let (len, mut rest) = take_varint_usize(bytes)?;
431        if len > MAX_DYNAMIC_NODES {
432            return Err(Error::Corruption { page_id: 0 });
433        }
434        let mut items = Vec::with_capacity(len);
435        for _ in 0..len {
436            let (item, next) = Self::decode_value(rest, depth, nodes)?;
437            items.push(item);
438            rest = next;
439        }
440        Ok((Dynamic::Seq(items), rest))
441    }
442
443    fn decode_map<'a>(
444        bytes: &'a [u8],
445        depth: usize,
446        nodes: &mut usize,
447    ) -> Result<(Self, &'a [u8])> {
448        let (len, mut rest) = take_varint_usize(bytes)?;
449        if len > MAX_DYNAMIC_NODES {
450            return Err(Error::Corruption { page_id: 0 });
451        }
452        let mut map = BTreeMap::new();
453        for _ in 0..len {
454            let (key_bytes, after_key) = take_len_prefixed(rest)?;
455            let key = std::str::from_utf8(key_bytes)
456                .map_err(|_| Error::Corruption { page_id: 0 })?
457                .to_owned();
458            let (value, after_value) = Self::decode_value(after_key, depth, nodes)?;
459            if map.insert(key, value).is_some() {
460                // Duplicate keys in a tagged Map are a corruption
461                // signal: the encoder emits sorted-by-key with no
462                // duplicates.
463                return Err(Error::Corruption { page_id: 0 });
464            }
465            rest = after_value;
466        }
467        Ok((Dynamic::Map(map), rest))
468    }
469
470    fn encode_into(&self, dst: &mut Vec<u8>) -> Result<()> {
471        encode_one_bounded(self, 0, dst)
472    }
473}
474
475/// Recursive encoder bounded by [`MAX_DYNAMIC_DEPTH`]. Power-of-ten
476/// Rule 1: every recursive call increments `depth`; exceeding the
477/// bound returns [`Error::Corruption`] rather than risking a stack
478/// overflow on a forged input.
479fn encode_one_bounded(value: &Dynamic, depth: usize, dst: &mut Vec<u8>) -> Result<()> {
480    if depth >= MAX_DYNAMIC_DEPTH {
481        return Err(Error::Corruption { page_id: 0 });
482    }
483    match value {
484        Dynamic::Null => dst.push(TAG_NULL),
485        Dynamic::Bool(b) => {
486            dst.push(TAG_BOOL);
487            dst.push(u8::from(*b));
488        }
489        Dynamic::U64(n) => {
490            dst.push(TAG_U64);
491            write_varint_u64(*n, dst);
492        }
493        Dynamic::I64(n) => {
494            dst.push(TAG_I64);
495            write_varint_i64(*n, dst);
496        }
497        Dynamic::F64(f) => {
498            dst.push(TAG_F64);
499            dst.extend_from_slice(&f.to_le_bytes());
500        }
501        Dynamic::String(s) => {
502            dst.push(TAG_STRING);
503            write_varint_u64(s.len() as u64, dst);
504            dst.extend_from_slice(s.as_bytes());
505        }
506        Dynamic::Bytes(b) => {
507            dst.push(TAG_BYTES);
508            write_varint_u64(b.len() as u64, dst);
509            dst.extend_from_slice(b);
510        }
511        Dynamic::Seq(items) => {
512            dst.push(TAG_SEQ);
513            write_varint_u64(items.len() as u64, dst);
514            for item in items {
515                encode_one_bounded(item, depth + 1, dst)?;
516            }
517        }
518        Dynamic::Map(map) => {
519            dst.push(TAG_MAP);
520            write_varint_u64(map.len() as u64, dst);
521            for (k, v) in map {
522                write_varint_u64(k.len() as u64, dst);
523                dst.extend_from_slice(k.as_bytes());
524                encode_one_bounded(v, depth + 1, dst)?;
525            }
526        }
527        Dynamic::Enum { variant, payload } => {
528            dst.push(TAG_ENUM);
529            write_varint_u64(variant.len() as u64, dst);
530            dst.extend_from_slice(variant.as_bytes());
531            encode_one_bounded(payload, depth + 1, dst)?;
532        }
533    }
534    Ok(())
535}
536
537// ---------- low-level decode helpers ----------
538
539fn split_first(bytes: &[u8]) -> Result<(u8, &[u8])> {
540    let (first, rest) = bytes
541        .split_first()
542        .ok_or(Error::Corruption { page_id: 0 })?;
543    Ok((*first, rest))
544}
545
546fn take_n(bytes: &[u8], n: usize) -> Result<(&[u8], &[u8])> {
547    if bytes.len() < n {
548        return Err(Error::Corruption { page_id: 0 });
549    }
550    Ok(bytes.split_at(n))
551}
552
553fn take_len_prefixed(bytes: &[u8]) -> Result<(&[u8], &[u8])> {
554    let (len, rest) = take_varint_usize(bytes)?;
555    let (data, after) = take_n(rest, len)?;
556    Ok((data, after))
557}
558
559/// Read a LEB128-style unsigned varint (the postcard / protobuf
560/// shape: 7 data bits per byte, MSB = continuation).
561fn take_varint_usize(bytes: &[u8]) -> Result<(usize, &[u8])> {
562    let (v, rest) = take_varint_u64(bytes)?;
563    let v = usize::try_from(v).map_err(|_| Error::Corruption { page_id: 0 })?;
564    Ok((v, rest))
565}
566
567// A u64 varint is at most 10 bytes (7 bits per byte, 64 / 7 = 9.14).
568const MAX_VARINT_BYTES: usize = 10;
569
570fn take_varint_u64(bytes: &[u8]) -> Result<(u64, &[u8])> {
571    let mut value: u64 = 0;
572    let mut shift: u32 = 0;
573    let mut i: usize = 0;
574    while i < bytes.len() && i < MAX_VARINT_BYTES {
575        let b = bytes[i];
576        let part = u64::from(b & 0x7F);
577        let shifted = part
578            .checked_shl(shift)
579            .ok_or(Error::Corruption { page_id: 0 })?;
580        value |= shifted;
581        i += 1;
582        if (b & 0x80) == 0 {
583            return Ok((value, &bytes[i..]));
584        }
585        shift = shift
586            .checked_add(7)
587            .ok_or(Error::Corruption { page_id: 0 })?;
588    }
589    Err(Error::Corruption { page_id: 0 })
590}
591
592fn write_varint_u64(mut value: u64, dst: &mut Vec<u8>) {
593    while value >= 0x80 {
594        // Lower 7 bits of `value` fit in a u8 by construction; the
595        // `& 0x7F` mask guarantees the truncation is exact.
596        #[allow(clippy::cast_possible_truncation)] // exact: masked to 7 bits before the cast
597        let lo = (value & 0x7F) as u8;
598        dst.push(lo | 0x80);
599        value >>= 7;
600    }
601    // After the loop `value < 0x80`, so it fits in a u8 by inspection.
602    #[allow(clippy::cast_possible_truncation)] // exact: post-loop `value < 0x80`
603    let last = value as u8;
604    dst.push(last);
605}
606
607fn write_varint_i64(value: i64, dst: &mut Vec<u8>) {
608    // Zigzag encode: `((x << 1) ^ (x >> 63))` maps i64 to u64
609    // bijectively so signed values pack into varints.
610    let zz = ((value << 1) ^ (value >> 63)).cast_unsigned();
611    write_varint_u64(zz, dst);
612}
613
614fn decode_bool(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
615    let (b, rest) = split_first(bytes)?;
616    match b {
617        0 => Ok((Dynamic::Bool(false), rest)),
618        1 => Ok((Dynamic::Bool(true), rest)),
619        _ => Err(Error::Corruption { page_id: 0 }),
620    }
621}
622
623fn decode_u64(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
624    let (v, rest) = take_varint_u64(bytes)?;
625    Ok((Dynamic::U64(v), rest))
626}
627
628fn decode_i64(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
629    let (zz, rest) = take_varint_u64(bytes)?;
630    // Zigzag decode: high bit signals sign, low bits hold magnitude
631    // shifted by one. The `as i64` reinterprets the unsigned half
632    // exactly — that's the whole point of the encoding.
633    let high = (zz >> 1).cast_signed();
634    let low = (zz & 1).cast_signed();
635    let v = high ^ -low;
636    Ok((Dynamic::I64(v), rest))
637}
638
639fn decode_f64(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
640    let (data, rest) = take_n(bytes, 8)?;
641    let mut buf = [0u8; 8];
642    buf.copy_from_slice(data);
643    Ok((Dynamic::F64(f64::from_le_bytes(buf)), rest))
644}
645
646fn decode_string(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
647    let (data, rest) = take_len_prefixed(bytes)?;
648    let s = std::str::from_utf8(data)
649        .map_err(|_| Error::Corruption { page_id: 0 })?
650        .to_owned();
651    Ok((Dynamic::String(s), rest))
652}
653
654fn decode_bytes(bytes: &[u8]) -> Result<(Dynamic, &[u8])> {
655    let (data, rest) = take_len_prefixed(bytes)?;
656    Ok((Dynamic::Bytes(data.to_vec()), rest))
657}
658
659// ---------- serde ----------
660
661impl Serialize for Dynamic {
662    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
663        use serde::ser::{SerializeMap, SerializeSeq};
664        match self {
665            Dynamic::Null => ser.serialize_unit(),
666            Dynamic::Bool(b) => ser.serialize_bool(*b),
667            Dynamic::U64(n) => ser.serialize_u64(*n),
668            Dynamic::I64(n) => ser.serialize_i64(*n),
669            Dynamic::F64(f) => ser.serialize_f64(*f),
670            Dynamic::String(s) => ser.serialize_str(s),
671            Dynamic::Bytes(b) => ser.serialize_bytes(b),
672            Dynamic::Seq(items) => {
673                let mut s = ser.serialize_seq(Some(items.len()))?;
674                for item in items {
675                    s.serialize_element(item)?;
676                }
677                s.end()
678            }
679            Dynamic::Map(map) => {
680                let mut m = ser.serialize_map(Some(map.len()))?;
681                for (k, v) in map {
682                    m.serialize_entry(k, v)?;
683                }
684                m.end()
685            }
686            // Enum serialises as a single-entry map keyed by variant
687            // name — `Dynamic::deserialize` is documented as map-
688            // shape interop only, so this is the closest reasonable
689            // serde representation. The tagged round-trip
690            // (`to_postcard_bytes` / `from_tagged_bytes`) carries the
691            // variant + payload bytes directly via `TAG_ENUM` and
692            // does not pass through this impl.
693            Dynamic::Enum { variant, payload } => {
694                let mut m = ser.serialize_map(Some(1))?;
695                m.serialize_entry(variant, payload.as_ref())?;
696                m.end()
697            }
698        }
699    }
700}
701
702// ---------- ergonomic conversions ---------------------------------
703//
704// `From` impls for the common scalar shapes so callers of M7's
705// `Collection::find_unique` / `lookup` / `index_range` can pass
706// `"ada@example.com"` or `42u64` directly instead of explicitly
707// constructing a `Dynamic`. Only the primitive shapes are covered —
708// `Seq` / `Map` are migration-path constructs, not lookup keys.
709
710impl From<bool> for Dynamic {
711    fn from(b: bool) -> Self {
712        Dynamic::Bool(b)
713    }
714}
715
716impl From<u64> for Dynamic {
717    fn from(n: u64) -> Self {
718        Dynamic::U64(n)
719    }
720}
721
722impl From<u32> for Dynamic {
723    fn from(n: u32) -> Self {
724        Dynamic::U64(u64::from(n))
725    }
726}
727
728impl From<i64> for Dynamic {
729    fn from(n: i64) -> Self {
730        Dynamic::I64(n)
731    }
732}
733
734impl From<i32> for Dynamic {
735    fn from(n: i32) -> Self {
736        Dynamic::I64(i64::from(n))
737    }
738}
739
740impl From<f64> for Dynamic {
741    fn from(f: f64) -> Self {
742        Dynamic::F64(f)
743    }
744}
745
746impl From<String> for Dynamic {
747    fn from(s: String) -> Self {
748        Dynamic::String(s)
749    }
750}
751
752impl From<&str> for Dynamic {
753    fn from(s: &str) -> Self {
754        Dynamic::String(s.to_owned())
755    }
756}
757
758impl From<Vec<u8>> for Dynamic {
759    fn from(b: Vec<u8>) -> Self {
760        Dynamic::Bytes(b)
761    }
762}
763
764impl From<&[u8]> for Dynamic {
765    fn from(b: &[u8]) -> Self {
766        Dynamic::Bytes(b.to_vec())
767    }
768}
769
770// ---------- schema-driven (native-postcard) walker ---------------
771//
772// Reads a native-postcard payload according to a [`DynamicSchema`]
773// description, producing a structured `Dynamic`. The walker uses an
774// explicit `Vec<Frame>` stack per power-of-ten Rule 1; depth is
775// bounded by [`MAX_SCHEMA_DEPTH`].
776//
777// The walker's only postcard-specific knowledge is the varint /
778// zigzag / length-prefix decoding rules; everything else is driven
779// by the schema. Postcard treats a Rust struct as a positional
780// tuple — `Map` in the schema names each field for `Dynamic::get` /
781// `set` / `remove` addressing but emits no bytes for the names.
782
783/// One pending composite frame in the schema walker's explicit
784/// stack. Each frame describes a `Seq` or `Map` whose children are
785/// still being decoded; once enough children are emitted the frame
786/// is folded into the parent (or returned as the root value).
787enum Frame {
788    /// `Seq` decode in progress: `remaining` children to read,
789    /// each shaped like `elem`; `acc` collects the decoded children
790    /// in order.
791    Seq {
792        elem: DynamicSchema,
793        remaining: usize,
794        acc: Vec<Dynamic>,
795    },
796    /// `Map` decode in progress.
797    ///
798    /// `pending` is the FIFO of `(name, schema)` pairs whose values
799    /// have NOT yet been decoded.  `current_name` is the name of
800    /// the field whose value the walker is decoding RIGHT NOW —
801    /// it has already been popped off `pending` by
802    /// [`request_next_child`] but its value is not yet in `acc`.
803    /// `acc` holds the already-completed fields. `path_prefix` is
804    /// the dotted path leading up to (but not including) this map,
805    /// reused for diagnostic mismatch errors.
806    Map {
807        pending: std::collections::VecDeque<(String, DynamicSchema)>,
808        current_name: Option<String>,
809        acc: BTreeMap<String, Dynamic>,
810        path_prefix: String,
811    },
812    /// `Enum` decode in progress — the walker has read the
813    /// discriminant + resolved the variant and is now waiting for
814    /// the payload's `Dynamic` to fold back. Single-child shape:
815    /// `pending_payload` carries the payload schema until
816    /// [`request_next_child`] takes it; once the payload completes
817    /// `fold_into` moves the decoded value into `acc` and the frame
818    /// is popped + replaced with a [`Dynamic::Enum`] carrying the
819    /// remembered `variant` name and `acc`'s payload.
820    Enum {
821        /// Variant name decoded from the schema; carried verbatim
822        /// into the resulting [`Dynamic::Enum`].
823        variant: String,
824        /// Payload schema; `None` once
825        /// [`request_next_child`] has handed it to the outer loop.
826        pending_payload: Option<DynamicSchema>,
827        /// The decoded payload `Dynamic`, populated by `fold_into`
828        /// when the single child completes. `None` until then.
829        acc: Option<Dynamic>,
830        /// Dotted path leading up to (but not including) this enum,
831        /// reused for diagnostic mismatch errors.
832        #[allow(dead_code)] // forensic field; retained for debugging
833        path_prefix: String,
834    },
835}
836
837/// Walk `bytes` per `schema`. Returns the decoded `Dynamic` and any
838/// trailing bytes the walker did not consume (typically empty for a
839/// matched schema / payload pair).
840fn walk_schema<'a>(bytes: &'a [u8], schema: &DynamicSchema) -> Result<(Dynamic, &'a [u8])> {
841    let mut stack: Vec<Frame> = Vec::new();
842    let mut rest = bytes;
843    // The "next schema slot to decode" + the path leading to it.
844    // Initialised to the root schema; updated by
845    // [`request_next_child`] after each frame push or fold.
846    let mut next_schema: DynamicSchema = schema.clone();
847    let mut next_path: String = String::new();
848    let mut iters: usize = 0;
849    let iter_cap = (1 + MAX_SCHEMA_DEPTH) * (1 + MAX_DYNAMIC_NODES);
850    loop {
851        // Bound the outer loop (Rule 2). Worst case is one
852        // iteration per node decoded; cap matches the existing
853        // tagged-format bound for symmetry.
854        iters = iters.checked_add(1).ok_or(Error::SchemaDepthExceeded {
855            depth: MAX_SCHEMA_DEPTH,
856        })?;
857        check_walk_schema_bounds(iters, iter_cap, stack.len())?;
858        let outcome = decode_slot(rest, &next_schema, &next_path, &mut stack)?;
859        rest = outcome.rest;
860        match advance_after_slot(outcome.value, &mut stack, next_path)? {
861            WalkStep::Complete(root) => return Ok((root, rest)),
862            WalkStep::Continue {
863                next_schema: ns,
864                next_path: np,
865            } => {
866                next_schema = ns;
867                next_path = np;
868            }
869        }
870    }
871}
872
873/// Enforce the per-iteration bounds for [`walk_schema`]: the overall
874/// iteration cap (Rule 2) and the in-flight frame-stack depth limit
875/// (Rule 1, mirrored from the tagged-format walker).
876fn check_walk_schema_bounds(iters: usize, iter_cap: usize, stack_len: usize) -> Result<()> {
877    if iters > iter_cap {
878        return Err(Error::SchemaDepthExceeded {
879            depth: MAX_SCHEMA_DEPTH,
880        });
881    }
882    if stack_len >= MAX_SCHEMA_DEPTH {
883        return Err(Error::SchemaDepthExceeded {
884            depth: MAX_SCHEMA_DEPTH,
885        });
886    }
887    Ok(())
888}
889
890/// Outcome of [`advance_after_slot`]: either the walker has produced
891/// the root `Dynamic` and is done, or it has identified the next
892/// schema slot to drive `decode_slot` against.
893enum WalkStep {
894    /// The fold collapsed every open frame; the carried value is the
895    /// fully-decoded root.
896    Complete(Dynamic),
897    /// More frames remain; carry the next schema slot + diagnostic
898    /// path back to the driver loop.
899    Continue {
900        next_schema: DynamicSchema,
901        next_path: String,
902    },
903}
904
905/// Dispatch on a `decode_slot` outcome: either continue with the next
906/// child's schema/path, fold a completed value (possibly all the way
907/// back to the root), or surface a corruption-shaped error if the
908/// frame stack and the slot value disagree about whether more children
909/// remain to be decoded.
910fn advance_after_slot(
911    slot_value: Option<Dynamic>,
912    stack: &mut Vec<Frame>,
913    current_path: String,
914) -> Result<WalkStep> {
915    match slot_value {
916        None => {
917            // decode_slot pushed a new composite frame; ask
918            // for the first child's schema and continue.
919            if let Some((next_schema, next_path)) = request_next_child(stack) {
920                Ok(WalkStep::Continue {
921                    next_schema,
922                    next_path,
923                })
924            } else {
925                // Pushed frame turned out to be empty — fold
926                // an empty result back into the parent.
927                // (decode_slot already short-circuits the
928                // empty case, so this branch is defense in
929                // depth only.)
930                Err(Error::SchemaTypeMismatch {
931                    expected: "non-empty",
932                    found: "empty-frame",
933                    path: current_path,
934                })
935            }
936        }
937        Some(value) => {
938            if let Some(root) = fold_value(value, stack) {
939                return Ok(WalkStep::Complete(root));
940            }
941            let Some((next_schema, next_path)) = request_next_child(stack) else {
942                // Stack non-empty but no more children — would
943                // be a fold/pop bug; surface as a corruption-
944                // shaped error to keep the contract honest.
945                return Err(Error::SchemaTypeMismatch {
946                    expected: "next-child",
947                    found: "exhausted-frame",
948                    path: current_path,
949                });
950            };
951            Ok(WalkStep::Continue {
952                next_schema,
953                next_path,
954            })
955        }
956    }
957}
958
959/// Result of decoding one schema slot.
960struct SlotOutcome<'a> {
961    /// `Some(value)` if the slot produced a complete `Dynamic`
962    /// inline (scalars + empty composites); `None` if a new frame
963    /// was pushed onto the stack and the outer driver should
964    /// continue with the new frame's first child.
965    value: Option<Dynamic>,
966    /// Remaining input bytes after the slot's bytes were consumed.
967    rest: &'a [u8],
968}
969
970/// Decode a single schema slot.
971fn decode_slot<'a>(
972    bytes: &'a [u8],
973    schema: &DynamicSchema,
974    path: &str,
975    stack: &mut Vec<Frame>,
976) -> Result<SlotOutcome<'a>> {
977    match schema {
978        DynamicSchema::Null => Ok(SlotOutcome {
979            value: Some(Dynamic::Null),
980            rest: bytes,
981        }),
982        DynamicSchema::Bool => {
983            let (v, rest) = decode_bool_schema(bytes, path)?;
984            Ok(SlotOutcome {
985                value: Some(v),
986                rest,
987            })
988        }
989        DynamicSchema::U64 => {
990            let (n, rest) = take_varint_u64(bytes)?;
991            Ok(SlotOutcome {
992                value: Some(Dynamic::U64(n)),
993                rest,
994            })
995        }
996        DynamicSchema::I64 => {
997            let (zz, rest) = take_varint_u64(bytes)?;
998            Ok(SlotOutcome {
999                value: Some(Dynamic::I64(zigzag_decode_i64(zz))),
1000                rest,
1001            })
1002        }
1003        DynamicSchema::F64 => {
1004            let (v, rest) = decode_f64_schema(bytes, path)?;
1005            Ok(SlotOutcome {
1006                value: Some(v),
1007                rest,
1008            })
1009        }
1010        DynamicSchema::String => {
1011            let (v, rest) = decode_string_schema(bytes, path)?;
1012            Ok(SlotOutcome {
1013                value: Some(v),
1014                rest,
1015            })
1016        }
1017        DynamicSchema::Bytes => {
1018            let (v, rest) = decode_bytes_schema(bytes, path)?;
1019            Ok(SlotOutcome {
1020                value: Some(v),
1021                rest,
1022            })
1023        }
1024        DynamicSchema::Seq(elem) => decode_seq_slot(bytes, elem, stack),
1025        DynamicSchema::Map(fields) => Ok(decode_map_slot(bytes, fields, path, stack)),
1026        DynamicSchema::Enum(variants) => decode_enum_slot(bytes, variants, path, stack),
1027    }
1028}
1029
1030/// Decode a `Bool` schema slot.
1031fn decode_bool_schema<'a>(bytes: &'a [u8], path: &str) -> Result<(Dynamic, &'a [u8])> {
1032    let (b, rest) = bytes
1033        .split_first()
1034        .ok_or_else(|| Error::SchemaTypeMismatch {
1035            expected: "Bool",
1036            found: "truncated",
1037            path: path.to_owned(),
1038        })?;
1039    match *b {
1040        0 => Ok((Dynamic::Bool(false), rest)),
1041        1 => Ok((Dynamic::Bool(true), rest)),
1042        _ => Err(Error::SchemaTypeMismatch {
1043            expected: "Bool",
1044            found: "non-bool-byte",
1045            path: path.to_owned(),
1046        }),
1047    }
1048}
1049
1050/// Decode an `F64` schema slot (8 LE bytes).
1051fn decode_f64_schema<'a>(bytes: &'a [u8], path: &str) -> Result<(Dynamic, &'a [u8])> {
1052    if bytes.len() < 8 {
1053        return Err(Error::SchemaTypeMismatch {
1054            expected: "F64",
1055            found: "truncated",
1056            path: path.to_owned(),
1057        });
1058    }
1059    let (data, rest) = bytes.split_at(8);
1060    let mut buf = [0u8; 8];
1061    buf.copy_from_slice(data);
1062    Ok((Dynamic::F64(f64::from_le_bytes(buf)), rest))
1063}
1064
1065/// Decode a `String` schema slot (varint length + UTF-8 bytes).
1066fn decode_string_schema<'a>(bytes: &'a [u8], path: &str) -> Result<(Dynamic, &'a [u8])> {
1067    let (len, rest) = take_varint_usize(bytes)?;
1068    if rest.len() < len {
1069        return Err(Error::SchemaTypeMismatch {
1070            expected: "String",
1071            found: "truncated",
1072            path: path.to_owned(),
1073        });
1074    }
1075    let (data, after) = rest.split_at(len);
1076    let s = std::str::from_utf8(data)
1077        .map_err(|_| Error::SchemaTypeMismatch {
1078            expected: "String",
1079            found: "non-utf8",
1080            path: path.to_owned(),
1081        })?
1082        .to_owned();
1083    Ok((Dynamic::String(s), after))
1084}
1085
1086/// Decode a `Bytes` schema slot (varint length + raw bytes).
1087fn decode_bytes_schema<'a>(bytes: &'a [u8], path: &str) -> Result<(Dynamic, &'a [u8])> {
1088    let (len, rest) = take_varint_usize(bytes)?;
1089    if rest.len() < len {
1090        return Err(Error::SchemaTypeMismatch {
1091            expected: "Bytes",
1092            found: "truncated",
1093            path: path.to_owned(),
1094        });
1095    }
1096    let (data, after) = rest.split_at(len);
1097    Ok((Dynamic::Bytes(data.to_vec()), after))
1098}
1099
1100/// Begin a `Seq` schema slot. Reads the varint length; if zero,
1101/// returns `Some(Dynamic::Seq(vec![]))` directly. Otherwise pushes
1102/// a `Frame::Seq` and returns `None` so the outer loop walks the
1103/// first element.
1104fn decode_seq_slot<'a>(
1105    bytes: &'a [u8],
1106    elem: &DynamicSchema,
1107    stack: &mut Vec<Frame>,
1108) -> Result<SlotOutcome<'a>> {
1109    let (len, rest) = take_varint_usize(bytes)?;
1110    if len == 0 {
1111        return Ok(SlotOutcome {
1112            value: Some(Dynamic::Seq(Vec::new())),
1113            rest,
1114        });
1115    }
1116    if len > MAX_DYNAMIC_NODES {
1117        return Err(Error::SchemaTypeMismatch {
1118            expected: "Seq",
1119            found: "length-exceeds-bound",
1120            path: String::new(),
1121        });
1122    }
1123    stack.push(Frame::Seq {
1124        elem: elem.clone(),
1125        remaining: len,
1126        acc: Vec::with_capacity(len),
1127    });
1128    Ok(SlotOutcome { value: None, rest })
1129}
1130
1131/// Begin a `Map` schema slot. Postcard emits **no length prefix**
1132/// for a struct's fields — the schema names every field in order.
1133/// Empty `Map` short-circuits with an empty `Dynamic::Map`;
1134/// otherwise a `Frame::Map` is pushed.
1135fn decode_map_slot<'a>(
1136    bytes: &'a [u8],
1137    fields: &[(String, DynamicSchema)],
1138    path: &str,
1139    stack: &mut Vec<Frame>,
1140) -> SlotOutcome<'a> {
1141    if fields.is_empty() {
1142        return SlotOutcome {
1143            value: Some(Dynamic::Map(BTreeMap::new())),
1144            rest: bytes,
1145        };
1146    }
1147    let pending: std::collections::VecDeque<(String, DynamicSchema)> =
1148        fields.iter().cloned().collect();
1149    stack.push(Frame::Map {
1150        pending,
1151        current_name: None,
1152        acc: BTreeMap::new(),
1153        path_prefix: path.to_owned(),
1154    });
1155    SlotOutcome {
1156        value: None,
1157        rest: bytes,
1158    }
1159}
1160
1161/// Begin an `Enum` schema slot. Reads the varint `u32` discriminant,
1162/// binary-searches `variants` for the match, and pushes a
1163/// `Frame::Enum` carrying the variant name + payload schema. The
1164/// schema's `variants` MUST be sorted ascending by discriminant —
1165/// debug-asserted on first decode. A missing discriminant returns
1166/// [`Error::SchemaTypeMismatch`].
1167fn decode_enum_slot<'a>(
1168    bytes: &'a [u8],
1169    variants: &[EnumVariantSchema],
1170    path: &str,
1171    stack: &mut Vec<Frame>,
1172) -> Result<SlotOutcome<'a>> {
1173    debug_assert!(
1174        variants
1175            .windows(2)
1176            .all(|w| w[0].discriminant < w[1].discriminant),
1177        "Enum schema variants must be strictly ascending by discriminant",
1178    );
1179    debug_assert!(
1180        !variants.is_empty(),
1181        "Enum schema must declare at least one variant",
1182    );
1183    let (disc_u64, rest) = take_varint_u64(bytes)?;
1184    let disc = u32::try_from(disc_u64).map_err(|_| Error::SchemaTypeMismatch {
1185        expected: "u32-discriminant",
1186        found: "varint-overflow",
1187        path: path.to_owned(),
1188    })?;
1189    let idx = variants
1190        .binary_search_by(|v| v.discriminant.cmp(&disc))
1191        .map_err(|_| Error::SchemaTypeMismatch {
1192            expected: "known variant",
1193            found: "unknown-discriminant",
1194            path: path.to_owned(),
1195        })?;
1196    let variant = &variants[idx];
1197    stack.push(Frame::Enum {
1198        variant: variant.name.clone(),
1199        pending_payload: Some(variant.payload.as_ref().clone()),
1200        acc: None,
1201        path_prefix: path.to_owned(),
1202    });
1203    Ok(SlotOutcome { value: None, rest })
1204}
1205
1206/// Fold a freshly-decoded `value` into the top frame's accumulator.
1207/// May cascade pops up the stack if `value` completes one or more
1208/// composite frames. Returns `Some(root)` when the stack empties
1209/// (the walk is finished), `None` otherwise.
1210fn fold_value(mut value: Dynamic, stack: &mut Vec<Frame>) -> Option<Dynamic> {
1211    let mut iters: usize = 0;
1212    loop {
1213        iters = iters.saturating_add(1);
1214        debug_assert!(
1215            iters <= MAX_SCHEMA_DEPTH + 1,
1216            "fold cascade exceeded MAX_SCHEMA_DEPTH",
1217        );
1218        let Some(top) = stack.last_mut() else {
1219            return Some(value);
1220        };
1221        if !fold_into(top, &mut value) {
1222            return None;
1223        }
1224        // The top frame completed — pop it and treat its
1225        // accumulator as the new `value` for the next iteration.
1226        let completed = match stack.pop() {
1227            Some(Frame::Seq { acc, .. }) => Dynamic::Seq(acc),
1228            Some(Frame::Map { acc, .. }) => Dynamic::Map(acc),
1229            Some(Frame::Enum {
1230                variant,
1231                acc: payload,
1232                ..
1233            }) => {
1234                // `fold_into` for `Frame::Enum` always populates
1235                // `acc` before returning `true`; a `None` here would
1236                // be a fold-cascade bug. Fall back to `Null` to keep
1237                // the walker total; the inner debug_assert! flags
1238                // the bug in development.
1239                debug_assert!(
1240                    payload.is_some(),
1241                    "Enum frame popped before fold_into populated acc",
1242                );
1243                Dynamic::Enum {
1244                    variant,
1245                    payload: Box::new(payload.unwrap_or(Dynamic::Null)),
1246                }
1247            }
1248            None => return Some(value),
1249        };
1250        value = completed;
1251    }
1252}
1253
1254/// Push `value` into `top`. Returns `true` if `top` is now complete
1255/// (caller must pop + cascade); `false` if the frame still has
1256/// children pending.
1257fn fold_into(top: &mut Frame, value: &mut Dynamic) -> bool {
1258    match top {
1259        Frame::Seq { remaining, acc, .. } => {
1260            acc.push(std::mem::replace(value, Dynamic::Null));
1261            *remaining = remaining.saturating_sub(1);
1262            *remaining == 0
1263        }
1264        Frame::Map {
1265            pending,
1266            current_name,
1267            acc,
1268            ..
1269        } => {
1270            let name = current_name
1271                .take()
1272                .unwrap_or_else(|| String::from("<bug:missing-current-name>"));
1273            debug_assert!(
1274                !name.starts_with("<bug:"),
1275                "Map frame missing current_name in fold",
1276            );
1277            acc.insert(name, std::mem::replace(value, Dynamic::Null));
1278            pending.is_empty()
1279        }
1280        Frame::Enum { acc, .. } => {
1281            // Single-child frame: the first (and only) fold completes
1282            // the Enum. `acc` MUST be `None` here — `request_next_child`
1283            // hands out the payload schema exactly once.
1284            debug_assert!(acc.is_none(), "Enum frame folded twice");
1285            *acc = Some(std::mem::replace(value, Dynamic::Null));
1286            true
1287        }
1288    }
1289}
1290
1291/// Ask the top frame for the schema (and dotted path) of the next
1292/// child slot to decode.  `Seq` frames return the element schema
1293/// unchanged; `Map` frames pop the next pending field off and
1294/// remember its name in `current_name` for the eventual fold.
1295fn request_next_child(stack: &mut [Frame]) -> Option<(DynamicSchema, String)> {
1296    let top = stack.last_mut()?;
1297    match top {
1298        Frame::Seq { elem, .. } => Some((elem.clone(), String::new())),
1299        Frame::Map {
1300            pending,
1301            current_name,
1302            path_prefix,
1303            ..
1304        } => {
1305            let (name, schema) = pending.pop_front()?;
1306            let path = if path_prefix.is_empty() {
1307                name.clone()
1308            } else {
1309                format!("{path_prefix}.{name}")
1310            };
1311            *current_name = Some(name);
1312            Some((schema, path))
1313        }
1314        Frame::Enum {
1315            variant,
1316            pending_payload,
1317            path_prefix,
1318            ..
1319        } => {
1320            let schema = pending_payload.take()?;
1321            let path = if path_prefix.is_empty() {
1322                variant.clone()
1323            } else {
1324                format!("{path_prefix}.{variant}")
1325            };
1326            Some((schema, path))
1327        }
1328    }
1329}
1330
1331/// Static name of `Dynamic`'s variant. Used by [`Dynamic::get_str`]
1332/// for diagnostic mismatch errors.
1333fn variant_name(d: &Dynamic) -> &'static str {
1334    match d {
1335        Dynamic::Null => "Null",
1336        Dynamic::Bool(_) => "Bool",
1337        Dynamic::U64(_) => "U64",
1338        Dynamic::I64(_) => "I64",
1339        Dynamic::F64(_) => "F64",
1340        Dynamic::String(_) => "String",
1341        Dynamic::Bytes(_) => "Bytes",
1342        Dynamic::Seq(_) => "Seq",
1343        Dynamic::Map(_) => "Map",
1344        Dynamic::Enum { .. } => "Enum",
1345    }
1346}
1347
1348/// Zigzag-decode a u64 into i64. Mirrors the encoding postcard uses
1349/// for signed varints.
1350fn zigzag_decode_i64(zz: u64) -> i64 {
1351    let high = (zz >> 1).cast_signed();
1352    let low = (zz & 1).cast_signed();
1353    high ^ -low
1354}
1355
1356// `Deserialize` is intentionally NOT implemented for `Dynamic` — the
1357// tagged-Dynamic format is the only way to load a `Dynamic` tree.
1358// `serde`'s built-in self-describing-format assumption does not hold
1359// for postcard, so a `serde::Deserialize` impl would silently produce
1360// garbage. M5 migration code paths use `Dynamic::from_tagged_bytes`
1361// (or the schema-driven `Dynamic::from_postcard_bytes` introduced in
1362// M10) explicitly, which is the audit-grade entry point.
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367    use serde::{Deserialize, Serialize};
1368
1369    #[test]
1370    fn round_trip_scalar() {
1371        for value in [
1372            Dynamic::Null,
1373            Dynamic::Bool(true),
1374            Dynamic::Bool(false),
1375            Dynamic::U64(0),
1376            Dynamic::U64(u64::MAX),
1377            Dynamic::I64(0),
1378            Dynamic::I64(-1),
1379            Dynamic::I64(i64::MIN),
1380            Dynamic::I64(i64::MAX),
1381            Dynamic::F64(1.5),
1382            Dynamic::String("hello".to_owned()),
1383            Dynamic::Bytes(vec![1, 2, 3]),
1384        ] {
1385            let bytes = value.to_postcard_bytes().expect("encode");
1386            let decoded = Dynamic::from_tagged_bytes(&bytes).expect("decode");
1387            assert_eq!(decoded, value);
1388        }
1389    }
1390
1391    #[test]
1392    fn round_trip_nested_map() {
1393        let mut inner = BTreeMap::new();
1394        inner.insert("a".to_owned(), Dynamic::U64(1));
1395        inner.insert("b".to_owned(), Dynamic::String("two".to_owned()));
1396        let mut outer = BTreeMap::new();
1397        outer.insert("inner".to_owned(), Dynamic::Map(inner));
1398        outer.insert(
1399            "list".to_owned(),
1400            Dynamic::Seq(vec![
1401                Dynamic::U64(10),
1402                Dynamic::U64(20),
1403                Dynamic::Bool(false),
1404            ]),
1405        );
1406        let value = Dynamic::Map(outer);
1407        let bytes = value.to_postcard_bytes().expect("encode");
1408        let decoded = Dynamic::from_tagged_bytes(&bytes).expect("decode");
1409        assert_eq!(decoded, value);
1410    }
1411
1412    #[test]
1413    fn unknown_tag_is_corruption() {
1414        let err = Dynamic::from_tagged_bytes(&[0xFF]).expect_err("unknown tag");
1415        assert!(matches!(err, Error::Corruption { page_id: 0 }));
1416    }
1417
1418    #[test]
1419    fn truncated_tagged_string_is_corruption() {
1420        // tag = string, claimed length = 100, payload = "x" only.
1421        let bytes = [TAG_STRING, 100, b'x'];
1422        let err = Dynamic::from_tagged_bytes(&bytes).expect_err("truncated");
1423        assert!(matches!(err, Error::Corruption { page_id: 0 }));
1424    }
1425
1426    #[test]
1427    fn get_and_set_on_map() {
1428        let mut m = BTreeMap::new();
1429        m.insert("a".to_owned(), Dynamic::U64(1));
1430        let mut value = Dynamic::Map(m);
1431        assert_eq!(value.get("a"), Some(&Dynamic::U64(1)));
1432        assert_eq!(value.get("missing"), None);
1433        value.set("b", Dynamic::Bool(true));
1434        assert_eq!(value.get("b"), Some(&Dynamic::Bool(true)));
1435    }
1436
1437    #[test]
1438    fn remove_from_map_returns_removed() {
1439        let mut m = BTreeMap::new();
1440        m.insert("a".to_owned(), Dynamic::U64(1));
1441        m.insert("b".to_owned(), Dynamic::String("two".to_owned()));
1442        let mut value = Dynamic::Map(m);
1443        let removed = value.remove("a").expect("ok");
1444        assert_eq!(removed, Some(Dynamic::U64(1)));
1445        assert_eq!(value.get("a"), None);
1446        assert_eq!(value.get("b"), Some(&Dynamic::String("two".to_owned())));
1447        // Re-removing returns None — distinguishes "absent" from
1448        // "wrong shape".
1449        let again = value.remove("a").expect("ok");
1450        assert_eq!(again, None);
1451    }
1452
1453    #[test]
1454    fn remove_on_non_map_errors() {
1455        let mut value = Dynamic::U64(5);
1456        let err = value.remove("k").expect_err("non-map");
1457        assert!(matches!(err, Error::DynamicPathNotMap { .. }));
1458    }
1459
1460    #[test]
1461    fn set_on_non_map_replaces() {
1462        let mut value = Dynamic::U64(5);
1463        value.set("k", Dynamic::String("v".to_owned()));
1464        match value {
1465            Dynamic::Map(m) => {
1466                assert_eq!(m.len(), 1);
1467                assert_eq!(m.get("k"), Some(&Dynamic::String("v".to_owned())));
1468            }
1469            other => panic!("expected Map, got {other:?}"),
1470        }
1471    }
1472
1473    /// Reproduces the M5 migration shape: encode a v1 doc through
1474    /// postcard, build a `Dynamic` view of its fields, set the new
1475    /// v2 field with a default, then construct the v2 struct from
1476    /// the populated `Dynamic`. This is the pattern every
1477    /// hand-written `Migrate::migrate` impl will follow.
1478    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1479    struct V1 {
1480        a: u32,
1481    }
1482
1483    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1484    struct V2 {
1485        a: u32,
1486        b: String,
1487    }
1488
1489    #[test]
1490    fn dynamic_migration_shape() {
1491        // 1. Original v1 doc on the wire (native postcard).
1492        let v1 = V1 { a: 42 };
1493        let v1_postcard = postcard::to_allocvec(&v1).expect("encode v1");
1494
1495        // 2. Migration override knows the v1 shape — postcard is not
1496        //    self-describing, so the override decodes v1 postcard
1497        //    into the v1 struct (which it still has access to, by
1498        //    construction) and constructs a Dynamic by hand.
1499        let recovered_v1: V1 = postcard::from_bytes(&v1_postcard).expect("decode v1");
1500        let mut dynamic = Dynamic::Map(BTreeMap::new());
1501        dynamic.set("a", Dynamic::U64(u64::from(recovered_v1.a)));
1502
1503        // 3. Add the new field with a default value.
1504        dynamic.set("b", Dynamic::String("default-b".to_owned()));
1505
1506        // 4. Pull the v2 struct out of the populated Dynamic by
1507        //    field-level access. (The `Dynamic::deserialize`
1508        //    round-trip via postcard is only viable when the wire
1509        //    shape matches; postcard treats Rust structs as
1510        //    field-ordered tuples, not maps, so per-field extraction
1511        //    is the portable approach.)
1512        let a = match dynamic.get("a") {
1513            Some(Dynamic::U64(n)) => u32::try_from(*n).expect("u32 range"),
1514            other => panic!("missing or wrong-type a: {other:?}"),
1515        };
1516        let b = match dynamic.get("b") {
1517            Some(Dynamic::String(s)) => s.clone(),
1518            other => panic!("missing or wrong-type b: {other:?}"),
1519        };
1520        let v2 = V2 { a, b };
1521        assert_eq!(
1522            v2,
1523            V2 {
1524                a: 42,
1525                b: "default-b".to_owned(),
1526            }
1527        );
1528    }
1529
1530    // ---------- M10 issue #41: schema-driven walker ----------
1531
1532    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1533    struct SchemaPair {
1534        name: String,
1535        age: u32,
1536    }
1537
1538    #[test]
1539    fn schema_walker_decodes_simple_struct() {
1540        let s = SchemaPair {
1541            name: "ada".to_owned(),
1542            age: 36,
1543        };
1544        let bytes = postcard::to_allocvec(&s).expect("encode");
1545        let schema =
1546            DynamicSchema::map([("name", DynamicSchema::String), ("age", DynamicSchema::U64)]);
1547        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1548        assert_eq!(
1549            dyn_view.get("name"),
1550            Some(&Dynamic::String("ada".to_owned())),
1551        );
1552        assert_eq!(dyn_view.get("age"), Some(&Dynamic::U64(36)));
1553    }
1554
1555    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1556    struct SchemaInner {
1557        x: u32,
1558        y: u32,
1559    }
1560
1561    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1562    struct SchemaOuter {
1563        tag: String,
1564        inner: SchemaInner,
1565        tail: bool,
1566    }
1567
1568    #[test]
1569    fn schema_walker_decodes_nested_struct() {
1570        let s = SchemaOuter {
1571            tag: "hello".to_owned(),
1572            inner: SchemaInner { x: 1, y: 2 },
1573            tail: true,
1574        };
1575        let bytes = postcard::to_allocvec(&s).expect("encode");
1576        let schema = DynamicSchema::map([
1577            ("tag", DynamicSchema::String),
1578            (
1579                "inner",
1580                DynamicSchema::map([("x", DynamicSchema::U64), ("y", DynamicSchema::U64)]),
1581            ),
1582            ("tail", DynamicSchema::Bool),
1583        ]);
1584        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1585        assert_eq!(
1586            dyn_view.get("tag"),
1587            Some(&Dynamic::String("hello".to_owned())),
1588        );
1589        let inner = dyn_view.get("inner").expect("inner present");
1590        assert_eq!(inner.get("x"), Some(&Dynamic::U64(1)));
1591        assert_eq!(inner.get("y"), Some(&Dynamic::U64(2)));
1592        assert_eq!(dyn_view.get("tail"), Some(&Dynamic::Bool(true)));
1593    }
1594
1595    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1596    struct SchemaWithSeq {
1597        items: Vec<u32>,
1598        name: String,
1599    }
1600
1601    #[test]
1602    fn schema_walker_decodes_seq() {
1603        let s = SchemaWithSeq {
1604            items: vec![10, 20, 30],
1605            name: "vec".to_owned(),
1606        };
1607        let bytes = postcard::to_allocvec(&s).expect("encode");
1608        let schema = DynamicSchema::map([
1609            ("items", DynamicSchema::seq(DynamicSchema::U64)),
1610            ("name", DynamicSchema::String),
1611        ]);
1612        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1613        match dyn_view.get("items") {
1614            Some(Dynamic::Seq(items)) => {
1615                assert_eq!(items.len(), 3);
1616                assert_eq!(items[0], Dynamic::U64(10));
1617                assert_eq!(items[1], Dynamic::U64(20));
1618                assert_eq!(items[2], Dynamic::U64(30));
1619            }
1620            other => panic!("expected Seq, got {other:?}"),
1621        }
1622        assert_eq!(
1623            dyn_view.get("name"),
1624            Some(&Dynamic::String("vec".to_owned())),
1625        );
1626    }
1627
1628    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1629    struct SchemaSigned {
1630        neg: i32,
1631        pos: i32,
1632    }
1633
1634    #[test]
1635    fn schema_walker_decodes_signed_ints() {
1636        let s = SchemaSigned { neg: -5, pos: 7 };
1637        let bytes = postcard::to_allocvec(&s).expect("encode");
1638        let schema = DynamicSchema::map([("neg", DynamicSchema::I64), ("pos", DynamicSchema::I64)]);
1639        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1640        assert_eq!(dyn_view.get("neg"), Some(&Dynamic::I64(-5)));
1641        assert_eq!(dyn_view.get("pos"), Some(&Dynamic::I64(7)));
1642    }
1643
1644    #[test]
1645    fn schema_walker_decodes_bytes_and_unit() {
1646        #[derive(Serialize, Deserialize)]
1647        struct B {
1648            blob: Vec<u8>,
1649            nothing: (),
1650            present: bool,
1651        }
1652        let v = B {
1653            blob: vec![1, 2, 3, 4],
1654            nothing: (),
1655            present: false,
1656        };
1657        let bytes = postcard::to_allocvec(&v).expect("encode");
1658        let schema = DynamicSchema::map([
1659            ("blob", DynamicSchema::Bytes),
1660            ("nothing", DynamicSchema::Null),
1661            ("present", DynamicSchema::Bool),
1662        ]);
1663        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1664        match dyn_view.get("blob") {
1665            // postcard encodes Vec<u8> using its standard sequence
1666            // encoding (varint len + elements), so the wire shape is
1667            // a Bytes-equivalent prefix that the schema's Bytes
1668            // variant decodes directly.
1669            Some(Dynamic::Bytes(b)) => assert_eq!(b, &vec![1, 2, 3, 4]),
1670            other => panic!("expected Bytes, got {other:?}"),
1671        }
1672        assert_eq!(dyn_view.get("nothing"), Some(&Dynamic::Null));
1673        assert_eq!(dyn_view.get("present"), Some(&Dynamic::Bool(false)));
1674    }
1675
1676    #[test]
1677    fn schema_walker_round_trip_to_native_deserialize() {
1678        // The walker decodes a postcard payload AND postcard can
1679        // re-decode the same payload directly — every struct that
1680        // round-trips through postcard also round-trips through the
1681        // walker.
1682        let s = SchemaPair {
1683            name: "ada".to_owned(),
1684            age: 36,
1685        };
1686        let bytes = postcard::to_allocvec(&s).expect("encode");
1687        let direct: SchemaPair = postcard::from_bytes(&bytes).expect("postcard");
1688        let schema =
1689            DynamicSchema::map([("name", DynamicSchema::String), ("age", DynamicSchema::U64)]);
1690        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1691        // Hand-construct equivalent: confirm Dynamic carries every
1692        // field the direct postcard decode produced.
1693        assert_eq!(
1694            dyn_view.get("name"),
1695            Some(&Dynamic::String(direct.name.clone())),
1696        );
1697        assert_eq!(
1698            dyn_view.get("age"),
1699            Some(&Dynamic::U64(u64::from(direct.age)))
1700        );
1701    }
1702
1703    #[test]
1704    fn schema_walker_rejects_truncated_payload() {
1705        let s = SchemaPair {
1706            name: "ada".to_owned(),
1707            age: 36,
1708        };
1709        let mut bytes = postcard::to_allocvec(&s).expect("encode");
1710        bytes.truncate(2); // not enough bytes for the full struct.
1711        let schema =
1712            DynamicSchema::map([("name", DynamicSchema::String), ("age", DynamicSchema::U64)]);
1713        let err = Dynamic::from_postcard_bytes(&bytes, &schema).expect_err("truncated");
1714        assert!(matches!(err, Error::SchemaTypeMismatch { .. }));
1715    }
1716
1717    #[test]
1718    fn schema_walker_rejects_excess_depth() {
1719        // Build a schema deeper than MAX_SCHEMA_DEPTH and feed it a
1720        // payload whose every Seq carries length 1, so the walker
1721        // is forced to descend a frame per level. Each level
1722        // consumes one byte (varint(1) == 0x01); the depth bound
1723        // trips before the walker can read the innermost U64.
1724        let mut s = DynamicSchema::U64;
1725        for _ in 0..(MAX_SCHEMA_DEPTH + 2) {
1726            s = DynamicSchema::seq(s);
1727        }
1728        // varint(1) per level + spare bytes for the innermost U64.
1729        let bytes = vec![1u8; MAX_SCHEMA_DEPTH + 16];
1730        let err = Dynamic::from_postcard_bytes(&bytes, &s).expect_err("depth");
1731        assert!(matches!(err, Error::SchemaDepthExceeded { .. }));
1732    }
1733
1734    // ---------- M11 issue #88: enum walker ----------
1735
1736    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
1737    enum SchemaEnumProbe {
1738        Pending,
1739        Shipped { tracking: String },
1740        Cancelled(String),
1741    }
1742
1743    fn schema_enum_probe_schema() -> DynamicSchema {
1744        DynamicSchema::enumeration([
1745            EnumVariantSchema::new(0, "Pending", DynamicSchema::Null),
1746            EnumVariantSchema::new(
1747                1,
1748                "Shipped",
1749                DynamicSchema::map([("tracking", DynamicSchema::String)]),
1750            ),
1751            EnumVariantSchema::new(2, "Cancelled", DynamicSchema::String),
1752        ])
1753    }
1754
1755    #[test]
1756    fn schema_walker_decodes_unit_variant() {
1757        let bytes = postcard::to_allocvec(&SchemaEnumProbe::Pending).expect("encode");
1758        let schema = schema_enum_probe_schema();
1759        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1760        assert_eq!(dyn_view.enum_variant(), Some("Pending"));
1761        assert_eq!(dyn_view.enum_payload(), Some(&Dynamic::Null));
1762    }
1763
1764    #[test]
1765    fn schema_walker_decodes_struct_variant() {
1766        let v = SchemaEnumProbe::Shipped {
1767            tracking: "ABC".to_owned(),
1768        };
1769        let bytes = postcard::to_allocvec(&v).expect("encode");
1770        let dyn_view =
1771            Dynamic::from_postcard_bytes(&bytes, &schema_enum_probe_schema()).expect("walk");
1772        assert_eq!(dyn_view.enum_variant(), Some("Shipped"));
1773        let payload = dyn_view.enum_payload().expect("payload");
1774        assert_eq!(
1775            payload.get("tracking"),
1776            Some(&Dynamic::String("ABC".to_owned())),
1777        );
1778    }
1779
1780    #[test]
1781    fn schema_walker_decodes_newtype_variant() {
1782        let v = SchemaEnumProbe::Cancelled("late".to_owned());
1783        let bytes = postcard::to_allocvec(&v).expect("encode");
1784        let dyn_view =
1785            Dynamic::from_postcard_bytes(&bytes, &schema_enum_probe_schema()).expect("walk");
1786        assert_eq!(dyn_view.enum_variant(), Some("Cancelled"));
1787        assert_eq!(
1788            dyn_view.enum_payload(),
1789            Some(&Dynamic::String("late".to_owned())),
1790        );
1791    }
1792
1793    #[test]
1794    fn schema_walker_unknown_discriminant_errors() {
1795        // Single-byte payload: discriminant `99`, no payload. The
1796        // schema only declares 0..=2.
1797        let bytes = [99u8];
1798        let err =
1799            Dynamic::from_postcard_bytes(&bytes, &schema_enum_probe_schema()).expect_err("unknown");
1800        assert!(matches!(
1801            err,
1802            Error::SchemaTypeMismatch {
1803                expected: "known variant",
1804                ..
1805            }
1806        ));
1807    }
1808
1809    #[test]
1810    fn schema_walker_decodes_enum_inside_map() {
1811        #[derive(Serialize, Deserialize)]
1812        struct Wrap {
1813            label: String,
1814            status: SchemaEnumProbe,
1815        }
1816        let v = Wrap {
1817            label: "order".to_owned(),
1818            status: SchemaEnumProbe::Shipped {
1819                tracking: "XYZ".to_owned(),
1820            },
1821        };
1822        let bytes = postcard::to_allocvec(&v).expect("encode");
1823        let schema = DynamicSchema::map([
1824            ("label", DynamicSchema::String),
1825            ("status", schema_enum_probe_schema()),
1826        ]);
1827        let dyn_view = Dynamic::from_postcard_bytes(&bytes, &schema).expect("walk");
1828        assert_eq!(
1829            dyn_view.get("label"),
1830            Some(&Dynamic::String("order".to_owned())),
1831        );
1832        let status = dyn_view.get("status").expect("status");
1833        assert_eq!(status.enum_variant(), Some("Shipped"));
1834        let payload = status.enum_payload().expect("payload");
1835        assert_eq!(
1836            payload.get("tracking"),
1837            Some(&Dynamic::String("XYZ".to_owned())),
1838        );
1839    }
1840
1841    #[test]
1842    fn enum_tagged_round_trip() {
1843        // Tagged-Dynamic format must round-trip `Dynamic::Enum` so a
1844        // forensic log of an in-flight migration value can be
1845        // re-loaded.
1846        let value = Dynamic::Enum {
1847            variant: "Shipped".to_owned(),
1848            payload: Box::new(Dynamic::Map({
1849                let mut m = BTreeMap::new();
1850                m.insert("tracking".to_owned(), Dynamic::String("ABC".to_owned()));
1851                m
1852            })),
1853        };
1854        let bytes = value.to_postcard_bytes().expect("encode");
1855        let back = Dynamic::from_tagged_bytes(&bytes).expect("decode");
1856        assert_eq!(back, value);
1857    }
1858
1859    #[test]
1860    fn dynamic_tagged_round_trip_matches_intent() {
1861        // The tagged-Dynamic wire format is closed under round-trip:
1862        // every value encoded by `to_postcard_bytes` decodes back via
1863        // `from_postcard_bytes`. This is the contract the migration
1864        // path relies on whenever it persists an intermediate
1865        // Dynamic for forensic logging.
1866        let value = Dynamic::Seq(vec![
1867            Dynamic::Null,
1868            Dynamic::Bool(true),
1869            Dynamic::String("nested".to_owned()),
1870            Dynamic::Map({
1871                let mut m = BTreeMap::new();
1872                m.insert("k".to_owned(), Dynamic::I64(-7));
1873                m
1874            }),
1875        ]);
1876        let bytes = value.to_postcard_bytes().expect("encode");
1877        let back = Dynamic::from_tagged_bytes(&bytes).expect("decode");
1878        assert_eq!(back, value);
1879    }
1880}