Skip to main content

graphrefly_storage/
wal.rs

1//! WAL frame substrate (Phase 14.6 — DS-14-storage Q1+Q3+Q5 locks, M4.A
2//! 2026-05-10).
3//!
4//! On-disk frame format consumed by `Graph::restore_snapshot({ mode:"diff" })`
5//! (M4.E). Each frame decomposes a single graph diff into one DS-14
6//! [`BaseChange<T>`] envelope per structural-or-value change, scoped by
7//! [`Lifecycle`] so callers can narrow rewinds.
8//!
9//! The TS reference impl lives at
10//! `packages/pure-ts/src/extra/storage/wal.ts`. Field names + checksum
11//! algorithm are parity-locked — both impls produce byte-identical hex SHA-256
12//! over the same canonical-JSON encoding of a frame body.
13//!
14//! # Canonical-JSON parity
15//!
16//! TS's `stableJsonString` (`packages/pure-ts/src/extra/storage/core.ts:22-39`)
17//! is "recursively sort object keys, then `JSON.stringify(_, undefined, 0)`".
18//! The Rust port mirrors this by routing the frame body through
19//! [`serde_json::to_value`] (lands in `serde_json::Map` which is `BTreeMap` by
20//! default — sorted iteration) then [`serde_json::to_string`]. Output is
21//! byte-identical to TS for the WAL frame schema: ASCII keys, integer
22//! numerics, no floats.
23//!
24//! **Parity caveats** (lift when a real consumer surfaces):
25//! - String VALUES containing surrogate-pair code points (≥ U+10000): JS sorts
26//!   keys by UTF-16 code-unit order; Rust `BTreeMap` sorts by UTF-8 byte order.
27//!   For ASCII keys these agree; for non-BMP keys they don't. The frame
28//!   schema's keys are ASCII so this can only bite if `path` or
29//!   `change.structure` contains non-BMP code points — neither is expected
30//!   for graph identifiers.
31//! - Float-typed user payloads: JS `JSON.stringify` uses IEEE 754 with
32//!   shortest-decimal-round-trip; Rust's `serde_json` uses `ryu` which agrees
33//!   on finite f64 in safe range but may diverge on subnormals. WAL frames
34//!   typically carry integer-only data; if a user puts a float in
35//!   `change.change`, document the constraint.
36//!
37//! # Checksum
38//!
39//! SHA-256 over canonical-JSON of the frame body (everything except the
40//! `checksum` field itself), encoded as a 64-char lowercase hex string.
41//! Spec-locked at `GRAPHREFLY-SPEC.md:1201-1206` — original BLAKE3 lock was
42//! revised to SHA-256 so the TS impl could stay zero-dep (no BLAKE3 in
43//! `WebCrypto`). Rust matches via `sha2` + `hex`.
44
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48use graphrefly_structures::{BaseChange, Lifecycle};
49
50// ── WAL frame envelope ─────────────────────────────────────────────────────
51
52/// On-disk WAL frame (DS-14-storage Q1 lock).
53///
54/// Two seq fields and two timestamp fields are intentional:
55/// - [`Self::frame_seq`] ≠ `change.seq`: latter is the bundle's `mutations`
56///   cursor (DS-14 T1); former is the WAL tier's own cursor (this record's
57///   position in the WAL stream). Replay uses `frame_seq` for ordering;
58///   `change.seq` is only relevant for bundle-level cursor restoration.
59/// - [`Self::frame_t_ns`] ≠ `change.t_ns`: latter is wall-clock at mutation
60///   entry; former is wall-clock at WAL-write time. Under debounced tiers
61///   they differ by `debounce_ms`.
62///
63/// The bridge wire format (DS-14 PART 5 worker bridge) is the schema-narrowed
64/// subset `{ t, lifecycle, path, change }` — this struct is the
65/// persistence-tier superset (DS-14-storage L3 lock).
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct WALFrame<T> {
68    /// Bridge tag — discriminator shared with the DS-14 worker-bridge wire
69    /// format. Always `"c"`; allocated as `String` for parity with the TS
70    /// wire shape (TS uses a literal `"c"` value).
71    pub t: WalTag,
72    /// Lifecycle scope (DS-14 PART 4). Determines replay phase ordering.
73    pub lifecycle: Lifecycle,
74    /// Target node / bundle path (per-graph qualified path).
75    pub path: String,
76    /// DS-14 universal [`BaseChange<T>`] envelope — structure-tagged delta.
77    pub change: BaseChange<T>,
78    /// WAL-tier monotonic cursor (uniquely owned by the WAL tier writer).
79    pub frame_seq: u64,
80    /// Wall-clock at WAL-write time (matches `wall_clock_ns()`).
81    pub frame_t_ns: u64,
82    /// SHA-256 over the canonical-JSON of the frame body sans `checksum`,
83    /// encoded as a 64-char lowercase hex string. Hex (vs raw bytes) keeps
84    /// the wire format JSON-codec-friendly. M4.A parity-fixture asserts
85    /// byte-equivalence against the TS impl.
86    #[serde(default)]
87    pub checksum: String,
88    /// Codec version tag. All M4.A frames are implicitly version 1
89    /// (JSON codec). Defaults to `1` for backward-compatible deserialization
90    /// of frames written before this field was added.
91    #[serde(default = "default_format_version")]
92    pub format_version: u32,
93}
94
95fn default_format_version() -> u32 {
96    1
97}
98
99/// Singleton-string discriminator for the bridge wire-format tag. Always
100/// serializes / deserializes as `"c"`; rejects any other value at parse time.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub struct WalTag;
103
104impl WalTag {
105    pub const VALUE: &'static str = "c";
106}
107
108impl Serialize for WalTag {
109    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110        serializer.serialize_str(Self::VALUE)
111    }
112}
113
114impl<'de> Deserialize<'de> for WalTag {
115    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116        let s = String::deserialize(deserializer)?;
117        if s == Self::VALUE {
118            Ok(WalTag)
119        } else {
120            Err(serde::de::Error::custom(format!(
121                "WALFrame.t must be {:?}, got {:?}",
122                Self::VALUE,
123                s
124            )))
125        }
126    }
127}
128
129// ── Key format (Q5) ────────────────────────────────────────────────────────
130
131/// Default WAL prefix segment relative to a `graph.name`. Frames land at
132/// `${graph.name}/${WAL_KEY_SEGMENT}/${frame_seq:020}`.
133pub const WAL_KEY_SEGMENT: &str = "wal";
134
135/// Pad width for `frame_seq` in WAL keys. 20 digits keeps lex-ASC string sort
136/// = numeric ASC up to `frame_seq < 10^20` (well past `u64::MAX`).
137pub const WAL_FRAME_SEQ_PAD: usize = 20;
138
139/// Build the canonical WAL frame key. `prefix` is the WAL-prefix portion (e.g.
140/// `"my-graph/wal"`); `frame_seq` is the per-frame cursor. Zero-padded so
141/// lex-ASC string sort equals numeric ASC sort.
142#[must_use]
143pub fn wal_frame_key(prefix: &str, frame_seq: u64) -> String {
144    format!("{prefix}/{frame_seq:020}")
145}
146
147/// Default WAL key prefix for a graph by its `name`.
148#[must_use]
149pub fn graph_wal_prefix(graph_name: &str) -> String {
150    format!("{graph_name}/{WAL_KEY_SEGMENT}")
151}
152
153// ── Replay order (Q2) ──────────────────────────────────────────────────────
154
155/// Cross-scope replay order (DS-14 PART 4 lock — `Spec → Data → Ownership`).
156/// Exported so the replay implementation and parity tests share one source of
157/// truth.
158pub const REPLAY_ORDER: [Lifecycle; 3] = [Lifecycle::Spec, Lifecycle::Data, Lifecycle::Ownership];
159
160// ── Checksum ───────────────────────────────────────────────────────────────
161
162/// Errors surfaced by checksum compute / verify.
163#[derive(Debug, thiserror::Error)]
164pub enum ChecksumError {
165    /// `serde_json` rejected the frame body — typically a non-serializable
166    /// payload (e.g. a `Map` with non-string keys, an `f64::NAN`).
167    #[error("canonical JSON encoding failed: {0}")]
168    CanonicalJsonFailed(#[from] serde_json::Error),
169}
170
171/// Body fields contributing to the checksum, in the shape TS computes over
172/// (TS's `canonicalFrameBody` at `wal.ts:141`). The `checksum` field of the
173/// outer [`WALFrame`] is deliberately excluded.
174#[derive(Serialize)]
175struct ChecksumBody<'a, T: Serialize> {
176    t: &'static str,
177    lifecycle: &'a Lifecycle,
178    path: &'a str,
179    change: &'a BaseChange<T>,
180    frame_seq: u64,
181    frame_t_ns: u64,
182}
183
184/// Encode a typed value to canonical JSON (sorted keys, no whitespace).
185/// Routes through [`serde_json::Value`] so the resulting `serde_json::Map<
186/// String, Value>` (BTreeMap-backed by default) iterates in sorted-key order
187/// — byte-identical to TS `stableJsonString` on the WAL schema.
188fn canonical_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
189    let v = serde_json::to_value(value)?;
190    serde_json::to_string(&v)
191}
192
193/// Compute the SHA-256 checksum over a frame's body (sans `checksum`),
194/// returning a 64-char lowercase hex string. Parity-locked with TS
195/// `walFrameChecksum`.
196pub fn wal_frame_checksum<T: Serialize>(frame: &WALFrame<T>) -> Result<String, ChecksumError> {
197    let body = ChecksumBody {
198        t: WalTag::VALUE,
199        lifecycle: &frame.lifecycle,
200        path: frame.path.as_str(),
201        change: &frame.change,
202        frame_seq: frame.frame_seq,
203        frame_t_ns: frame.frame_t_ns,
204    };
205    let canonical = canonical_json(&body)?;
206    let digest = Sha256::digest(canonical.as_bytes());
207    Ok(hex::encode(digest))
208}
209
210/// Verify a frame's `checksum` field matches its body. Replay invokes this at
211/// the WAL tail (drop on mismatch by default) and mid-stream (abort on
212/// mismatch by default) per Q3.
213pub fn verify_wal_frame_checksum<T: Serialize>(frame: &WALFrame<T>) -> Result<bool, ChecksumError> {
214    let expected = wal_frame_checksum(frame)?;
215    Ok(frame.checksum == expected)
216}
217
218// ── Tests ──────────────────────────────────────────────────────────────────
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use graphrefly_structures::Version;
224
225    fn sample_frame() -> WALFrame<u64> {
226        WALFrame {
227            t: WalTag,
228            lifecycle: Lifecycle::Data,
229            path: "root/state".into(),
230            change: BaseChange {
231                structure: "graphValue".into(),
232                version: Version::Counter(1),
233                t_ns: 1_700_000_000_000,
234                seq: Some(0),
235                lifecycle: Lifecycle::Data,
236                change: 42,
237            },
238            frame_seq: 17,
239            frame_t_ns: 1_700_000_001_000,
240            checksum: String::new(),
241            format_version: 1,
242        }
243    }
244
245    #[test]
246    fn wal_frame_key_zero_pads_to_20_digits() {
247        assert_eq!(wal_frame_key("g/wal", 0), "g/wal/00000000000000000000",);
248        assert_eq!(wal_frame_key("g/wal", 17), "g/wal/00000000000000000017",);
249        assert_eq!(
250            wal_frame_key("g/wal", u64::MAX),
251            format!("g/wal/{:020}", u64::MAX),
252        );
253    }
254
255    #[test]
256    fn wal_frame_key_lex_sort_equals_numeric_sort() {
257        // Build keys for 0, 1, 10, 100, u64::MAX. Sort lex; assert numeric
258        // order is preserved (the core invariant `frame_seq` ASC = lex ASC).
259        let seqs = [0u64, 1, 10, 100, 1_000_000, u64::MAX];
260        let mut keys: Vec<String> = seqs.iter().map(|s| wal_frame_key("g/wal", *s)).collect();
261        keys.sort();
262        for (k, expected) in keys.iter().zip(seqs.iter()) {
263            assert!(
264                k.ends_with(&format!("{expected:020}")),
265                "lex-sort key {k} did not match numeric order for {expected}",
266            );
267        }
268    }
269
270    #[test]
271    fn graph_wal_prefix_joins_with_segment() {
272        assert_eq!(graph_wal_prefix("my-graph"), "my-graph/wal");
273    }
274
275    #[test]
276    fn checksum_roundtrip_verifies() {
277        let mut frame = sample_frame();
278        frame.checksum = wal_frame_checksum(&frame).unwrap();
279        assert!(verify_wal_frame_checksum(&frame).unwrap());
280    }
281
282    #[test]
283    fn checksum_tamper_change_payload_fails_verify() {
284        let mut frame = sample_frame();
285        frame.checksum = wal_frame_checksum(&frame).unwrap();
286        frame.change.change = 43; // tamper the user payload
287        assert!(!verify_wal_frame_checksum(&frame).unwrap());
288    }
289
290    #[test]
291    fn checksum_tamper_path_fails_verify() {
292        let mut frame = sample_frame();
293        frame.checksum = wal_frame_checksum(&frame).unwrap();
294        frame.path = "different/path".into();
295        assert!(!verify_wal_frame_checksum(&frame).unwrap());
296    }
297
298    #[test]
299    fn checksum_tamper_frame_seq_fails_verify() {
300        let mut frame = sample_frame();
301        frame.checksum = wal_frame_checksum(&frame).unwrap();
302        frame.frame_seq = 18;
303        assert!(!verify_wal_frame_checksum(&frame).unwrap());
304    }
305
306    #[test]
307    fn checksum_excludes_checksum_field_itself() {
308        let mut frame = sample_frame();
309        frame.checksum = "deadbeef".repeat(8);
310        let first = wal_frame_checksum(&frame).unwrap();
311        frame.checksum = "00".repeat(32);
312        let second = wal_frame_checksum(&frame).unwrap();
313        assert_eq!(
314            first, second,
315            "wal_frame_checksum must not depend on the existing checksum field",
316        );
317    }
318
319    #[test]
320    fn checksum_is_64_char_lowercase_hex() {
321        let mut frame = sample_frame();
322        frame.checksum = wal_frame_checksum(&frame).unwrap();
323        assert_eq!(frame.checksum.len(), 64);
324        assert!(
325            frame
326                .checksum
327                .chars()
328                .all(|c| matches!(c, '0'..='9' | 'a'..='f')),
329            "checksum must be lowercase hex: {}",
330            frame.checksum,
331        );
332    }
333
334    #[test]
335    fn wal_tag_serializes_as_string_c() {
336        let s = serde_json::to_string(&WalTag).unwrap();
337        assert_eq!(s, "\"c\"");
338    }
339
340    #[test]
341    fn wal_tag_rejects_other_values() {
342        let r: Result<WalTag, _> = serde_json::from_str("\"x\"");
343        assert!(r.is_err(), "WalTag must reject non-c discriminators");
344    }
345
346    #[test]
347    fn canonical_json_sorts_keys() {
348        // Canonical-JSON sanity check on a struct with declaration order
349        // OPPOSITE to alphabetical: `zebra, monkey, apple`. The emitted JSON
350        // must list keys in alphabetical order regardless of declaration
351        // order (mirrors TS `stableJsonString` recursive key sort). Single
352        // level only so `find` matches unambiguously.
353        #[derive(Serialize)]
354        struct Flat {
355            zebra: u32,
356            monkey: u32,
357            apple: u32,
358        }
359        let json = canonical_json(&Flat {
360            zebra: 1,
361            monkey: 2,
362            apple: 3,
363        })
364        .unwrap();
365        assert_eq!(json, "{\"apple\":3,\"monkey\":2,\"zebra\":1}");
366    }
367
368    /// Cross-impl parity fixture.
369    ///
370    /// This is the parity-or-bust check: a hand-computed canonical-JSON +
371    /// SHA-256 fixture sourced from running TS's `walFrameChecksum` on the
372    /// same input. If the Rust impl drifts from byte-identical TS output,
373    /// this test fails loudly.
374    ///
375    /// Fixture inputs are deliberately minimal (single `u64` change payload)
376    /// so the expected canonical bytes are auditable by hand.
377    #[test]
378    fn checksum_parity_fixture_minimal_frame() {
379        // Frame body:
380        //   { t:"c", lifecycle:"data", path:"p",
381        //     change:{ change:0, lifecycle:"data", structure:"s", t_ns:0, version:0 },
382        //     frame_seq:0, frame_t_ns:0 }
383        //
384        // Canonical (sorted-key, no whitespace) form:
385        //   {"change":{"change":0,"lifecycle":"data","structure":"s","t_ns":0,"version":0},"frame_seq":0,"frame_t_ns":0,"lifecycle":"data","path":"p","t":"c"}
386        //
387        // SHA-256 over those bytes is checked below. Regenerate via:
388        //   python3 -c 'import hashlib; print(hashlib.sha256(b\'{"change":...}\').hexdigest())'
389        let frame: WALFrame<u64> = WALFrame {
390            t: WalTag,
391            lifecycle: Lifecycle::Data,
392            path: "p".into(),
393            change: BaseChange {
394                structure: "s".into(),
395                version: Version::Counter(0),
396                t_ns: 0,
397                seq: None,
398                lifecycle: Lifecycle::Data,
399                change: 0,
400            },
401            frame_seq: 0,
402            frame_t_ns: 0,
403            checksum: String::new(),
404            format_version: 1,
405        };
406        let computed = wal_frame_checksum(&frame).unwrap();
407
408        // Sanity: confirm the canonical body the Rust impl is hashing.
409        let body = ChecksumBody {
410            t: WalTag::VALUE,
411            lifecycle: &frame.lifecycle,
412            path: frame.path.as_str(),
413            change: &frame.change,
414            frame_seq: frame.frame_seq,
415            frame_t_ns: frame.frame_t_ns,
416        };
417        let canonical = canonical_json(&body).unwrap();
418        let expected_canonical = "{\"change\":{\"change\":0,\"lifecycle\":\"data\",\"structure\":\"s\",\"t_ns\":0,\"version\":0},\"frame_seq\":0,\"frame_t_ns\":0,\"lifecycle\":\"data\",\"path\":\"p\",\"t\":\"c\"}";
419        assert_eq!(
420            canonical, expected_canonical,
421            "canonical JSON drifted from TS-side stableJsonString shape",
422        );
423
424        // SHA-256 hex of the canonical bytes above (computed via shell:
425        // `printf '<canonical>' | shasum -a 256`).
426        let expected_sha = "d00054d7886e1d73c07a0086e5cbccddf62de3c0cadae31e75d78215b3293ece";
427        assert_eq!(
428            computed, expected_sha,
429            "SHA-256 hex drifted; canonical bytes were:\n  {canonical}",
430        );
431    }
432
433    /// /qa A5 (2026-05-10): parity-fixture for `Lifecycle::Spec` — locks
434    /// the canonical-JSON byte shape and SHA-256 for the `"spec"` discriminant.
435    #[test]
436    fn checksum_parity_fixture_lifecycle_spec() {
437        let frame: WALFrame<u64> = WALFrame {
438            t: WalTag,
439            lifecycle: Lifecycle::Spec,
440            path: "p".into(),
441            change: BaseChange {
442                structure: "s".into(),
443                version: Version::Counter(0),
444                t_ns: 0,
445                seq: None,
446                lifecycle: Lifecycle::Spec,
447                change: 0,
448            },
449            frame_seq: 0,
450            frame_t_ns: 0,
451            checksum: String::new(),
452            format_version: 1,
453        };
454        let expected_sha = "7e857f0862bd429d7d144980a2580da732e0d4b420a03d73d63462368f896c3b";
455        assert_eq!(wal_frame_checksum(&frame).unwrap(), expected_sha);
456    }
457
458    /// /qa A5 (2026-05-10): parity-fixture for `Lifecycle::Ownership`.
459    #[test]
460    fn checksum_parity_fixture_lifecycle_ownership() {
461        let frame: WALFrame<u64> = WALFrame {
462            t: WalTag,
463            lifecycle: Lifecycle::Ownership,
464            path: "p".into(),
465            change: BaseChange {
466                structure: "s".into(),
467                version: Version::Counter(0),
468                t_ns: 0,
469                seq: None,
470                lifecycle: Lifecycle::Ownership,
471                change: 0,
472            },
473            frame_seq: 0,
474            frame_t_ns: 0,
475            checksum: String::new(),
476            format_version: 1,
477        };
478        let expected_sha = "901d3d70d38d954864243bdee5a88cb6d204e5e9823598606d38c10e604c3af4";
479        assert_eq!(wal_frame_checksum(&frame).unwrap(), expected_sha);
480    }
481
482    /// /qa A6 (2026-05-10): parity-fixture for `seq: Some(0)`. The
483    /// `skip_serializing_if` attribute means `None` omits the field; `Some(0)`
484    /// emits `"seq":0`. Both round-trip cleanly. Distinct SHA from the
485    /// `seq: None` fixture above proves the canonical body differs.
486    #[test]
487    fn checksum_parity_fixture_seq_some_zero() {
488        let frame: WALFrame<u64> = WALFrame {
489            t: WalTag,
490            lifecycle: Lifecycle::Data,
491            path: "p".into(),
492            change: BaseChange {
493                structure: "s".into(),
494                version: Version::Counter(0),
495                t_ns: 0,
496                seq: Some(0),
497                lifecycle: Lifecycle::Data,
498                change: 0,
499            },
500            frame_seq: 0,
501            frame_t_ns: 0,
502            checksum: String::new(),
503            format_version: 1,
504        };
505        let expected_sha = "da42bdfa3eff9dbb7ffc60b04c7478cbe7cbb7015ba48963b4ea4661f678c387";
506        assert_eq!(wal_frame_checksum(&frame).unwrap(), expected_sha);
507    }
508
509    /// /qa A7 (2026-05-10): `WalTag` deserialization rejects non-string JSON
510    /// tokens (null, number, array, object) with a clear error — not just
511    /// other string values.
512    #[test]
513    fn wal_tag_rejects_non_string_tokens() {
514        for bad in ["null", "42", "[]", "{}", "true"] {
515            let r: Result<WalTag, _> = serde_json::from_str(bad);
516            assert!(r.is_err(), "WalTag must reject {bad}");
517        }
518    }
519
520    /// /qa A13 (2026-05-10): sanity-check the `WALFrame<T>` shape for two
521    /// non-trivial payload types — unit `()` and `serde_json::Value` (the
522    /// "any JSON" escape hatch). Both must round-trip with stable checksums.
523    #[test]
524    fn wal_frame_unit_payload_round_trips() {
525        let frame: WALFrame<()> = WALFrame {
526            t: WalTag,
527            lifecycle: Lifecycle::Data,
528            path: "p".into(),
529            change: BaseChange {
530                structure: "unit".into(),
531                version: Version::Counter(0),
532                t_ns: 0,
533                seq: None,
534                lifecycle: Lifecycle::Data,
535                change: (),
536            },
537            frame_seq: 0,
538            frame_t_ns: 0,
539            checksum: String::new(),
540            format_version: 1,
541        };
542        let mut f = frame.clone();
543        f.checksum = wal_frame_checksum(&frame).unwrap();
544        assert!(verify_wal_frame_checksum(&f).unwrap());
545    }
546
547    #[test]
548    fn wal_frame_value_payload_round_trips() {
549        use serde_json::json;
550        let payload = json!({"kind": "set", "key": "k1", "value": [1, 2, 3]});
551        let frame: WALFrame<serde_json::Value> = WALFrame {
552            t: WalTag,
553            lifecycle: Lifecycle::Data,
554            path: "node/state".into(),
555            change: BaseChange {
556                structure: "graphValue".into(),
557                version: Version::Counter(1),
558                t_ns: 100,
559                seq: Some(7),
560                lifecycle: Lifecycle::Data,
561                change: payload,
562            },
563            frame_seq: 17,
564            frame_t_ns: 200,
565            checksum: String::new(),
566            format_version: 1,
567        };
568        let mut f = frame.clone();
569        f.checksum = wal_frame_checksum(&frame).unwrap();
570        assert!(verify_wal_frame_checksum(&f).unwrap());
571    }
572
573    /// /qa F5 (2026-05-12): backward-compatible deserialization of
574    /// pre-`format_version` frames. Old frames serialized WITHOUT the
575    /// `format_version` field must deserialize successfully with
576    /// `format_version` defaulting to `1`.
577    #[test]
578    fn format_version_defaults_on_old_frame_json() {
579        // JSON from a pre-format_version frame (no `format_version` key).
580        let old_json = r#"{
581            "t": "c",
582            "lifecycle": "data",
583            "path": "p",
584            "change": {
585                "structure": "s",
586                "version": 0,
587                "t_ns": 0,
588                "lifecycle": "data",
589                "change": 0
590            },
591            "frame_seq": 0,
592            "frame_t_ns": 0,
593            "checksum": ""
594        }"#;
595        let frame: WALFrame<u64> = serde_json::from_str(old_json).unwrap();
596        assert_eq!(
597            frame.format_version, 1,
598            "missing format_version must default to 1"
599        );
600    }
601
602    /// /qa F5 (2026-05-12): new frames with explicit `format_version`
603    /// round-trip correctly.
604    #[test]
605    fn format_version_round_trips() {
606        let frame = WALFrame {
607            t: WalTag,
608            lifecycle: Lifecycle::Data,
609            path: "p".into(),
610            change: BaseChange {
611                structure: "s".into(),
612                version: Version::Counter(0),
613                t_ns: 0,
614                seq: None,
615                lifecycle: Lifecycle::Data,
616                change: 0u64,
617            },
618            frame_seq: 0,
619            frame_t_ns: 0,
620            checksum: String::new(),
621            format_version: 2,
622        };
623        let json = serde_json::to_string(&frame).unwrap();
624        let deser: WALFrame<u64> = serde_json::from_str(&json).unwrap();
625        assert_eq!(deser.format_version, 2);
626    }
627
628    /// /qa A10 (2026-05-10): canary detecting `serde_json/preserve_order`
629    /// feature unification. The canonical-JSON parity invariant requires
630    /// `serde_json::Map<String, Value>` to be `BTreeMap`-backed (sorted on
631    /// iter). If any workspace consumer enables `preserve_order` via Cargo
632    /// feature unification, `Map` swaps to `IndexMap` (insertion-order) and
633    /// this test fails loudly with a diff.
634    #[test]
635    fn preserve_order_feature_is_not_enabled() {
636        // Build a Value::Object with INSERTION ORDER = reverse-alphabetical.
637        // BTreeMap-backed Map iterates in alphabetical order on `to_string`.
638        // IndexMap-backed Map preserves insertion order.
639        let mut map = serde_json::Map::new();
640        map.insert("z".into(), serde_json::json!(1));
641        map.insert("a".into(), serde_json::json!(2));
642        let serialized = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
643        assert_eq!(
644            serialized, r#"{"a":2,"z":1}"#,
645            "serde_json `preserve_order` feature appears to be enabled \
646             workspace-wide via Cargo feature unification — this BREAKS the \
647             WAL checksum canonical-JSON parity invariant. Find the offending \
648             dep with `cargo tree -e features | grep preserve_order` and \
649             either disable it or pin to a non-preserve-order codec route.",
650        );
651    }
652}