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}