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}