Skip to main content

bevy_brink/
handle.rs

1//! T1d-3: `bevy-brink` handle integration (`docs/t1d-spec.md` §4).
2//!
3//! Host resources (entities, audio instances, timers) reach an ink script
4//! as opaque [`brink_format::Value::Handle`] tokens: `{kind, id}` scalars
5//! with value semantics. This module owns the host half of that boundary:
6//!
7//! - [`HandleKind`] — the two-halved per-kind trait. `save_key` captures a
8//!   live resource as a durable *reconstruction recipe*; `resolve` rebuilds
9//!   a resource from one at load time. A kind that returns `None` from
10//!   `save_key` is choosing ephemerality — never a spec-assigned category.
11//! - [`HandleRegistry<K>`] — the per-kind token registry (a `Resource`):
12//!   opaque `u64` id allocation, live-resource storage.
13//! - [`HandleKinds`]/[`BrinkHandleAppExt::register_handle_kind`] — the
14//!   type-erased index over every registered kind, needed anywhere a
15//!   binding only has a runtime `Value::Handle` (kind known only as a
16//!   string) rather than a static `K`: [`is_valid_system`], the dead-deref
17//!   event, registry GC, and save/load.
18//! - [`save_handles`]/[`load_handles`] — token→[`HandleSaveKey`]
19//!   persistence beside the ink [`SaveState`], and the load-time
20//!   [`RehydrationReport`] (rebound / dead-by-resolve / dead-ephemeral /
21//!   dead-by-unregistered-kind) gated by [`RehydrationPolicy`].
22//! - [`gc_on_turn_done`] — registry GC via a reachable-token scan, run at
23//!   `-> DONE` quiescent sweeps (spec §4, value-model §6 license).
24//! - [`HandleEntityRemap`] — an [`EntityMapper`] a `Resource`-typed
25//!   [`HandleKind`]'s `resolve` can consult/populate when reconstructing
26//!   scene-based entities whose cross-references used the old session's
27//!   `Entity` ids.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::marker::PhantomData;
31
32use bevy_app::App;
33use bevy_ecs::change_detection::DetectChangesMut as _;
34use bevy_ecs::entity::{Entity, EntityMapper};
35use bevy_ecs::event::EntityEvent;
36use bevy_ecs::observer::On;
37use bevy_ecs::resource::Resource;
38use bevy_ecs::system::{Commands, In, Query, Res, ResMut};
39use bevy_ecs::world::World;
40use bevy_log::warn;
41use brink_format::Value;
42use brink_runtime::{Program, SaveState};
43use serde::Serialize;
44use serde::de::DeserializeOwned;
45use thiserror::Error;
46
47use crate::asset::{BrinkProgram, ProgramAsset};
48use crate::bindings::BrinkQueryInput;
49use crate::event::BrinkTurnDone;
50use crate::globals::{BrinkContext, BrinkGlobals, save_flow_state};
51use bevy_asset::Assets;
52
53// ── HandleKind — the two-halved per-kind trait (spec §4) ────────────────
54
55/// Per-kind rehydration contract: save-side keying (live resource → durable
56/// [`SaveKey`](Self::SaveKey)) and load-side resolution (`SaveKey` → new
57/// resource). Verbatim from `docs/t1d-spec.md` §4 (2026-07-14 mechanics
58/// ruling).
59///
60/// `SaveKey` is a **reconstruction recipe, not just a foreign key** — the
61/// implementor picks a point on the spectrum: identity lookup (an NPC GUID),
62/// reconstruction (a timer saves its remaining duration; `resolve` spawns a
63/// fresh one — timers *are* resumable, the canonical example), or
64/// deliberate ephemerality (`save_key` returns `None`: "this resource is
65/// meaningless across sessions"). Ephemerality is an implementor choice,
66/// never a category the spec assigns.
67pub trait HandleKind: Send + Sync + 'static {
68    /// The manifest-declared kind name (`Handle<KIND>` in ink source).
69    const KIND: &'static str;
70    /// The live host resource this kind's tokens dereference to.
71    type Resource: Send + Sync + 'static;
72    /// The durable reconstruction recipe persisted beside the ink
73    /// [`SaveState`].
74    type SaveKey: Serialize + DeserializeOwned + Clone + Send + Sync + 'static;
75
76    /// Capture `res` as a durable [`SaveKey`](Self::SaveKey). `None` means
77    /// this particular live resource is ephemeral — it will not round-trip
78    /// through save/load at all (not an error; a deliberate choice).
79    fn save_key(&self, world: &World, res: &Self::Resource) -> Option<Self::SaveKey>;
80
81    /// Rebuild a resource from a persisted [`SaveKey`](Self::SaveKey) at
82    /// load time. `None` means the recipe no longer resolves to anything
83    /// live (e.g. the NPC GUID it names despawned) — a normal, expected
84    /// outcome, reported as `dead_by_resolve`, never a fault.
85    fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource>;
86}
87
88// ── HandleRegistry<K> — per-kind token storage ───────────────────────────
89
90/// Per-kind token registry: opaque `u64` id allocation plus live-resource
91/// storage. A `Resource`, inserted by
92/// [`register_handle_kind`](BrinkHandleAppExt::register_handle_kind).
93///
94/// `BTreeMap` (not `HashMap`) for deterministic iteration — GC and snapshot
95/// walk `live` in id order (CLAUDE.md determinism rule).
96#[derive(Resource)]
97pub struct HandleRegistry<K: HandleKind> {
98    implementor: K,
99    next_id: u64,
100    live: BTreeMap<u64, K::Resource>,
101}
102
103impl<K: HandleKind> HandleRegistry<K> {
104    #[must_use]
105    pub fn new(implementor: K) -> Self {
106        Self {
107            implementor,
108            next_id: 0,
109            live: BTreeMap::new(),
110        }
111    }
112
113    /// Mint a fresh opaque token id for `resource`, storing it live.
114    pub fn mint(&mut self, resource: K::Resource) -> u64 {
115        let id = self.next_id;
116        self.next_id += 1;
117        self.live.insert(id, resource);
118        id
119    }
120
121    /// Mint a token and build the ink-facing [`Value::Handle`] for it,
122    /// resolving this kind's name id against `program`'s name table.
123    /// `None` if this compile never interned [`HandleKind::KIND`] (no
124    /// `Handle<K>`-typed signature/annotation anywhere in the source graph
125    /// — see [`Program::name_id`](brink_runtime::Program::name_id)).
126    pub fn mint_value(&mut self, program: &Program, resource: K::Resource) -> Option<Value> {
127        let kind = program.name_id(K::KIND)?;
128        let id = self.mint(resource);
129        Some(Value::handle(kind, id))
130    }
131
132    #[must_use]
133    pub fn get(&self, id: u64) -> Option<&K::Resource> {
134        self.live.get(&id)
135    }
136
137    #[must_use]
138    pub fn contains(&self, id: u64) -> bool {
139        self.live.contains_key(&id)
140    }
141
142    pub fn remove(&mut self, id: u64) -> Option<K::Resource> {
143        self.live.remove(&id)
144    }
145
146    #[must_use]
147    pub fn len(&self) -> usize {
148        self.live.len()
149    }
150
151    #[must_use]
152    pub fn is_empty(&self) -> bool {
153        self.live.is_empty()
154    }
155
156    /// Look up a token, firing [`BrinkDeadHandleDeref`] at `flow` when it's
157    /// dead. Opt-in dead-deref telemetry (spec §4): a binding that just
158    /// wants a silent `None` should use [`get`](Self::get) instead.
159    pub fn get_or_dead<M: Send + Sync + 'static>(
160        &self,
161        id: u64,
162        commands: &mut Commands,
163        flow: Entity,
164    ) -> Option<&K::Resource> {
165        let found = self.live.get(&id);
166        if found.is_none() {
167            commands.trigger(BrinkDeadHandleDeref::<M>::new(flow, K::KIND, id));
168        }
169        found
170    }
171}
172
173// ── Type-erased dispatch — needed wherever the kind is only a runtime string ─
174
175/// One `(id, SaveKey)` entry, `SaveKey` erased to JSON so heterogeneous
176/// kinds can share one persisted table ([`HandleSaveState`]).
177#[derive(Debug, Clone, Serialize, serde::Deserialize)]
178pub struct HandleSaveEntry {
179    pub id: u64,
180    pub key: serde_json::Value,
181}
182
183/// Per-kind rehydration outcome, folded into a [`RehydrationReport`] by
184/// [`load_handles`].
185#[derive(Debug, Default)]
186struct KindRehydrateOutcome {
187    rebound: Vec<u64>,
188    dead_by_resolve: Vec<u64>,
189}
190
191/// Type-erased per-kind operations, so [`HandleKinds`] can dispatch on a
192/// runtime kind name without knowing `K` statically — exactly the situation
193/// a binding is in when it only has a `Value::Handle` (spec §4: `is_valid`,
194/// dead-deref, GC, save/load all work from the wire token, not a static
195/// type).
196trait ErasedHandleRegistry: Send + Sync + 'static {
197    fn kind_name(&self) -> &'static str;
198    fn is_valid(&self, world: &World, id: u64) -> bool;
199    /// Drop every live entry whose id isn't in `keep`. Returns
200    /// `(dropped, retained)`.
201    fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize);
202    /// Every live token's `SaveKey`, JSON-erased. Ephemeral tokens
203    /// (`save_key` returned `None`) are silently omitted — by design, they
204    /// never round-trip.
205    fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry>;
206    /// Resolve `referenced` ids against `persisted` (this kind's slice of
207    /// the loaded [`HandleSaveState`]), inserting resolved resources back
208    /// into the registry **under the same id** (token-id stability, spec
209    /// §4). Ids in `referenced` absent from `persisted` are not reported
210    /// here — the caller treats them as `dead_ephemeral` (a registered kind
211    /// with no persisted entry for that id).
212    fn rebind_selected(
213        &self,
214        world: &mut World,
215        referenced: &BTreeSet<u64>,
216        persisted: &[HandleSaveEntry],
217    ) -> KindRehydrateOutcome;
218}
219
220struct RegistryOps<K: HandleKind>(PhantomData<fn() -> K>);
221
222impl<K: HandleKind> Default for RegistryOps<K> {
223    fn default() -> Self {
224        Self(PhantomData)
225    }
226}
227
228impl<K: HandleKind> ErasedHandleRegistry for RegistryOps<K> {
229    fn kind_name(&self) -> &'static str {
230        K::KIND
231    }
232
233    fn is_valid(&self, world: &World, id: u64) -> bool {
234        world
235            .get_resource::<HandleRegistry<K>>()
236            .is_some_and(|reg| reg.contains(id))
237    }
238
239    fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize) {
240        let Some(mut reg) = world.get_resource_mut::<HandleRegistry<K>>() else {
241            return (0, 0);
242        };
243        let before = reg.live.len();
244        reg.live.retain(|id, _| keep.contains(id));
245        let after = reg.live.len();
246        (before - after, after)
247    }
248
249    fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry> {
250        let Some(reg) = world.get_resource::<HandleRegistry<K>>() else {
251            return Vec::new();
252        };
253        reg.live
254            .iter()
255            .filter_map(|(id, resource)| {
256                let key = reg.implementor.save_key(world, resource)?;
257                let key = match serde_json::to_value(&key) {
258                    Ok(key) => key,
259                    Err(err) => {
260                        // Distinct from a deliberate `save_key -> None`
261                        // (chosen ephemerality): this is a real failure to
262                        // serialize a `SaveKey` the implementor *did*
263                        // produce (e.g. a non-finite f32 field — serde_json
264                        // errors on NaN/Inf). Surfacing it here rather than
265                        // silently dropping the token keeps it from being
266                        // laundered into "the kind chose ephemerality" on
267                        // load (dropped-construct-needs-a-diagnostic).
268                        warn!(
269                            "brink: handle kind {:?} id {id} failed to serialize its SaveKey ({err}); omitting from snapshot (will rehydrate as dead_ephemeral)"
270                            , K::KIND
271                        );
272                        return None;
273                    }
274                };
275                Some(HandleSaveEntry { id: *id, key })
276            })
277            .collect()
278    }
279
280    fn rebind_selected(
281        &self,
282        world: &mut World,
283        referenced: &BTreeSet<u64>,
284        persisted: &[HandleSaveEntry],
285    ) -> KindRehydrateOutcome {
286        let mut outcome = KindRehydrateOutcome::default();
287        let by_id: BTreeMap<u64, &serde_json::Value> =
288            persisted.iter().map(|e| (e.id, &e.key)).collect();
289        // Reserve id space across every id this kind knows about at load
290        // time — referenced (still named by ink state, even if it turns
291        // out dead-by-resolve or dead-ephemeral below) or persisted
292        // (previously minted, even if not currently referenced). This must
293        // happen unconditionally, before branching on outcome, because
294        // dead/ephemeral tokens remain live in ink state by this module's
295        // own design: their ids are still referenced even though nothing
296        // is rebound under them. `next_id` isn't itself persisted (it's
297        // reconstructed each load), so if we only bumped it on the
298        // `Some(resource)` branch, a later `mint` could reallocate a
299        // still-referenced dead token's id to an unrelated resource —
300        // silently violating token identity (a stale token would start
301        // dereferencing to the wrong resource).
302        let reserve_through = referenced
303            .iter()
304            .copied()
305            .chain(persisted.iter().map(|e| e.id))
306            .max();
307        world.resource_scope(
308            |world, mut reg: bevy_ecs::change_detection::Mut<HandleRegistry<K>>| {
309                if let Some(max_id) = reserve_through {
310                    reg.next_id = reg.next_id.max(max_id + 1);
311                }
312                for &id in referenced {
313                    let Some(key_json) = by_id.get(&id) else {
314                        // No persisted entry: dead_ephemeral, handled by the caller
315                        // (it already knows this id was referenced but not in
316                        // `persisted`).
317                        continue;
318                    };
319                    let resolved = match serde_json::from_value::<K::SaveKey>((*key_json).clone())
320                    {
321                        Ok(key) => reg.implementor.resolve(world, &key),
322                        Err(err) => {
323                            // Schema drift, not a normal "recipe no longer
324                            // resolves" outcome: the persisted JSON doesn't
325                            // even deserialize as this kind's `SaveKey`
326                            // any more. Surface it rather than silently
327                            // folding it into dead_by_resolve.
328                            warn!(
329                                "brink: handle kind {:?} id {id} failed to deserialize its persisted SaveKey ({err}); treating as dead_by_resolve"
330                                , K::KIND
331                            );
332                            None
333                        }
334                    };
335                    match resolved {
336                        Some(resource) => {
337                            reg.live.insert(id, resource);
338                            outcome.rebound.push(id);
339                        }
340                        None => outcome.dead_by_resolve.push(id),
341                    }
342                }
343            },
344        );
345        outcome
346    }
347}
348
349/// Type-erased index over every kind registered via
350/// [`BrinkHandleAppExt::register_handle_kind`] for marker `M`. `BTreeMap`
351/// keyed by [`HandleKind::KIND`] for deterministic iteration.
352#[derive(Resource)]
353pub struct HandleKinds<M: Send + Sync + 'static = ()> {
354    kinds: BTreeMap<&'static str, Box<dyn ErasedHandleRegistry>>,
355    _marker: PhantomData<fn() -> M>,
356}
357
358impl<M: Send + Sync + 'static> Default for HandleKinds<M> {
359    fn default() -> Self {
360        Self {
361            kinds: BTreeMap::new(),
362            _marker: PhantomData,
363        }
364    }
365}
366
367impl<M: Send + Sync + 'static> HandleKinds<M> {
368    /// The kind names registered so far, in deterministic order.
369    pub fn kind_names(&self) -> impl Iterator<Item = &'static str> + '_ {
370        self.kinds.keys().copied()
371    }
372
373    /// `true` when no [`HandleKind`] is registered for `M` — the whole
374    /// registry-GC machinery is inert, so the `-> DONE` reachable-token
375    /// sweep has provably nothing to collect and can be skipped (#1007).
376    #[must_use]
377    pub fn is_empty(&self) -> bool {
378        self.kinds.is_empty()
379    }
380}
381
382/// App-builder extension for registering [`HandleKind`] implementors.
383pub trait BrinkHandleAppExt {
384    /// Register a [`HandleKind`] implementor for marker `M`: inserts its
385    /// [`HandleRegistry<K>`] resource and indexes it in [`HandleKinds<M>`]
386    /// for the type-erased operations (`is_valid`, GC, save/load).
387    fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
388        &mut self,
389        implementor: K,
390    ) -> &mut Self;
391}
392
393impl BrinkHandleAppExt for App {
394    fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
395        &mut self,
396        implementor: K,
397    ) -> &mut Self {
398        self.world_mut()
399            .insert_resource(HandleRegistry::<K>::new(implementor));
400        self.world_mut()
401            .get_resource_or_insert_with(HandleKinds::<M>::default);
402        self.world_mut()
403            .resource_mut::<HandleKinds<M>>()
404            .kinds
405            .insert(K::KIND, Box::new(RegistryOps::<K>::default()));
406        self
407    }
408}
409
410// ── Persistence beside SaveState ─────────────────────────────────────────
411
412/// The token→`SaveKey` table, persisted beside the ink [`SaveState`] (spec
413/// §4: "bevy-brink owns opaque token ids and the per-kind registries,
414/// persists the token → `SaveKey` table beside the ink `SaveState`"). Keyed by
415/// [`HandleKind::KIND`]; `BTreeMap`/sorted-by-id `Vec` for deterministic
416/// serialization.
417#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
418pub struct HandleSaveState {
419    pub entries: BTreeMap<String, Vec<HandleSaveEntry>>,
420}
421
422/// Snapshot every registered kind's live tokens as a [`HandleSaveState`],
423/// to be persisted alongside a [`SaveState`] (e.g.
424/// [`BrinkGlobals::save_state`](crate::BrinkGlobals::save_state)).
425#[must_use]
426pub fn save_handles<M: Send + Sync + 'static>(world: &World) -> HandleSaveState {
427    let mut out = HandleSaveState::default();
428    if let Some(kinds) = world.get_resource::<HandleKinds<M>>() {
429        for ops in kinds.kinds.values() {
430            let entries = ops.snapshot(world);
431            if !entries.is_empty() {
432                out.entries.insert(ops.kind_name().to_string(), entries);
433            }
434        }
435    }
436    out
437}
438
439/// Host policy for handling a token whose kind isn't currently registered
440/// at load time (spec §4). `Lenient` is the production default —
441/// unregistered kinds are just reported, never-fail-load holds. `StrictKinds`
442/// is the dev/CI knob: an unregistered kind fails the load loudly (a
443/// registration drifted out of sync with a save file, which is a bug worth
444/// surfacing immediately rather than silently dropping state).
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
446pub enum RehydrationPolicy {
447    #[default]
448    Lenient,
449    StrictKinds,
450}
451
452/// Load-time outcome for every handle token referenced by the ink state
453/// being loaded, bucketed per spec §4.
454#[derive(Debug, Clone, Default)]
455pub struct RehydrationReport {
456    /// Resolved to a live resource under the same token id.
457    pub rebound: Vec<(String, u64)>,
458    /// A registered kind, a persisted `SaveKey`, but `resolve` returned
459    /// `None` — normal (the recipe no longer names anything live).
460    pub dead_by_resolve: Vec<(String, u64)>,
461    /// A registered kind with no persisted entry for this id — the kind
462    /// chose ephemerality (`save_key` returned `None`) for this token.
463    pub dead_ephemeral: Vec<(String, u64)>,
464    /// The token's kind isn't registered at all under `Lenient` — suspicious
465    /// (integration drift), surfaced for the host to log, never a fault.
466    pub dead_by_unregistered_kind: Vec<(String, u64)>,
467}
468
469impl RehydrationReport {
470    #[must_use]
471    pub fn is_fully_rebound(&self) -> bool {
472        self.dead_by_resolve.is_empty()
473            && self.dead_ephemeral.is_empty()
474            && self.dead_by_unregistered_kind.is_empty()
475    }
476}
477
478/// [`load_handles`] failure — only reachable under
479/// [`RehydrationPolicy::StrictKinds`].
480#[derive(Debug, Error, Clone, PartialEq, Eq)]
481pub enum HandleLoadError {
482    /// One or more kinds referenced by the loaded state aren't registered.
483    /// No registry was mutated — the load is atomic under `StrictKinds`.
484    #[error("unregistered handle kind(s) at load: {0:?}")]
485    UnregisteredKinds(Vec<String>),
486}
487
488/// Rehydrate every handle token `referenced` (the [`SaveState`] about to be
489/// loaded — [`BrinkGlobals`](crate::BrinkGlobals)'s or one flow's) against
490/// `persisted` (the companion [`HandleSaveState`] loaded alongside it),
491/// keeping token ids stable (spec §4: "rebinds registries at load keeping
492/// token ids stable — ink state is untouched; only the registry's
493/// right-hand side rebinds").
494///
495/// Under [`RehydrationPolicy::StrictKinds`], any kind referenced by
496/// `referenced` that isn't registered fails the whole call atomically
497/// (nothing is mutated) with [`HandleLoadError::UnregisteredKinds`]. Under
498/// `Lenient` (the production default — never-fail-load holds), those ids
499/// land in [`RehydrationReport::dead_by_unregistered_kind`] instead.
500pub fn load_handles<M: Send + Sync + 'static>(
501    world: &mut World,
502    program: &Program,
503    referenced: &SaveState,
504    persisted: &HandleSaveState,
505    policy: RehydrationPolicy,
506) -> Result<RehydrationReport, HandleLoadError> {
507    let mut by_kind: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
508    collect_from_save_state(referenced, program, &mut by_kind);
509
510    let registered: BTreeSet<&str> = world
511        .get_resource::<HandleKinds<M>>()
512        .map(|kinds| kinds.kind_names().collect())
513        .unwrap_or_default();
514
515    if policy == RehydrationPolicy::StrictKinds {
516        let unregistered: Vec<String> = by_kind
517            .keys()
518            .filter(|k| !registered.contains(k.as_str()))
519            .cloned()
520            .collect();
521        if !unregistered.is_empty() {
522            return Err(HandleLoadError::UnregisteredKinds(unregistered));
523        }
524    }
525
526    world.get_resource_or_insert_with(HandleEntityRemap::default);
527    if let Some(mut remap) = world.get_resource_mut::<HandleEntityRemap>() {
528        remap.clear();
529    }
530
531    let mut report = RehydrationReport::default();
532    world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
533        for (kind_name, ids) in &by_kind {
534            let Some(ops) = kinds.kinds.get(kind_name.as_str()) else {
535                report
536                    .dead_by_unregistered_kind
537                    .extend(ids.iter().map(|id| (kind_name.clone(), *id)));
538                continue;
539            };
540            let persisted_for_kind = persisted
541                .entries
542                .get(kind_name)
543                .map_or(&[][..], Vec::as_slice);
544            let by_persisted_id: BTreeSet<u64> = persisted_for_kind.iter().map(|e| e.id).collect();
545            let outcome = ops.rebind_selected(world, ids, persisted_for_kind);
546            report.rebound.extend(
547                outcome
548                    .rebound
549                    .into_iter()
550                    .map(|id| (kind_name.clone(), id)),
551            );
552            report.dead_by_resolve.extend(
553                outcome
554                    .dead_by_resolve
555                    .into_iter()
556                    .map(|id| (kind_name.clone(), id)),
557            );
558            report.dead_ephemeral.extend(
559                ids.iter()
560                    .filter(|id| !by_persisted_id.contains(id))
561                    .map(|id| (kind_name.clone(), *id)),
562            );
563        }
564    });
565
566    Ok(report)
567}
568
569// ── Reachable-token scan (shared by GC and load) ─────────────────────────
570
571/// Recursively collect every [`Value::Handle`] token reachable from `value`
572/// — including tokens nested in arrays, maps, records, and closure
573/// bound-args — resolving each token's kind name against `program`.
574fn collect_handles(value: &Value, program: &Program, out: &mut BTreeMap<String, BTreeSet<u64>>) {
575    match value {
576        Value::Handle { kind, id } => {
577            if let Some(name) = program.name_checked(*kind) {
578                out.entry(name.to_string()).or_default().insert(*id);
579            }
580        }
581        Value::Array(items) => {
582            for v in items.iter() {
583                collect_handles(v, program, out);
584            }
585        }
586        Value::Map(map) => {
587            for v in map.values() {
588                collect_handles(v, program, out);
589            }
590        }
591        Value::Record { fields, .. } => {
592            for v in fields.iter() {
593                collect_handles(v, program, out);
594            }
595        }
596        Value::Closure(closure) => {
597            for entry in &closure.env {
598                collect_handles(&entry.payload, program, out);
599            }
600        }
601        _ => {}
602    }
603}
604
605fn collect_from_save_state(
606    save: &SaveState,
607    program: &Program,
608    out: &mut BTreeMap<String, BTreeSet<u64>>,
609) {
610    for value in save.globals.values() {
611        collect_handles(value, program, out);
612    }
613}
614
615// ── EntityMapper integration (spec §4) ───────────────────────────────────
616
617/// An [`EntityMapper`] a `Resource = Entity` [`HandleKind`]'s `resolve` can
618/// consult (`world.resource::<HandleEntityRemap>()`) and populate
619/// (`set_mapped`) when reconstructing scene-based entities whose
620/// cross-references named another handle-entity by its *old* session's
621/// `Entity` id. Reset at the start of every [`load_handles`] call.
622///
623/// `BTreeMap` for deterministic iteration if a consumer ever walks it.
624#[derive(Resource, Default, Debug)]
625pub struct HandleEntityRemap {
626    map: BTreeMap<Entity, Entity>,
627}
628
629impl HandleEntityRemap {
630    pub fn clear(&mut self) {
631        self.map.clear();
632    }
633}
634
635impl EntityMapper for HandleEntityRemap {
636    fn get_mapped(&mut self, source: Entity) -> Entity {
637        self.map.get(&source).copied().unwrap_or(source)
638    }
639
640    fn set_mapped(&mut self, source: Entity, target: Entity) {
641        self.map.insert(source, target);
642    }
643}
644
645// ── Dead-deref host event ────────────────────────────────────────────────
646
647/// Fired (opt-in — see [`HandleRegistry::get_or_dead`]) when a binding
648/// dereferences a dead handle. Telemetry only: the binding itself still
649/// returns whatever declared failure value it chooses; this event doesn't
650/// change that value, it just lets a host observe the miss.
651#[derive(EntityEvent)]
652pub struct BrinkDeadHandleDeref<M: Send + Sync + 'static = ()> {
653    pub entity: Entity,
654    pub kind: &'static str,
655    pub id: u64,
656    _marker: PhantomData<fn() -> M>,
657}
658
659impl<M: Send + Sync + 'static> BrinkDeadHandleDeref<M> {
660    pub(crate) fn new(entity: Entity, kind: &'static str, id: u64) -> Self {
661        Self {
662            entity,
663            kind,
664            id,
665            _marker: PhantomData,
666        }
667    }
668}
669
670// ── is_valid — standard world-query binding (spec §4) ────────────────────
671
672/// The `is_valid(h)` binding body — ships as a standard
673/// [`bind_brink_query`](crate::BrinkBindingsAppExt::bind_brink_query)
674/// binding (not a language intrinsic, per spec §4). Registered
675/// automatically by [`BrinkPlugin`](crate::BrinkPlugin) under the name
676/// `"is_valid"`.
677///
678/// Returns `Value::Bool(false)` for anything that isn't a live, registered
679/// handle: a non-handle argument, an unregistered kind, or a dead token —
680/// `is_valid` never faults.
681pub fn is_valid_system<M: Send + Sync + 'static>(
682    In((entity, args)): In<BrinkQueryInput>,
683    world: &World,
684) -> Value {
685    let Some((kind, id)) = args.first().and_then(Value::as_handle) else {
686        return Value::Bool(false);
687    };
688    let Some(program_component) = world.get::<BrinkProgram<M>>(entity) else {
689        return Value::Bool(false);
690    };
691    let Some(program) = world
692        .get_resource::<Assets<ProgramAsset>>()
693        .and_then(|assets| assets.get(&program_component.handle))
694    else {
695        return Value::Bool(false);
696    };
697    let Some(kind_name) = program.program.name_checked(kind) else {
698        return Value::Bool(false);
699    };
700    let Some(kinds) = world.get_resource::<HandleKinds<M>>() else {
701        return Value::Bool(false);
702    };
703    let Some(ops) = kinds.kinds.get(kind_name) else {
704        return Value::Bool(false);
705    };
706    Value::Bool(ops.is_valid(world, id))
707}
708
709// ── Snapshot-retention dev metric (spec §8) ──────────────────────────────
710
711/// Per-kind live/GC counters, updated by [`gc_on_turn_done`]. A diagnostics
712/// feature, not a semantic (spec §8: "the dev-build snapshot-retention
713/// metric rides the bevy-brink slice as a diagnostics feature").
714#[derive(Debug, Clone, Default)]
715pub struct KindRetention {
716    /// Live token count as of the last sweep.
717    pub live: usize,
718    /// Tokens dropped by the last sweep.
719    pub last_gc_dropped: usize,
720    /// Total sweeps this kind has been through.
721    pub sweeps: u64,
722}
723
724#[derive(Resource, Debug, Clone)]
725pub struct HandleRetentionMetrics<M: Send + Sync + 'static = ()> {
726    pub per_kind: BTreeMap<String, KindRetention>,
727    _marker: PhantomData<fn() -> M>,
728}
729
730impl<M: Send + Sync + 'static> Default for HandleRetentionMetrics<M> {
731    fn default() -> Self {
732        Self {
733            per_kind: BTreeMap::new(),
734            _marker: PhantomData,
735        }
736    }
737}
738
739impl<M: Send + Sync + 'static> HandleRetentionMetrics<M> {
740    fn record(&mut self, kind: &str, dropped: usize, live: usize) {
741        let entry = self.per_kind.entry(kind.to_string()).or_default();
742        entry.live = live;
743        entry.last_gc_dropped = dropped;
744        entry.sweeps += 1;
745    }
746}
747
748// ── Registry GC at -> DONE quiescent sweeps (spec §4) ────────────────────
749
750/// Drop every registered kind's unreachable registry entries, given the
751/// already-computed reachable token set (kind name → live ids) — the
752/// mutating half of [`gc_on_turn_done`], run through `Commands::queue` so
753/// the type-erased dispatch gets its own `&mut World`.
754fn sweep_registries<M: Send + Sync + 'static>(
755    world: &mut World,
756    reachable: &BTreeMap<String, BTreeSet<u64>>,
757) {
758    let empty = BTreeSet::new();
759    world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
760        for ops in kinds.kinds.values() {
761            let keep = reachable.get(ops.kind_name()).unwrap_or(&empty);
762            let (dropped, live) = ops.gc_retain(world, keep);
763            if let Some(mut metrics) = world.get_resource_mut::<HandleRetentionMetrics<M>>() {
764                metrics.record(ops.kind_name(), dropped, live);
765            }
766        }
767    });
768}
769
770/// Observer: at every `-> DONE` (spec §4's quiescent sweep point), computes
771/// the currently-reachable handle-token set — every token in the shared
772/// `World`'s globals plus every flow's own local state, script state being
773/// fully enumerable (value-model §6 license) — and drops every registered
774/// kind's unreachable entries. No script-side destructors exist or are
775/// needed.
776///
777/// Registered automatically by [`BrinkPlugin`](crate::BrinkPlugin).
778#[expect(
779    clippy::needless_pass_by_value,
780    reason = "bevy systems take Res params by value"
781)]
782pub fn gc_on_turn_done<M: Send + Sync + 'static>(
783    _on: On<BrinkTurnDone<M>>,
784    kinds: Option<Res<HandleKinds<M>>>,
785    mut globals: Option<ResMut<BrinkGlobals<M>>>,
786    programs: Res<Assets<ProgramAsset>>,
787    mut contexts: Query<(&BrinkProgram<M>, &mut BrinkContext<M>)>,
788    mut commands: Commands,
789) {
790    // Fast path: when no handle kind is registered for `M`, the reachable
791    // sweep is a provable no-op (nothing to retain, nothing to drop). Bail
792    // BEFORE the O(total flows) reachable-token walk below — otherwise a
793    // handle-free flow population pays a full-population scan on every
794    // `-> DONE`, making a wake-storm O(n²) (#1007). Registering a kind
795    // opts back into the per-turn sweep.
796    if kinds.is_none_or(|k| k.is_empty()) {
797        return;
798    }
799
800    let Some(globals) = globals.as_mut() else {
801        return;
802    };
803    // Read-only in effect (issue #1632): the sweep below only *reads*
804    // through the context view `save_flow_state` builds — it takes `&mut`
805    // for the same reason `call_ink_function` does, not because it writes a
806    // global cell. Since this observer fires on every turn-completing frame
807    // once any kind is registered, letting that `&mut` move the resource's
808    // change tick would put a change *outside* the changed-cell ledger every
809    // turn: `BrinkWorldDelta::drain` sees a tick past the one the driver's
810    // own Apply recorded, reports `None`, and the wake pass falls back to
811    // the coarse "anything may have changed" bit — resurrecting #1101's
812    // spurious re-wake for every handle-using host, the half of the root
813    // cause #1146 fixed in `run_flow_sleep` and left standing here.
814    let globals = globals.bypass_change_detection();
815
816    let mut reachable: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
817    // Every flow's own program contributes its globals-view + local state —
818    // deliberately every flow under `M` (not just the one that just
819    // quiesced) so a still-mid-conversation flow's private handle
820    // references aren't GC'd out from under it just because a *different*
821    // flow reached `-> DONE`.
822    for (program_component, mut ctx) in &mut contexts {
823        let Some(program_asset) = programs.get(&program_component.handle) else {
824            continue;
825        };
826        let state = save_flow_state(globals, &mut ctx, &program_asset.program);
827        collect_from_save_state(&state, &program_asset.program, &mut reachable);
828    }
829
830    commands.queue(move |world: &mut World| {
831        sweep_registries::<M>(world, &reachable);
832    });
833}
834
835#[cfg(test)]
836mod tests {
837    use std::collections::BTreeSet as Set;
838
839    use bevy_ecs::system::RunSystemOnce as _;
840    use brink_format::SaveState;
841    use brink_runtime::ContextAccess as _;
842    use serde::{Deserialize, Serialize};
843
844    use super::*;
845    use crate::BrinkFlowRequest;
846    use crate::bindings::advance_flow;
847    use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
848
849    // ── Canonical example kinds ───────────────────────────────────────────
850
851    /// The reconstruction-recipe canonical example (spec §4): a timer isn't
852    /// looked up, it's rebuilt from its remaining duration — resumable.
853    #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
854    struct TimerSaveKey {
855        remaining_secs: f32,
856    }
857    #[derive(Debug, Clone, Copy, PartialEq)]
858    struct TimerState {
859        remaining_secs: f32,
860    }
861    struct TimerKind;
862    impl HandleKind for TimerKind {
863        const KIND: &'static str = "Timer";
864        type Resource = TimerState;
865        type SaveKey = TimerSaveKey;
866        fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
867            Some(TimerSaveKey {
868                remaining_secs: res.remaining_secs,
869            })
870        }
871        fn resolve(&self, _world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
872            Some(TimerState {
873                remaining_secs: key.remaining_secs,
874            })
875        }
876    }
877
878    /// An identity-lookup kind whose `resolve` depends on a host-side
879    /// "still alive" registry the test controls — models the ordinary
880    /// "the named resource may or may not still exist" case.
881    #[derive(Debug, Clone, Serialize, Deserialize)]
882    struct NpcSaveKey {
883        guid: String,
884    }
885    #[derive(Debug, Clone, PartialEq)]
886    struct NpcState {
887        guid: String,
888    }
889    struct NpcKind;
890    impl HandleKind for NpcKind {
891        const KIND: &'static str = "Npc";
892        type Resource = NpcState;
893        type SaveKey = NpcSaveKey;
894        fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
895            Some(NpcSaveKey {
896                guid: res.guid.clone(),
897            })
898        }
899        fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
900            let alive = world.get_resource::<AliveNpcs>()?;
901            alive.0.contains(&key.guid).then(|| NpcState {
902                guid: key.guid.clone(),
903            })
904        }
905    }
906    #[derive(Resource, Default)]
907    struct AliveNpcs(Set<String>);
908
909    /// The deliberate-ephemerality kind: `save_key` always returns `None`.
910    struct TransientKind;
911    impl HandleKind for TransientKind {
912        const KIND: &'static str = "Transient";
913        type Resource = ();
914        type SaveKey = ();
915        fn save_key(&self, _world: &World, (): &Self::Resource) -> Option<Self::SaveKey> {
916            None
917        }
918        fn resolve(&self, _world: &mut World, (): &Self::SaveKey) -> Option<Self::Resource> {
919            Some(())
920        }
921    }
922
923    fn empty_save_state() -> SaveState {
924        SaveState {
925            version: brink_runtime::SAVE_FORMAT_VERSION,
926            globals: BTreeMap::new(),
927            visits: Vec::new(),
928            turns: Vec::new(),
929            turn_index: 0,
930            rng_seed: 0,
931            previous_random: 0,
932            global_ids: BTreeMap::new(),
933            suspended: None,
934        }
935    }
936
937    fn referencing(global: &str, value: Value) -> SaveState {
938        let mut save = empty_save_state();
939        save.globals.insert(global.to_string(), value);
940        save
941    }
942
943    // ── HandleRegistry basics ─────────────────────────────────────────────
944
945    #[test]
946    fn mint_and_get_roundtrip() {
947        let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
948        let id = reg.mint(TimerState {
949            remaining_secs: 3.0,
950        });
951        assert_eq!(
952            reg.get(id),
953            Some(&TimerState {
954                remaining_secs: 3.0
955            })
956        );
957        assert!(reg.contains(id));
958        assert_eq!(reg.len(), 1);
959    }
960
961    #[test]
962    fn mint_value_none_when_kind_never_interned() {
963        let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
964        let mut app = make_test_app();
965        add_story_assets(&mut app, program, tables, ctx);
966        let program = &app
967            .world()
968            .resource::<Assets<ProgramAsset>>()
969            .iter()
970            .next()
971            .expect("one program asset")
972            .1
973            .program;
974        let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
975        // "Timer" is never mentioned in the compiled source, so it was
976        // never interned — no NameId to build a token against.
977        assert!(
978            reg.mint_value(
979                program,
980                TimerState {
981                    remaining_secs: 1.0
982                }
983            )
984            .is_none()
985        );
986    }
987
988    #[test]
989    fn mint_value_resolves_interned_kind_name() {
990        let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
991        let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
992        let value = reg
993            .mint_value(
994                &program,
995                TimerState {
996                    remaining_secs: 5.0,
997                },
998            )
999            .expect("Timer was interned via the VAR declaration");
1000        let (kind, _id) = value.as_handle().expect("a handle value");
1001        assert_eq!(program.name_checked(kind), Some("Timer"));
1002        drop(tables);
1003        drop(ctx);
1004    }
1005
1006    // ── is_valid ────────────────────────────────────────────────────────
1007
1008    #[test]
1009    fn is_valid_true_for_live_registered_handle() {
1010        let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
1011        let mut app = make_test_app();
1012        app.register_handle_kind::<(), TimerKind>(TimerKind);
1013        let story = add_story_assets(&mut app, program, tables, ctx);
1014        let entity = app
1015            .world_mut()
1016            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1017            .id();
1018        app.update(); // fulfill: entity gains BrinkProgram<()>
1019
1020        let handle_value = {
1021            let world = app.world_mut();
1022            let program = &world
1023                .resource::<Assets<ProgramAsset>>()
1024                .iter()
1025                .next()
1026                .expect("program asset")
1027                .1
1028                .program;
1029            let kind = program.name_id("Timer").expect("interned");
1030            let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
1031            Value::handle(
1032                kind,
1033                reg.mint(TimerState {
1034                    remaining_secs: 2.0,
1035                }),
1036            )
1037        };
1038
1039        let result = app
1040            .world_mut()
1041            .run_system_once_with(is_valid_system::<()>, (entity, vec![handle_value]))
1042            .expect("is_valid runs");
1043        assert_eq!(result, Value::Bool(true));
1044    }
1045
1046    #[test]
1047    fn is_valid_false_for_dead_or_non_handle() {
1048        let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
1049        let mut app = make_test_app();
1050        app.register_handle_kind::<(), TimerKind>(TimerKind);
1051        let story = add_story_assets(&mut app, program, tables, ctx);
1052        let entity = app
1053            .world_mut()
1054            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1055            .id();
1056        app.update();
1057
1058        // Not a handle at all.
1059        let result = app
1060            .world_mut()
1061            .run_system_once_with(is_valid_system::<()>, (entity, vec![Value::Int(1)]))
1062            .expect("is_valid runs");
1063        assert_eq!(result, Value::Bool(false));
1064
1065        // A well-formed but never-minted token id.
1066        let kind = {
1067            let world = app.world_mut();
1068            let program = &world
1069                .resource::<Assets<ProgramAsset>>()
1070                .iter()
1071                .next()
1072                .expect("program asset")
1073                .1
1074                .program;
1075            program.name_id("Timer").expect("interned")
1076        };
1077        let result = app
1078            .world_mut()
1079            .run_system_once_with(
1080                is_valid_system::<()>,
1081                (entity, vec![Value::handle(kind, 9999)]),
1082            )
1083            .expect("is_valid runs");
1084        assert_eq!(result, Value::Bool(false));
1085    }
1086
1087    // ── save_handles / load_handles: the three round-trips from #775 ──────
1088
1089    #[test]
1090    fn save_resolve_live() {
1091        let (program, _tables, _ctx) =
1092            compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1093        let mut world = World::new();
1094        world.insert_resource(HandleKinds::<()>::default());
1095        world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1096        world.get_resource_or_insert_with(HandleKinds::<()>::default);
1097        // Register directly (no App needed for this pure round-trip).
1098        world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1099        world
1100            .resource_mut::<HandleKinds<()>>()
1101            .kinds
1102            .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1103
1104        let kind = program.name_id("Npc").expect("interned");
1105        let id = world
1106            .resource_mut::<HandleRegistry<NpcKind>>()
1107            .mint(NpcState {
1108                guid: "abc".to_string(),
1109            });
1110
1111        let persisted = save_handles::<()>(&world);
1112        assert_eq!(persisted.entries["Npc"].len(), 1);
1113
1114        let referenced = referencing("npc_ref", Value::handle(kind, id));
1115        let report = load_handles::<()>(
1116            &mut world,
1117            &program,
1118            &referenced,
1119            &persisted,
1120            RehydrationPolicy::Lenient,
1121        )
1122        .expect("lenient load never errors");
1123
1124        assert_eq!(report.rebound, vec![("Npc".to_string(), id)]);
1125        assert!(report.is_fully_rebound());
1126        assert_eq!(
1127            world.resource::<HandleRegistry<NpcKind>>().get(id),
1128            Some(&NpcState {
1129                guid: "abc".to_string()
1130            })
1131        );
1132    }
1133
1134    #[test]
1135    fn save_despawn_load_dead_declared_fallback() {
1136        let (program, _tables, _ctx) =
1137            compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1138        let mut world = World::new();
1139        world.insert_resource(HandleKinds::<()>::default());
1140        world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1141        world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1142        world
1143            .resource_mut::<HandleKinds<()>>()
1144            .kinds
1145            .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1146
1147        let kind = program.name_id("Npc").expect("interned");
1148        let id = world
1149            .resource_mut::<HandleRegistry<NpcKind>>()
1150            .mint(NpcState {
1151                guid: "abc".to_string(),
1152            });
1153        let persisted = save_handles::<()>(&world);
1154
1155        // Despawn: the NPC is gone by load time (not in the "alive" set
1156        // any more), and the live registry entry from the old session
1157        // doesn't survive a real process restart either.
1158        world.resource_mut::<AliveNpcs>().0.clear();
1159        world.resource_mut::<HandleRegistry<NpcKind>>().remove(id);
1160
1161        let referenced = referencing("npc_ref", Value::handle(kind, id));
1162        let report = load_handles::<()>(
1163            &mut world,
1164            &program,
1165            &referenced,
1166            &persisted,
1167            RehydrationPolicy::Lenient,
1168        )
1169        .expect("lenient load never errors");
1170
1171        assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), id)]);
1172        assert!(!report.is_fully_rebound());
1173        assert_eq!(world.resource::<HandleRegistry<NpcKind>>().get(id), None);
1174
1175        // The binding-side declared-failure-value pattern: a binding that
1176        // dereferences this now-dead token falls back to whatever value it
1177        // has chosen to declare (here, -1) rather than faulting.
1178        let declared_fallback = world
1179            .resource::<HandleRegistry<NpcKind>>()
1180            .get(id)
1181            .map_or(Value::Int(-1), |_| Value::Int(0));
1182        assert_eq!(declared_fallback, Value::Int(-1));
1183    }
1184
1185    #[test]
1186    fn timer_reconstruction_after_restart() {
1187        let (program, _tables, _ctx) =
1188            compile_test_story("VAR timer_ref = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
1189        let mut world = World::new();
1190        world.insert_resource(HandleKinds::<()>::default());
1191        world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
1192        world.resource_mut::<HandleKinds<()>>().kinds.insert(
1193            TimerKind::KIND,
1194            Box::new(RegistryOps::<TimerKind>::default()),
1195        );
1196
1197        let kind = program.name_id("Timer").expect("interned");
1198        let id = world
1199            .resource_mut::<HandleRegistry<TimerKind>>()
1200            .mint(TimerState {
1201                remaining_secs: 12.5,
1202            });
1203        let persisted = save_handles::<()>(&world);
1204        assert_eq!(
1205            persisted.entries["Timer"][0].key,
1206            serde_json::json!({ "remaining_secs": 12.5 })
1207        );
1208
1209        // Simulate a fresh process: the old TimerState instance is gone,
1210        // only the persisted recipe (remaining duration) survives.
1211        world.resource_mut::<HandleRegistry<TimerKind>>().remove(id);
1212
1213        let referenced = referencing("timer_ref", Value::handle(kind, id));
1214        let report = load_handles::<()>(
1215            &mut world,
1216            &program,
1217            &referenced,
1218            &persisted,
1219            RehydrationPolicy::Lenient,
1220        )
1221        .expect("lenient load never errors");
1222
1223        assert_eq!(report.rebound, vec![("Timer".to_string(), id)]);
1224        // Same token id (stability), freshly reconstructed resource with
1225        // the recipe's remaining duration — the timer resumed, not just
1226        // "found again".
1227        assert_eq!(
1228            world.resource::<HandleRegistry<TimerKind>>().get(id),
1229            Some(&TimerState {
1230                remaining_secs: 12.5
1231            })
1232        );
1233    }
1234
1235    #[test]
1236    fn dead_ephemeral_when_kind_declines_to_persist() {
1237        let (program, _tables, _ctx) =
1238            compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
1239        let mut world = World::new();
1240        world.insert_resource(HandleKinds::<()>::default());
1241        world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
1242        world.resource_mut::<HandleKinds<()>>().kinds.insert(
1243            TransientKind::KIND,
1244            Box::new(RegistryOps::<TransientKind>::default()),
1245        );
1246
1247        let kind = program.name_id("Transient").expect("interned");
1248        let id = world
1249            .resource_mut::<HandleRegistry<TransientKind>>()
1250            .mint(());
1251
1252        let persisted = save_handles::<()>(&world);
1253        // Ephemeral by choice: save_key always returns None, so nothing
1254        // was ever written for this kind.
1255        assert!(!persisted.entries.contains_key("Transient"));
1256
1257        let referenced = referencing("t_ref", Value::handle(kind, id));
1258        let report = load_handles::<()>(
1259            &mut world,
1260            &program,
1261            &referenced,
1262            &persisted,
1263            RehydrationPolicy::Lenient,
1264        )
1265        .expect("lenient load never errors");
1266
1267        assert_eq!(report.dead_ephemeral, vec![("Transient".to_string(), id)]);
1268        assert!(report.rebound.is_empty());
1269        assert!(report.dead_by_resolve.is_empty());
1270    }
1271
1272    // ── next_id reservation across dead/ephemeral tokens (review finding) ──
1273    //
1274    // Dead/ephemeral tokens stay live in ink state by this module's design
1275    // (only the registry's right-hand side rebinds; ink state is
1276    // untouched). If a later `mint` were free to reallocate such a token's
1277    // id, a fresh unrelated resource would silently start answering to the
1278    // stale token — a token-identity violation. These prove `next_id` is
1279    // reserved past every id `load_handles` saw, regardless of which
1280    // outcome bucket that id landed in.
1281
1282    #[test]
1283    fn mint_after_load_does_not_collide_with_dead_by_resolve_token() {
1284        let (program, _tables, _ctx) =
1285            compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1286        let mut world = World::new();
1287        world.insert_resource(HandleKinds::<()>::default());
1288        world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1289        world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1290        world
1291            .resource_mut::<HandleKinds<()>>()
1292            .kinds
1293            .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1294
1295        let kind = program.name_id("Npc").expect("interned");
1296        // Only token minted so far: id 0.
1297        let dead_id = world
1298            .resource_mut::<HandleRegistry<NpcKind>>()
1299            .mint(NpcState {
1300                guid: "abc".to_string(),
1301            });
1302        assert_eq!(dead_id, 0);
1303        let persisted = save_handles::<()>(&world);
1304
1305        // The NPC is gone by load time: resolve returns None ->
1306        // dead_by_resolve. The old registry entry doesn't survive a real
1307        // process restart either.
1308        world.resource_mut::<AliveNpcs>().0.clear();
1309        world
1310            .resource_mut::<HandleRegistry<NpcKind>>()
1311            .remove(dead_id);
1312
1313        // Ink global still holds `handle{Npc,0}`.
1314        let referenced = referencing("npc_ref", Value::handle(kind, dead_id));
1315        let report = load_handles::<()>(
1316            &mut world,
1317            &program,
1318            &referenced,
1319            &persisted,
1320            RehydrationPolicy::Lenient,
1321        )
1322        .expect("lenient load never errors");
1323        assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), dead_id)]);
1324
1325        // A later mint must not reallocate id 0 — the still-referenced
1326        // dead token's id — to this unrelated resource.
1327        let minted_id = world
1328            .resource_mut::<HandleRegistry<NpcKind>>()
1329            .mint(NpcState {
1330                guid: "def".to_string(),
1331            });
1332        assert_ne!(
1333            minted_id, dead_id,
1334            "mint after load must not collide with a dead-by-resolve token's id \
1335             still referenced by ink state"
1336        );
1337    }
1338
1339    #[test]
1340    fn mint_after_load_does_not_collide_with_dead_ephemeral_token() {
1341        let (program, _tables, _ctx) =
1342            compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
1343        let mut world = World::new();
1344        world.insert_resource(HandleKinds::<()>::default());
1345        world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
1346        world.resource_mut::<HandleKinds<()>>().kinds.insert(
1347            TransientKind::KIND,
1348            Box::new(RegistryOps::<TransientKind>::default()),
1349        );
1350
1351        let kind = program.name_id("Transient").expect("interned");
1352        // Only token minted so far: id 0. `save_key` always returns None,
1353        // so nothing is ever persisted for this kind — this id never even
1354        // reaches `by_id`, exercising the `continue` (no persisted entry)
1355        // branch of `rebind_selected`.
1356        let dead_id = world
1357            .resource_mut::<HandleRegistry<TransientKind>>()
1358            .mint(());
1359        assert_eq!(dead_id, 0);
1360        let persisted = save_handles::<()>(&world);
1361        assert!(!persisted.entries.contains_key("Transient"));
1362
1363        // Ink global still holds `handle{Transient,0}`.
1364        let referenced = referencing("t_ref", Value::handle(kind, dead_id));
1365        let report = load_handles::<()>(
1366            &mut world,
1367            &program,
1368            &referenced,
1369            &persisted,
1370            RehydrationPolicy::Lenient,
1371        )
1372        .expect("lenient load never errors");
1373        assert_eq!(
1374            report.dead_ephemeral,
1375            vec![("Transient".to_string(), dead_id)]
1376        );
1377
1378        // A later mint must not reallocate id 0 — the still-referenced
1379        // dead-ephemeral token's id.
1380        let minted_id = world
1381            .resource_mut::<HandleRegistry<TransientKind>>()
1382            .mint(());
1383        assert_ne!(
1384            minted_id, dead_id,
1385            "mint after load must not collide with a dead-ephemeral token's id \
1386             still referenced by ink state"
1387        );
1388    }
1389
1390    #[test]
1391    fn unregistered_kind_lenient_reports_strict_fails() {
1392        let (program, _tables, _ctx) =
1393            compile_test_story("VAR ghost_ref = 0\nVAR Ghost = 0\nHi.\n-> DONE\n");
1394        let kind = program.name_id("Ghost").expect("interned");
1395        let referenced = referencing("ghost_ref", Value::handle(kind, 7));
1396        let persisted = HandleSaveState::default();
1397
1398        let mut lenient_world = World::new();
1399        lenient_world.insert_resource(HandleKinds::<()>::default());
1400        let report = load_handles::<()>(
1401            &mut lenient_world,
1402            &program,
1403            &referenced,
1404            &persisted,
1405            RehydrationPolicy::Lenient,
1406        )
1407        .expect("lenient never errors, even for unregistered kinds");
1408        assert_eq!(
1409            report.dead_by_unregistered_kind,
1410            vec![("Ghost".to_string(), 7)]
1411        );
1412
1413        let mut strict_world = World::new();
1414        strict_world.insert_resource(HandleKinds::<()>::default());
1415        let err = load_handles::<()>(
1416            &mut strict_world,
1417            &program,
1418            &referenced,
1419            &persisted,
1420            RehydrationPolicy::StrictKinds,
1421        )
1422        .expect_err("StrictKinds fails loudly on an unregistered kind");
1423        assert_eq!(
1424            err,
1425            HandleLoadError::UnregisteredKinds(vec!["Ghost".to_string()])
1426        );
1427    }
1428
1429    // ── Registry GC ─────────────────────────────────────────────────────
1430
1431    #[test]
1432    fn sweep_drops_unreachable_keeps_reachable() {
1433        let mut world = World::new();
1434        world.insert_resource(HandleKinds::<()>::default());
1435        world.insert_resource(HandleRetentionMetrics::<()>::default());
1436        world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
1437        world.resource_mut::<HandleKinds<()>>().kinds.insert(
1438            TimerKind::KIND,
1439            Box::new(RegistryOps::<TimerKind>::default()),
1440        );
1441
1442        let (reachable_id, orphan_id) = {
1443            let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
1444            (
1445                reg.mint(TimerState {
1446                    remaining_secs: 1.0,
1447                }),
1448                reg.mint(TimerState {
1449                    remaining_secs: 2.0,
1450                }),
1451            )
1452        };
1453
1454        let mut reachable = BTreeMap::new();
1455        reachable.insert("Timer".to_string(), Set::from([reachable_id]));
1456        sweep_registries::<()>(&mut world, &reachable);
1457
1458        let reg = world.resource::<HandleRegistry<TimerKind>>();
1459        assert!(reg.contains(reachable_id));
1460        assert!(!reg.contains(orphan_id));
1461
1462        let metrics = world.resource::<HandleRetentionMetrics<()>>();
1463        let timer = &metrics.per_kind["Timer"];
1464        assert_eq!(timer.live, 1);
1465        assert_eq!(timer.last_gc_dropped, 1);
1466        assert_eq!(timer.sweeps, 1);
1467    }
1468
1469    /// End-to-end: a real flow reaching `-> DONE` through
1470    /// [`crate::bindings::advance_flow`] fires [`BrinkTurnDone`], which
1471    /// [`BrinkPlugin`](crate::BrinkPlugin) has wired to [`gc_on_turn_done`] —
1472    /// proving the GC sweep is reachable from actual story playback, not
1473    /// just callable in isolation.
1474    #[test]
1475    fn gc_on_turn_done_is_wired_by_the_plugin() {
1476        let (program, tables, ctx) =
1477            compile_test_story("VAR target = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
1478        let mut app = make_test_app();
1479        app.register_handle_kind::<(), TimerKind>(TimerKind);
1480        let story = add_story_assets(&mut app, program, tables, ctx);
1481        let entity = app
1482            .world_mut()
1483            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1484            .id();
1485        app.update(); // fulfill
1486
1487        let (kind, target_idx) = {
1488            let world = app.world();
1489            let program = &world
1490                .resource::<Assets<ProgramAsset>>()
1491                .iter()
1492                .next()
1493                .expect("program asset")
1494                .1
1495                .program;
1496            (
1497                program.name_id("Timer").expect("interned"),
1498                program.global_index("target").expect("declared"),
1499            )
1500        };
1501        let (reachable_id, orphan_id) = {
1502            let mut reg = app.world_mut().resource_mut::<HandleRegistry<TimerKind>>();
1503            (
1504                reg.mint(TimerState {
1505                    remaining_secs: 1.0,
1506                }),
1507                reg.mint(TimerState {
1508                    remaining_secs: 2.0,
1509                }),
1510            )
1511        };
1512
1513        // Make `target` (a World-scoped global) hold the reachable token,
1514        // so the GC sweep's reachability scan picks it up from
1515        // `BrinkGlobals`'s own state.
1516        app.world_mut()
1517            .resource_mut::<BrinkGlobals<()>>()
1518            .inner
1519            .set_global(target_idx, Value::handle(kind, reachable_id));
1520
1521        {
1522            let world = app.world_mut();
1523            // Terminals carry no payload of their own (§7) — the trailing
1524            // "Hi.\n" content arrives as its own `Step::Line` before the
1525            // bare `Step::Done`, so this drives until the actual turn-done
1526            // event (which the GC sweep is wired to) instead of assuming
1527            // one call reaches it.
1528            loop {
1529                let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
1530                if step.is_terminal() {
1531                    break;
1532                }
1533            }
1534            world.flush();
1535        }
1536        app.update();
1537
1538        let world = app.world();
1539        let reg = world.resource::<HandleRegistry<TimerKind>>();
1540        assert!(
1541            reg.contains(reachable_id),
1542            "reachable token must survive the sweep"
1543        );
1544        assert!(
1545            !reg.contains(orphan_id),
1546            "unreferenced token must be dropped by the -> DONE sweep"
1547        );
1548    }
1549
1550    /// The registered-kinds predicate the `-> DONE` GC gate keys on (#1007):
1551    /// empty until a host registers a [`HandleKind`], non-empty after.
1552    #[test]
1553    fn handle_kinds_is_empty_tracks_registration() {
1554        let mut app = make_test_app();
1555        assert!(
1556            app.world().resource::<HandleKinds<()>>().is_empty(),
1557            "no kind registered → empty",
1558        );
1559        app.register_handle_kind::<(), TimerKind>(TimerKind);
1560        assert!(
1561            !app.world().resource::<HandleKinds<()>>().is_empty(),
1562            "after register_handle_kind → non-empty",
1563        );
1564    }
1565
1566    /// End-to-end gate proof: a handle-free story reaching `-> DONE` fires
1567    /// [`BrinkTurnDone`] but [`gc_on_turn_done`] must short-circuit on the
1568    /// empty [`HandleKinds`] before the O(total flows) reachable-token walk
1569    /// (#1007) — so no sweep runs and no retention metrics are recorded,
1570    /// while playback still reaches DONE.
1571    #[test]
1572    fn gc_on_turn_done_skips_scan_when_no_kinds_registered() {
1573        let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
1574        let mut app = make_test_app();
1575        // Deliberately NO `register_handle_kind` — the plugin still inserts an
1576        // empty `HandleKinds<()>`, which is exactly what the gate checks.
1577        let story = add_story_assets(&mut app, program, tables, ctx);
1578        let entity = app
1579            .world_mut()
1580            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1581            .id();
1582        app.update(); // fulfill
1583
1584        assert!(
1585            app.world().resource::<HandleKinds<()>>().is_empty(),
1586            "gate precondition: no kind registered",
1587        );
1588
1589        {
1590            let world = app.world_mut();
1591            // Terminals carry no payload of their own (§7) — the trailing
1592            // "Hi.\n" content arrives as its own `Step::Line` before the
1593            // bare `Step::Done`, so this drives until the actual turn-done
1594            // event (which the GC sweep is wired to) instead of assuming
1595            // one call reaches it.
1596            loop {
1597                let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
1598                if step.is_terminal() {
1599                    break;
1600                }
1601            }
1602            world.flush();
1603        }
1604        app.update(); // fires BrinkTurnDone → gc_on_turn_done early-returns
1605
1606        assert!(
1607            app.world()
1608                .resource::<HandleRetentionMetrics<()>>()
1609                .per_kind
1610                .is_empty(),
1611            "handle-free -> DONE must record no retention metrics (gate skipped the sweep)",
1612        );
1613    }
1614}