Skip to main content

brink_format/
save.rs

1//! Persistent, name-keyed save state for a story's game state.
2//!
3//! [`SaveState`] is the **durable** save format: globals, visit/turn counts,
4//! turn index, and RNG, keyed by stable identities (variable name; scope
5//! [`DefinitionId`]). It captures *game state only* — not execution position
6//! (call stack / PC) — via name-stable identities, so a save survives a story
7//! recompile/patch as long as the relevant names/paths are unchanged. The
8//! runtime (`Story::save_state` / `load_state`) produces and reconciles it.
9//! See `docs/external-binding-foundation.md`.
10//!
11//! **FS-1** (`docs/flow-suspension-spec.md` §2/§9, format-only slice) adds
12//! [`Self::suspended`]: a `FlowFrame` — the durable representation of *this*
13//! flow's execution position when parked mid-tunnel/mid-`await` — using the
14//! same name-stable-identity discipline as everything else here (container
15//! [`DefinitionId`]s, never instruction offsets), so it survives a recompile
16//! exactly like globals/visits do. This is currently a pure format addition:
17//! `Story::save_state`/`load_state` (this module's runtime counterpart)
18//! always produce/consume `None` — the compiler synthesis that populates a
19//! live frame (FS-2) and the runtime spill/restore that produces or consumes
20//! one (FS-3) are later slices.
21
22use alloc::collections::BTreeMap;
23use alloc::string::String;
24use alloc::vec::Vec;
25
26use serde::{Deserialize, Serialize};
27
28use crate::{DefinitionId, Value};
29
30/// Current [`SaveState`] format version. Bump when the *format* changes
31/// (independent of the story's own content); `version` lets a loader migrate.
32pub const SAVE_FORMAT_VERSION: u16 = 1;
33
34/// A persistent, name-keyed snapshot of a story's game state.
35///
36/// Globals are keyed by variable name; visit/turn counts by scope
37/// [`DefinitionId`] (which serializes as a stable `"$tt_hash"` string), with an
38/// advisory author path attached when the scope is named.
39#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
40pub struct SaveState {
41    /// Save-format version (see [`SAVE_FORMAT_VERSION`]).
42    pub version: u16,
43    /// Global variables by name. `BTreeMap` for deterministic serialization.
44    pub globals: BTreeMap<String, Value>,
45    /// Each saved global's compiled `DefinitionId` at save time, keyed by
46    /// the same name as [`Self::globals`] (M-3 rehydration miss-path lookup,
47    /// `docs/modules-spec.md` §5). A VAR/CONST/LIST living in a **declared**
48    /// module hashes its identity as `(module, name)`, so a bare name alone
49    /// can't reconstruct the id a `#@was` alias-table entry was compiled
50    /// against — this is the "module qualifier" the miss path needs,
51    /// round-tripped as the id itself rather than the module name, so no
52    /// hashing scheme has to be re-derived at load time. Consulted only when
53    /// a saved global's name no longer matches any current global slot;
54    /// absent entries (older saves predating this field) simply fall back
55    /// to the pre-M-3 unknown-global report, same tolerant-of-patches
56    /// behavior as before. `#[serde(default)]` so an older save missing
57    /// this field entirely still deserializes.
58    #[serde(default)]
59    pub global_ids: BTreeMap<String, DefinitionId>,
60    /// Visit counts by scope id, sorted by id for deterministic output.
61    pub visits: Vec<VisitEntry>,
62    /// Turn-since counts by scope id, sorted by id.
63    pub turns: Vec<VisitEntry>,
64    /// Global turn index.
65    pub turn_index: u32,
66    /// RNG seed.
67    pub rng_seed: i32,
68    /// Last drawn random value (so the RNG sequence resumes correctly).
69    pub previous_random: i32,
70    /// This flow's `FlowFrame` when parked mid-tunnel/mid-`await`
71    /// (`docs/flow-suspension-spec.md` §2/§9, FS-1). Absent when the flow
72    /// isn't suspended — an ordinary save at a turn boundary, choice, or
73    /// `-> END` has no execution position to capture, same as before this
74    /// field existed. `#[serde(default)]` so an older save predating this
75    /// field still deserializes; `skip_serializing_if` keeps an unsuspended
76    /// save's wire form byte-identical to before (no `"suspended": null`
77    /// noise).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub suspended: Option<SuspendedFlow>,
80}
81
82/// One visit/turn-count entry: a scope id and its count, plus (when the scope
83/// is a named knot/stitch) an advisory author path for human inspection. The
84/// `id` is the load key; `path` is cosmetic.
85#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
86pub struct VisitEntry {
87    /// The counted scope's definition id (load key).
88    pub id: DefinitionId,
89    /// Author path for a named scope, e.g. `"forest.clearing"`. Absent for
90    /// anonymous counted containers (gathers, choice points).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub path: Option<String>,
93    /// The count.
94    pub count: u32,
95}
96
97/// Current suspended-flow section version, versioned independently of
98/// [`SAVE_FORMAT_VERSION`] (`docs/flow-suspension-spec.md` §9 FS-1: "one new
99/// suspended-flow section, section-locally versioned"). Bump when the
100/// [`SuspendedFlow`] shape itself changes; the rest of [`SaveState`] is
101/// unaffected.
102///
103/// Bumped to 2 for #2108's block-run fields ([`SuspendedFlow::next_block_id`]/
104/// [`SuspendedFlow::pending_element`], 2026-08-05 ruling — see their own
105/// docs). Both new fields carry `#[serde(default)]`, so a version-1 JSON
106/// blob still decodes (a fresh `0`/empty map, exactly the pre-ruling
107/// behavior for a save predating this field); the version bump is a
108/// legibility signal for a reader inspecting the section, not a decode
109/// requirement.
110pub const SUSPENDED_FLOW_SECTION_VERSION: u16 = 2;
111
112/// The `FlowFrame` — a parked flow's durable, recompile-stable representation
113/// (`docs/flow-suspension-spec.md` §2, RULED). No instruction offsets ever
114/// serialize; recompile-stability rides container/[`DefinitionId`] identity,
115/// the same contract as the rest of [`SaveState`], `#@was`, and fn tokens.
116///
117/// FS-1 is format-only: this type's writer and reader are exercised today
118/// only by round-trip tests (`crates/internal/brink-format/tests/`). The
119/// compiler synthesis that populates [`Self::frame`] (FS-2) and the runtime
120/// spill/restore that produces or consumes a live value (FS-3) are later
121/// slices (`docs/flow-suspension-spec.md` §9).
122#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
123pub struct SuspendedFlow {
124    /// Section-local format version (see [`SUSPENDED_FLOW_SECTION_VERSION`]).
125    pub version: u16,
126    /// The container the flow is currently parked inside (name-stable; §2
127    /// point 1).
128    pub current: DefinitionId,
129    /// The tunnel-return chain, outermost first (name-stable; §2 point 2).
130    /// Depth-capped at the runtime layer (FS-3; §7 "recursive awaiting
131    /// tunnels" — a park-depth limit, sibling of the VM step limit) — the
132    /// format itself imposes no bound.
133    pub return_stack: Vec<DefinitionId>,
134    /// Every local crossing a yield, name-keyed — a plain [`Value`]
135    /// (typically [`Value::map`]), serialized by the existing `Value`
136    /// encoders with no new wire representation (§2 point 3). Frame-shape
137    /// drift across a recompile (a tunnel's crossing-locals set changes)
138    /// rides the standard name-keyed rehydration discipline — missing
139    /// field → default, extra field → dropped, renamed field → treated as
140    /// missing, each reported rather than silently swallowed. This is
141    /// tolerant *decode*, which is FS-3 runtime scope; FS-1's job is making
142    /// sure the encoding is name-keyed (never positional) so that decode is
143    /// possible at all — see this module's
144    /// `suspended_flow_frame_drift_is_representable` test.
145    pub frame: Value,
146    /// The wake policy governing when the parked flow resumes (§2 point 4).
147    pub wake: WakePolicy,
148    /// The flow's `next_block_id` counter (`brink_runtime`'s
149    /// `Flow::next_block_id`, runtime-side) at the instant it was parked
150    /// (§2 point 5, #2108,
151    /// 2026-08-05 ruling: *"block metadata persists, and `next_block_id`
152    /// persists with it"*). A parked flow's block-run numbering must survive
153    /// the save/resume boundary — unlike an ordinary (non-suspended) save,
154    /// where the host re-enters at a known knot and a fresh `0` is harmless
155    /// because nothing compares an id across that boundary, a *resumed*
156    /// flow continues executing from the exact point it parked, so its
157    /// block-id sequence must continue rather than collide with fresh
158    /// numbering starting over. `#[serde(default)]`: a save predating this
159    /// field decodes as `0`, identical to the pre-ruling behavior it
160    /// replaces.
161    #[serde(default)]
162    pub next_block_id: u64,
163    /// The element-attachment metadata (`@[convention(..., attach = X)]`,
164    /// #2108) accumulated on the run that was open at the instant this flow
165    /// parked — empty when no attach run was open (§2 point 5). A block is
166    /// not just lines: executable statements can interleave with an
167    /// attach-scoped dialogue run, so an `await` can suspend *inside* one
168    /// (`Step::Suspended` is not a `BlockId` run-terminator — see
169    /// `brink_runtime::story::BlockId`'s own doc). Persisting this alongside
170    /// [`Self::next_block_id`] is what lets a resumed flow keep attributing
171    /// the lines it emits after wake to the same speaker/attachment,
172    /// instead of the run's data silently resetting to empty on resume — the
173    /// player-visible loss the ruling explicitly refused to ship.
174    /// `#[serde(default)]` (older save decodes as empty — no open run to
175    /// restore, matching pre-ruling behavior); `skip_serializing_if` keeps
176    /// the common case (no run open at park time) free of wire noise.
177    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178    pub pending_element: BTreeMap<String, String>,
179}
180
181/// A parked flow's wake policy (`docs/flow-suspension-spec.md` §2 point 4):
182/// await-site id + condition fn token + host-source discriminant, all
183/// name-stable. See `docs/effects-spec.md` §13.1 for the wake contract this
184/// plugs into (persistent-by-default policies, `wake_once`, host
185/// cancellation) — that contract's runtime enforcement is FS-3/FS-4 scope;
186/// this type only carries the wire shape.
187#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
188pub struct WakePolicy {
189    /// The `await` site's synthesized resume-container id — site-stable
190    /// identity, so the general anonymous-fn identity problem does not
191    /// apply here (`docs/flow-suspension-spec.md` §3).
192    pub site: DefinitionId,
193    /// The condition's compiler-synthesized pure-fn token
194    /// (`docs/flow-suspension-spec.md` §3: "direct-expression conditions
195    /// capture as compiler-synthesized pure fns"). Absent for a policy whose
196    /// [`Self::source`] is [`WakeSource::Host`] — a host-driven wake trigger
197    /// (next-frame, external event) has no compiled ink condition fn to
198    /// token (§3: "an ink spelling for them is PROPOSED-only").
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub condition: Option<DefinitionId>,
201    /// Where the wake nudge originates.
202    pub source: WakeSource,
203}
204
205/// The wake policy's host-source discriminant
206/// (`docs/flow-suspension-spec.md` §2 point 4; `docs/effects-spec.md`
207/// §13.1). FS-1 records the discriminant only — the host-side wake plumbing
208/// (`wake_when`, dormant spawn, cancellation-to-false) is FS-4 scope; today
209/// only [`Self::Condition`] is ever compiler-produced, but the format
210/// reserves [`Self::Host`] for the host-driven wake sources §3 names as a
211/// future (PROPOSED-only) ink spelling, so the wire shape doesn't need a
212/// breaking change to add it later.
213#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
214pub enum WakeSource {
215    /// An ink-authored `await <condition>` / `while await <condition>` — the
216    /// compiled condition fn ([`WakePolicy::condition`]) drives
217    /// re-evaluation per the wake contract (`docs/effects-spec.md` §13.1).
218    Condition,
219    /// A host-driven wake source (e.g. next-frame) with no compiled ink
220    /// condition fn — the host owns re-evaluation directly (§3, §13.1).
221    Host,
222}
223
224/// What `Story::load_state` couldn't apply, so a host can surface it rather
225/// than have data silently vanish. Globals whose name no longer exists are
226/// **dropped** (no slot to hold them) and reported here. Visit/turn counts are
227/// never dropped — counts for scopes the current program lacks are retained
228/// harmlessly (unused until/unless the scope returns), so they aren't reported
229/// *except* when the miss-path alias lookup (M-3, docs/modules-spec.md §5)
230/// still can't place them — see `unresolved_renames` for a **named** scope,
231/// or `anonymous_states_dropped` for an anonymous one.
232#[derive(Default, Clone, Debug, PartialEq, Serialize)]
233pub struct LoadReport {
234    /// Saved global names with no matching global in the current program.
235    pub unknown_globals: Vec<String>,
236    /// M-3 rehydration miss-path teaching messages (docs/modules-spec.md
237    /// §5): a saved fn token, divert value, or visit/turn-count key that
238    /// didn't match the current program even after consulting the compiled
239    /// `#@was` alias table. Only populated for a program that actually
240    /// carries alias-table entries (i.e. uses `#@was` somewhere) — an
241    /// ordinary content edit with no rename directive stays silent, same
242    /// as before M-3.
243    pub unresolved_renames: Vec<String>,
244    /// Count of saved visit/turn-count entries for an **anonymous** scope
245    /// (`VisitEntry::path` is `None` — a gather or choice point with no
246    /// author label, or a sequence, none of which have a name an `#@was`
247    /// alias table entry could ever be written against) that could not be
248    /// placed in the current program (issue #1674, gap 4 of the identity
249    /// cluster: "anonymous-container state is bounded and reported, not
250    /// solved"). Unlike `unresolved_renames`, this is populated
251    /// unconditionally on a miss — an anonymous scope's positional id can
252    /// never be recovered through the alias table regardless of whether the
253    /// program uses `#@was` elsewhere, so gating this on
254    /// `Program::has_aliases` (the way `unresolved_renames` is) would make
255    /// it silent for the overwhelming majority of projects, defeating the
256    /// point.
257    ///
258    /// The bounded fallout `docs/decision-log.md`'s 2026-07-27 ruling
259    /// measured: a once-only choice may reappear as if never chosen, or a
260    /// sequence may restart from its first branch. Each miss increments
261    /// this once per saved entry — a scope with both a visit *and* a turn
262    /// count that both go unresolved counts as two, since each is
263    /// independently lost state, not one.
264    ///
265    /// That "bounded" measurement covers the visit/turn-count half only.
266    /// The same day's "CORRECTION to the R1 entry" widened the blast
267    /// radius: anonymous scopes also carry translation units (intl exports
268    /// keyed by scope id, `Option`-named), which this field does not
269    /// account for — it counts only what `Story::load_state` itself drops.
270    pub anonymous_states_dropped: u32,
271}
272
273impl LoadReport {
274    /// Whether the load applied cleanly (nothing dropped, nothing left
275    /// unresolved after the rename miss-path lookup, no anonymous state
276    /// orphaned).
277    #[must_use]
278    pub fn is_clean(&self) -> bool {
279        self.unknown_globals.is_empty()
280            && self.unresolved_renames.is_empty()
281            && self.anonymous_states_dropped == 0
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::id::DefinitionTag;
289    use crate::value::{MapKey, OrderedMap};
290
291    /// A `SaveState` whose globals hold collection values must round-trip
292    /// through serde as full trees — no lossy fold to null (T1a-3 / #525). The
293    /// `globals` map is a `BTreeMap` (name-keyed, deterministic order), and
294    /// each `Value` serializes through its own derived tree encoding, so a
295    /// map-of-array global survives a save/load byte-for-byte.
296    #[test]
297    fn save_state_round_trips_collection_globals() {
298        let inventory: OrderedMap = [
299            (
300                MapKey::from("weapons"),
301                Value::array(vec![Value::from("sword"), Value::from("bow")]),
302            ),
303            (MapKey::from("gold"), Value::Int(42)),
304        ]
305        .into_iter()
306        .collect();
307
308        let mut globals = BTreeMap::new();
309        globals.insert(String::from("inventory"), Value::map(inventory));
310        globals.insert(
311            String::from("scores"),
312            Value::array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
313        );
314
315        let save = SaveState {
316            version: SAVE_FORMAT_VERSION,
317            globals,
318            global_ids: BTreeMap::new(),
319            visits: Vec::new(),
320            turns: Vec::new(),
321            turn_index: 0,
322            rng_seed: 7,
323            previous_random: 0,
324            suspended: None,
325        };
326
327        let json = serde_json::to_string(&save).expect("serialize save");
328        let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
329        assert_eq!(back, save);
330    }
331
332    /// A `SaveState` with no suspended flow serializes with no `"suspended"`
333    /// key at all (`skip_serializing_if`) — an unsuspended save's wire form
334    /// is byte-identical to the pre-FS-1 shape, and an older save missing the
335    /// key entirely still deserializes via `#[serde(default)]`.
336    #[test]
337    fn suspended_absent_by_default_and_omitted_from_wire() {
338        let save = SaveState {
339            version: SAVE_FORMAT_VERSION,
340            globals: BTreeMap::new(),
341            global_ids: BTreeMap::new(),
342            visits: Vec::new(),
343            turns: Vec::new(),
344            turn_index: 0,
345            rng_seed: 0,
346            previous_random: 0,
347            suspended: None,
348        };
349
350        let json = serde_json::to_string(&save).expect("serialize save");
351        assert!(
352            !json.contains("suspended"),
353            "unsuspended save must omit the key entirely: {json}"
354        );
355
356        // An older save's JSON, predating the field, deserializes unchanged.
357        let old_json = r#"{"version":1,"globals":{},"global_ids":{},"visits":[],"turns":[],"turn_index":0,"rng_seed":0,"previous_random":0}"#;
358        let back: SaveState = serde_json::from_str(old_json).expect("deserialize old save");
359        assert_eq!(back, save);
360    }
361
362    /// A `SuspendedFlow` — current container, return stack, name-keyed frame
363    /// record, wake policy, block-run state — round-trips through
364    /// `serde_json` byte-for-byte (`docs/flow-suspension-spec.md` §2, FS-1).
365    /// Covers both `WakeSource` variants: `Condition` (a compiled condition
366    /// fn token present) and `Host` (no condition fn — §3's PROPOSED-only
367    /// host wake spelling), and (#2108) both a non-empty and an empty
368    /// `pending_element` — a real open attach run on the condition case, no
369    /// open run on the host case.
370    #[test]
371    fn suspended_flow_round_trips() {
372        let mut frame = OrderedMap::new();
373        frame.insert(MapKey::from("hp"), Value::Int(7));
374        frame.insert(
375            MapKey::from("party"),
376            Value::array(vec![Value::from("hero"), Value::from("mage")]),
377        );
378
379        let mut pending_element = BTreeMap::new();
380        pending_element.insert("speaker".to_string(), "VENDOR".to_string());
381
382        let suspended_condition = SuspendedFlow {
383            version: SUSPENDED_FLOW_SECTION_VERSION,
384            current: DefinitionId::new(DefinitionTag::Address, 1),
385            return_stack: vec![
386                DefinitionId::new(DefinitionTag::Address, 2),
387                DefinitionId::new(DefinitionTag::Address, 3),
388            ],
389            frame: Value::map(frame.clone()),
390            wake: WakePolicy {
391                site: DefinitionId::new(DefinitionTag::Address, 4),
392                condition: Some(DefinitionId::new(DefinitionTag::ExternalFn, 5)),
393                source: WakeSource::Condition,
394            },
395            next_block_id: 12,
396            pending_element: pending_element.clone(),
397        };
398        let save_condition = SaveState {
399            version: SAVE_FORMAT_VERSION,
400            globals: BTreeMap::new(),
401            global_ids: BTreeMap::new(),
402            visits: Vec::new(),
403            turns: Vec::new(),
404            turn_index: 3,
405            rng_seed: 1,
406            previous_random: 0,
407            suspended: Some(suspended_condition),
408        };
409
410        let json = serde_json::to_string(&save_condition).expect("serialize save");
411        assert!(
412            json.contains("next_block_id") && json.contains("pending_element"),
413            "a parked flow's block-run state must actually appear on the \
414             wire, not just round-trip through an in-memory equality check: \
415             {json}"
416        );
417        let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
418        assert_eq!(back, save_condition);
419
420        let suspended_host = SuspendedFlow {
421            version: SUSPENDED_FLOW_SECTION_VERSION,
422            current: DefinitionId::new(DefinitionTag::Address, 1),
423            return_stack: Vec::new(),
424            frame: Value::map(frame),
425            wake: WakePolicy {
426                site: DefinitionId::new(DefinitionTag::Address, 4),
427                condition: None,
428                source: WakeSource::Host,
429            },
430            next_block_id: 0,
431            pending_element: BTreeMap::new(),
432        };
433        let save_host = SaveState {
434            suspended: Some(suspended_host),
435            ..save_condition
436        };
437
438        let json = serde_json::to_string(&save_host).expect("serialize save");
439        let back: SaveState = serde_json::from_str(&json).expect("deserialize save");
440        assert_eq!(back, save_host);
441        assert!(
442            !json.contains("condition"),
443            "absent condition must be omitted, not null: {json}"
444        );
445        assert!(
446            !json.contains("pending_element"),
447            "no open attach run at park time must omit the key entirely, \
448             not serialize an empty map: {json}"
449        );
450    }
451
452    /// A version-1 `SuspendedFlow` JSON blob (predating #2108's
453    /// `next_block_id`/`pending_element`) still deserializes — `0` and an
454    /// empty map respectively, identical to the behavior it replaces for a
455    /// save that never carried block-run state at all. Built by serializing
456    /// a real (new-shape) value and stripping the two new keys back out,
457    /// rather than hand-writing `DefinitionId`/`Value`'s wire encoding —
458    /// this proves tolerance of an *absent* key without having to guess
459    /// unrelated types' JSON shape.
460    #[test]
461    fn suspended_flow_tolerates_a_pre_2108_save() {
462        let new_shape = SuspendedFlow {
463            version: 1,
464            current: DefinitionId::new(DefinitionTag::Address, 1),
465            return_stack: Vec::new(),
466            frame: Value::map(OrderedMap::new()),
467            wake: WakePolicy {
468                site: DefinitionId::new(DefinitionTag::Address, 2),
469                condition: None,
470                source: WakeSource::Host,
471            },
472            next_block_id: 99,
473            pending_element: {
474                let mut m = BTreeMap::new();
475                m.insert("speaker".to_string(), "SHOULD_NOT_SURVIVE".to_string());
476                m
477            },
478        };
479        let json = serde_json::to_string(&new_shape).expect("serialize");
480        // A real serde_json::Value round-trip strip, not brittle string
481        // splicing: remove the two #2108 keys, simulating an older save
482        // that never had them.
483        let mut as_map: serde_json::Value = serde_json::from_str(&json).expect("parse to Value");
484        let obj = as_map
485            .as_object_mut()
486            .expect("SuspendedFlow serializes as an object");
487        obj.remove("next_block_id");
488        obj.remove("pending_element");
489        let old_json = serde_json::to_string(&as_map).expect("re-serialize stripped");
490
491        let back: SuspendedFlow =
492            serde_json::from_str(&old_json).expect("deserialize old-shape flow");
493        assert_eq!(back.next_block_id, 0, "{back:?}");
494        assert!(back.pending_element.is_empty(), "{back:?}");
495    }
496
497    /// Frame-shape drift (`docs/flow-suspension-spec.md` §7): the compiler
498    /// changes a tunnel's crossing-locals set between a save and a later
499    /// load (a field is dropped, a field is added, a field is renamed). FS-1
500    /// is encode-side only — the tolerant *decode* (missing → default,
501    /// extra → dropped, renamed → treated as missing, each reported) is
502    /// FS-3 runtime scope — but the frame record must be encoded so that
503    /// decode is *possible*: because `frame` is an ordinary name-keyed
504    /// `Value::Map` (never a positional tuple), a shape change on one side
505    /// never desyncs field identity with the other — each entry carries its
506    /// own name, so added/missing/renamed keys are ordinary map diffs, not a
507    /// decode failure. This proves the encoding carries what FS-3 needs
508    /// without implementing FS-3's reconciliation itself.
509    #[test]
510    #[expect(clippy::panic, reason = "test assertion on a specific enum variant")]
511    fn suspended_flow_frame_drift_is_representable() {
512        let mut old_shape = OrderedMap::new();
513        old_shape.insert(MapKey::from("hp"), Value::Int(7));
514        old_shape.insert(MapKey::from("gold"), Value::Int(100));
515
516        let old = SuspendedFlow {
517            version: SUSPENDED_FLOW_SECTION_VERSION,
518            current: DefinitionId::new(DefinitionTag::Address, 1),
519            return_stack: Vec::new(),
520            frame: Value::map(old_shape),
521            wake: WakePolicy {
522                site: DefinitionId::new(DefinitionTag::Address, 2),
523                condition: Some(DefinitionId::new(DefinitionTag::ExternalFn, 3)),
524                source: WakeSource::Condition,
525            },
526            next_block_id: 4,
527            pending_element: BTreeMap::new(),
528        };
529        let json = serde_json::to_string(&old).expect("serialize old-shape frame");
530
531        // The author edits the tunnel: `gold` is dropped, `hp` is renamed to
532        // `health`, and a new `mana` local is added — none of this is
533        // reflected in `json` above, simulating a save made against the old
534        // shape being loaded against a recompiled program with the new one.
535        let mut new_shape = OrderedMap::new();
536        new_shape.insert(MapKey::from("health"), Value::Int(0));
537        new_shape.insert(MapKey::from("mana"), Value::Int(50));
538        let new_shape_flow = SuspendedFlow {
539            frame: Value::map(new_shape),
540            ..old.clone()
541        };
542        let new_shape_json =
543            serde_json::to_string(&new_shape_flow).expect("serialize new-shape frame");
544
545        // Both the old-shaped and new-shaped saves decode cleanly — the
546        // frame is a generic name-keyed map, not a fixed-arity tuple, so
547        // neither shape is a parse error. This is exactly the property FS-3's
548        // tolerant reconciliation (missing/extra/renamed) needs to build on.
549        let decoded_old: SuspendedFlow = serde_json::from_str(&json).expect("decode old shape");
550        let decoded_new: SuspendedFlow =
551            serde_json::from_str(&new_shape_json).expect("decode new shape");
552        assert_eq!(decoded_old, old);
553        assert_eq!(decoded_new, new_shape_flow);
554
555        // The old save's frame keys are untouched by the shape change
556        // happening elsewhere — a future FS-3 reconciler can diff
557        // `decoded_old.frame` against the *current* program's expected
558        // key set field-by-field, because each value carries its own name.
559        let Value::Map(m) = &decoded_old.frame else {
560            panic!("expected a map frame");
561        };
562        assert_eq!(m.get(&MapKey::from("hp")), Some(&Value::Int(7)));
563        assert_eq!(m.get(&MapKey::from("gold")), Some(&Value::Int(100)));
564    }
565}