Skip to main content

bevy_brink/
capability.rs

1//! BH-1: the bevy host capability join (`docs/effects-spec.md` §9, §12–§13;
2//! tracking #897, this slice #899).
3//!
4//! Pure data plumbing — no scheduling, no `unsafe`. This module owns the
5//! host half of the "ECS join" §9 describes: brink-side rows speak cells +
6//! call kinds (all the compiler can see, shipped in `.inkb`'s `EffectRows`
7//! section, T2-3/PR #878); the host manifest declares each binding's
8//! capability signature in engine vocabulary (§13.2); at story load, the two
9//! join into a `bevy_ecs::query::Access` per container — the same currency
10//! bevy's own executor uses, so a later scheduler (BH-3) can test
11//! disjointness with `Access::is_compatible` directly.
12//!
13//! - [`CapabilityManifest`]/[`CapabilityManifestExternal`]/[`CapabilityEffects`]
14//!   — §13.2 grammar: `{"name": ..., "effects": {"reads": [...], "writes":
15//!   [...], "detect": {...}}}`. Capability names are engine-vocabulary
16//!   strings, **compiler-opaque** — deserialized here as plain `String`s;
17//!   bevy-brink is the only thing that gives them meaning.
18//! - [`CapabilityRegistry`]/[`BrinkCapabilityAppExt::register_capability`] —
19//!   the app-level name → `ComponentId` map, mirroring the
20//!   [`HandleKind`](crate::HandleKind) registration pattern
21//!   (`crates/bevy-brink/src/handle.rs`, T1d-3/PR #780): an app-builder
22//!   extension trait, a type-keyed `Resource` (here keyed by string name
23//!   directly rather than by a per-kind trait, since a capability is just a
24//!   `ComponentId` — no save/resolve halves to erase).
25//! - [`compute_container_access`] — the row join: for every
26//!   [`EffectRowEntry`] a loaded story ships, resolve its call atoms'
27//!   `NameId`s back to external names, look each up in the manifest, resolve
28//!   the declared capability names against the registry (an unregistered
29//!   name is a load-time [`CapabilityError::UnknownCapability`], never a
30//!   silent drop), and fold the result into one [`Access`] per container —
31//!   also walking every dispatch's static fallback row (`docs/effects-spec.md`
32//!   §7: v1 does no runtime narrowing, so the conservative fallback always
33//!   applies; skipping it would under-report access, the one soundness
34//!   direction §3 forbids).
35//! - [`CapabilityTable`]/[`rebuild_capability_table`] — the load/unload
36//!   boundary: a `Resource` keyed by `AssetId<ProgramAsset>`, rebuilt
37//!   whenever a story (re)loads and torn down when it unloads (§12.5's
38//!   "story load/unload is when the params rebuild" invariant, applied here
39//!   to the per-container `Access` table rather than a `SystemParamBuilder`).
40//! - [`dump_container_access`] — the dev-visible debug fn (container → access
41//!   set), for BH-B's scenario harness and interactive debugging.
42//! - [`missing_capabilities`]/[`MissingCapability`]/[`CapabilityError::LoadRejected`]/
43//!   [`check_load_capability_gate`] — issue #912's load-boundary admission
44//!   check: the manifest stays app-global while [`CapabilityRegistry`] is
45//!   per-marker `M`, so a story loaded under a marker whose registry lacks a
46//!   manifest-required capability must fail to load *at all* under that
47//!   marker, loudly, rather than joining to a silently-incomplete
48//!   `UnknownCapability` err-table at call time.
49//!   [`check_load_capability_gate`] is the one shared helper every
50//!   load-shaped entry point must call before constructing a `FlowInstance`
51//!   from a `ProgramAsset` (#997): `bevy-brink`'s `fulfill_flow_requests::<M>`
52//!   (`crates/bevy-brink/src/request.rs`, the initial load) and
53//!   `replay_on_reload::<M>` (`crates/bevy-brink/src/replay.rs`, the dev-only
54//!   hot-reload reconstruction) both call it at their respective
55//!   story-construction boundary and refuse to proceed when it errs.
56
57use std::any::TypeId;
58use std::collections::{BTreeMap, BTreeSet};
59use std::marker::PhantomData;
60
61use bevy_app::{App, Update};
62use bevy_asset::{AssetEvent, AssetId, Assets};
63use bevy_ecs::component::{Component, ComponentId};
64use bevy_ecs::message::MessageReader;
65use bevy_ecs::query::{Access, Changed};
66use bevy_ecs::resource::Resource;
67use bevy_ecs::schedule::IntoScheduleConfigs as _;
68use bevy_ecs::schedule::common_conditions::any_with_component;
69use bevy_ecs::system::{Query, Res, ResMut};
70use bevy_log::error;
71use brink_format::{CallAtom, DefinitionId, DirectEffects, EffectRowEntry};
72use brink_runtime::Program;
73use serde::{Deserialize, Serialize};
74use thiserror::Error;
75
76use crate::asset::ProgramAsset;
77
78// ── Manifest grammar (§13.2) ─────────────────────────────────────────────
79
80/// The `effects` object on a manifest external (`docs/effects-spec.md`
81/// §13.2): `{"reads": [...], "writes": [...], "detect": {...}}`. Every field
82/// is optional (defaults empty) — an external with no `effects` key at all
83/// touches no ECS capability.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct CapabilityEffects {
86    /// Capability names this external reads. Compiler-opaque engine
87    /// vocabulary (e.g. `"Transform"`) — meaningless until resolved through a
88    /// [`CapabilityRegistry`].
89    #[serde(default)]
90    pub reads: Vec<String>,
91    /// Capability names this external writes.
92    #[serde(default)]
93    pub writes: Vec<String>,
94    /// Capability name → change-detection-backed bit: `true` means bevy's
95    /// own change ticks can back a wake/reactive-sleep dependency on this
96    /// capability; `false` (or absent) means it must be polled. Consumed by
97    /// BH-4's Detect phase (`crate::sleep`) after the per-container
98    /// AND/conservative merge (`#913`).
99    #[serde(default)]
100    pub detect: BTreeMap<String, bool>,
101}
102
103/// One external's manifest entry, restricted to the fields BH-1 needs.
104/// Deserialized from the **same** JSON manifest file
105/// `docs/host-capability-manifest.md`/`brink_ir::host_manifest` describes for
106/// the compiler/IDE side (`name`, `params`, `kind`, `doc`, `widgets`, `path`,
107/// …) — this type only names `name` and `effects`; every other key present in
108/// a real manifest file is ignored by `serde`'s default "unknown fields are
109/// fine" behavior, so the same file serves both consumers.
110///
111/// **Not converged onto `brink_ir::host_manifest::ManifestExternal`** (issue
112/// #911, BH follow-up deliverable 1) — see that type's module doc for the
113/// full rationale (opposite-direction crate dependency the two sides must
114/// never take on each other). The two shared keys (`externals`, `name`) are
115/// pinned by `brink_format::manifest_field_names`; `tests/manifest_field_convergence.rs`
116/// cross-validates one manifest literal against both types.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct CapabilityManifestExternal {
119    pub name: String,
120    #[serde(default)]
121    pub effects: CapabilityEffects,
122}
123
124/// The top-level manifest shape: `{"externals": [...]}`. Register one as a
125/// `Resource` (`app.insert_resource(CapabilityManifest::from_json(json)?)`)
126/// before or after adding [`crate::BrinkPlugin`] — order doesn't matter,
127/// [`BrinkPlugin`](crate::BrinkPlugin) only `init_resource`s an empty default
128/// if none is present yet.
129#[derive(Debug, Clone, Default, PartialEq, Eq, Resource, Serialize, Deserialize)]
130pub struct CapabilityManifest {
131    #[serde(default)]
132    pub externals: Vec<CapabilityManifestExternal>,
133}
134
135impl CapabilityManifest {
136    /// Parse a manifest from its JSON text (§13.2 grammar).
137    pub fn from_json(json: &str) -> Result<Self, CapabilityError> {
138        Ok(serde_json::from_str(json)?)
139    }
140
141    /// Look up an external's manifest entry by name. First match on
142    /// duplicate names (not a documented case; manifests are host-authored).
143    #[must_use]
144    pub fn external(&self, name: &str) -> Option<&CapabilityManifestExternal> {
145        self.externals.iter().find(|e| e.name == name)
146    }
147}
148
149// ── Errors ────────────────────────────────────────────────────────────────
150
151/// Errors from manifest parsing or the row join.
152#[derive(Debug, Error)]
153pub enum CapabilityError {
154    #[error("capability manifest JSON is malformed: {0}")]
155    ManifestJson(#[from] serde_json::Error),
156    /// The tier-1 admission rule: a manifest-declared capability name that no
157    /// `register_capability` call ever registered. Always a load-time error
158    /// — never silently dropped (an unregistered name means the join cannot
159    /// prove which `ComponentId` the row may touch, which would under-report
160    /// access, the one direction `docs/effects-spec.md` §3 forbids).
161    #[error(
162        "external `{external}` declares capability `{capability}` in its effects manifest, \
163         but no `register_capability::<_, _>(\"{capability}\")` call has registered that name \
164         — capability join cannot proceed for this story"
165    )]
166    UnknownCapability {
167        external: String,
168        capability: String,
169    },
170    /// Load-boundary hard error (issue #912, RULED 2026-07-18 option (b)):
171    /// a story failed to load under a marker because that marker's
172    /// [`CapabilityRegistry`] is missing one or more manifest-required
173    /// capabilities. Raised by `bevy-brink`'s `fulfill_flow_requests` at
174    /// the story-load boundary itself — the tier-1 admission posture
175    /// applied per-marker. Before this variant existed, an unregistered
176    /// name only ever surfaced as a per-story [`UnknownCapability`] logged
177    /// into [`CapabilityTable`] at call time (a silent err-table); this is
178    /// the load refusing to happen at all, loudly, naming the marker, the
179    /// story, and every missing capability at once (not just the first).
180    #[error(
181        "story `{story}` failed to load under marker `{marker}`: this marker's \
182         CapabilityRegistry is missing {} manifest-required capability name(s): {}",
183        missing.len(),
184        missing
185            .iter()
186            .map(|m| format!("`{}` (required by external `{}`)", m.capability, m.external))
187            .collect::<Vec<_>>()
188            .join(", ")
189    )]
190    LoadRejected {
191        /// The marker type name (`std::any::type_name::<M>()`) the story
192        /// was loading under.
193        marker: &'static str,
194        /// A human-readable identifier for the story — its asset path if
195        /// the handle carries one, otherwise its `AssetId` debug form.
196        story: String,
197        /// Every manifest-required capability name this marker's registry
198        /// doesn't recognize, deduplicated and sorted (`external`, then
199        /// `capability`).
200        missing: Vec<MissingCapability>,
201    },
202}
203
204/// One manifest-declared capability name a marker's [`CapabilityRegistry`]
205/// doesn't recognize — the unit [`missing_capabilities`] collects and
206/// [`CapabilityError::LoadRejected`] reports in full (issue #912).
207#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
208pub struct MissingCapability {
209    /// The external whose manifest entry declares the missing capability.
210    pub external: String,
211    /// The capability name itself (engine vocabulary, e.g. `"Transform"`).
212    pub capability: String,
213}
214
215// ── Registry: name → ComponentId (mirrors HandleKind's registration) ────
216
217/// App-level registry mapping capability names to `ComponentId`s, keyed by
218/// marker `M` (mirrors [`HandleKinds<M>`](crate::HandleKinds)). Populated via
219/// [`BrinkCapabilityAppExt::register_capability`]. `BTreeMap` for
220/// deterministic iteration (CLAUDE.md determinism rule).
221#[derive(Resource)]
222pub struct CapabilityRegistry<M: Send + Sync + 'static = ()> {
223    names: BTreeMap<&'static str, ComponentId>,
224    /// Parallel name → concrete-component `TypeId` map. Keyed the same way as
225    /// `names`, but valued by `TypeId` rather than `ComponentId` because the
226    /// §12.5 change-tick tracker ([`CapabilityChanges`]) keys its per-frame
227    /// verdict by `TypeId` — the one identity a
228    /// [`detect_capability_changes`] system (generic over the concrete `C`)
229    /// can compute at runtime with no `&World`/`Components` access.
230    type_ids: BTreeMap<&'static str, TypeId>,
231    /// Component `TypeId`s that already have a [`detect_capability_changes`]
232    /// system wired into `Update` — the idempotency guard so registering the
233    /// same component (or two names for one component) never double-adds its
234    /// change-tracker system.
235    detect_wired: BTreeSet<TypeId>,
236    _marker: PhantomData<fn() -> M>,
237}
238
239impl<M: Send + Sync + 'static> Default for CapabilityRegistry<M> {
240    fn default() -> Self {
241        Self {
242            names: BTreeMap::new(),
243            type_ids: BTreeMap::new(),
244            detect_wired: BTreeSet::new(),
245            _marker: PhantomData,
246        }
247    }
248}
249
250impl<M: Send + Sync + 'static> CapabilityRegistry<M> {
251    /// Resolve a registered capability name to its `ComponentId`. `None`
252    /// means no `register_capability` call has claimed this name yet.
253    #[must_use]
254    pub fn component_id(&self, name: &str) -> Option<ComponentId> {
255        self.names.get(name).copied()
256    }
257
258    /// Resolve a registered capability name to its concrete component's
259    /// `TypeId` — the key BH-4's Detect phase (`crate::sleep`) uses to look
260    /// the capability's per-frame change verdict up in [`CapabilityChanges`]
261    /// (§12.5). `None` means no `register_capability` call has claimed this
262    /// name, so the capability is **untracked**: the wake layer must
263    /// conservatively must-poll it (it cannot prove the component is
264    /// unchanged, and a missed wake is the engine-race bug class).
265    #[must_use]
266    pub fn type_id(&self, name: &str) -> Option<TypeId> {
267        self.type_ids.get(name).copied()
268    }
269
270    /// The capability names registered so far, in deterministic order.
271    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
272        self.names.keys().copied()
273    }
274}
275
276/// App-builder extension for registering capability names.
277pub trait BrinkCapabilityAppExt {
278    /// Register component `C` under the engine-vocabulary `name` a manifest
279    /// `effects.reads`/`effects.writes` entry may reference, for marker `M`.
280    /// Mirrors [`BrinkHandleAppExt::register_handle_kind`](crate::BrinkHandleAppExt::register_handle_kind):
281    /// resolves (registering if needed) `C`'s `ComponentId` and indexes it in
282    /// [`CapabilityRegistry<M>`] by name.
283    fn register_capability<M: Send + Sync + 'static, C: Component>(
284        &mut self,
285        name: &'static str,
286    ) -> &mut Self;
287}
288
289impl BrinkCapabilityAppExt for App {
290    fn register_capability<M: Send + Sync + 'static, C: Component>(
291        &mut self,
292        name: &'static str,
293    ) -> &mut Self {
294        let id = self.world_mut().register_component::<C>();
295        self.world_mut()
296            .get_resource_or_insert_with(CapabilityRegistry::<M>::default);
297        // Index the name (→ ComponentId for the row join, → TypeId for the
298        // §12.5 change tracker) and learn whether `C` still needs a
299        // change-tracker system wired. `BTreeSet::insert` returns `true` only
300        // the first time `C`'s `TypeId` is seen — the idempotency guard.
301        let needs_detect_system = {
302            let mut registry = self.world_mut().resource_mut::<CapabilityRegistry<M>>();
303            registry.names.insert(name, id);
304            registry.type_ids.insert(name, TypeId::of::<C>());
305            registry.detect_wired.insert(TypeId::of::<C>())
306        };
307        // The per-frame change-verdict sink the tracker writes and
308        // `mark_wake_dirty` reads (§12.5). Idempotent: only the first
309        // capability under marker `M` creates it.
310        self.init_resource::<CapabilityChanges<M>>();
311        if needs_detect_system {
312            // BH detect path (#996, `docs/effects-spec.md` §12.5): wire a
313            // typed change-tracker for `C` so a component-backed,
314            // detect-capable wake condition re-evaluates only when `C`
315            // actually changed — not every frame. Ordered before
316            // `mark_wake_dirty` (its reader) so a same-frame component change
317            // is seen this pass; gated on `any_with_component::<FlowSleep<M>>`
318            // exactly like the wake systems, so it costs nothing until a flow
319            // sleeps.
320            self.add_systems(
321                Update,
322                detect_capability_changes::<M, C>
323                    .before(crate::sleep::mark_wake_dirty::<M>)
324                    .run_if(any_with_component::<crate::sleep::FlowSleep<M>>),
325            );
326        }
327        self
328    }
329}
330
331// ── §12.5: per-capability component-tick tracking (#996) ────────────────────
332
333/// Per-frame, per-capability change verdict — the §12.5 hook BH-4's Detect
334/// phase (`crate::sleep::mark_wake_dirty`) consumes so a component-backed
335/// **detect-capable** wake condition gets the cheap re-evaluate-on-change
336/// path without the missed-wake class.
337///
338/// Keyed by the concrete component's `TypeId` (see
339/// [`CapabilityRegistry::type_id`] for why `TypeId` rather than `ComponentId`).
340/// A [`detect_capability_changes`] system — one per registered component,
341/// wired by [`BrinkCapabilityAppExt::register_capability`] — overwrites its
342/// component's entry every frame with whether any entity carrying that
343/// component changed since the tracker last ran (bevy's own `Changed<C>`
344/// window). An **absent** entry means no tracker has recorded a verdict for
345/// that component yet this run — the wake layer treats that, like an
346/// unregistered capability, as a conservative must-poll (never a missed wake).
347#[derive(Resource)]
348pub struct CapabilityChanges<M: Send + Sync + 'static = ()> {
349    /// component `TypeId` → did any entity carrying it change since the
350    /// tracker's last run this frame. `BTreeMap` for the determinism rule,
351    /// though this map is only ever point-looked-up, never iterated for
352    /// output.
353    changed: BTreeMap<TypeId, bool>,
354    _marker: PhantomData<fn() -> M>,
355}
356
357impl<M: Send + Sync + 'static> Default for CapabilityChanges<M> {
358    fn default() -> Self {
359        Self {
360            changed: BTreeMap::new(),
361            _marker: PhantomData,
362        }
363    }
364}
365
366impl<M: Send + Sync + 'static> CapabilityChanges<M> {
367    /// This frame's change verdict for a component `TypeId`: `Some(true)` if
368    /// an entity carrying it changed since the tracker last ran, `Some(false)`
369    /// if it is tracked but unchanged, and `None` if no tracker has recorded a
370    /// verdict for it yet (untracked — the wake layer must-polls it).
371    #[must_use]
372    pub fn changed(&self, ty: TypeId) -> Option<bool> {
373        self.changed.get(&ty).copied()
374    }
375}
376
377/// Typed change-tracker for one registered capability component `C` (§12.5,
378/// #996). Wired into `Update` by
379/// [`BrinkCapabilityAppExt::register_capability`], one per distinct component,
380/// ordered before [`mark_wake_dirty`](crate::sleep::mark_wake_dirty).
381///
382/// `Query<(), Changed<C>>` rides bevy's own per-table change ticks: on a quiet
383/// frame it matches nothing (tables whose change tick didn't advance are
384/// skipped wholesale), so `is_empty()` is cheap — far cheaper than the
385/// alternative it replaces (re-evaluating every parked flow's wake condition,
386/// a full `bind_brink_query` round trip, every single frame). The verdict is
387/// written by the component's `TypeId` so `mark_wake_dirty` — which only knows
388/// capability *names*, resolved to `TypeId`s through [`CapabilityRegistry`] —
389/// can read it back without ever needing the concrete `C`.
390pub fn detect_capability_changes<M: Send + Sync + 'static, C: Component>(
391    changed: Query<(), Changed<C>>,
392    mut sink: ResMut<CapabilityChanges<M>>,
393) {
394    let any_changed = !changed.is_empty();
395    sink.changed.insert(TypeId::of::<C>(), any_changed);
396}
397
398// ── The row join ─────────────────────────────────────────────────────────
399
400/// A story's full joined access table: every container's (knot/stitch's)
401/// [`DefinitionId`] mapped to its [`ContainerAccess`].
402pub type ContainerAccessTable = BTreeMap<DefinitionId, ContainerAccess>;
403
404/// One container's (knot/stitch's) joined ECS access — the output of folding
405/// an [`EffectRowEntry`] through the [`CapabilityManifest`] and
406/// [`CapabilityRegistry`] (`docs/effects-spec.md` §9).
407#[derive(Debug, Clone, Default)]
408pub struct ContainerAccess {
409    /// The joined access, in bevy's own currency (§12.2: "the row-join output
410    /// is the same currency as bevy's `FilteredAccessSet`"). BH-3's parallel
411    /// step phase tests flow disjointness with `Access::is_compatible` on
412    /// this directly.
413    pub access: Access,
414    /// Capability names this container reads, sorted — the human-readable
415    /// projection of `access`'s read set, for [`dump_container_access`].
416    pub reads: Vec<String>,
417    /// Capability names this container writes, sorted.
418    pub writes: Vec<String>,
419    /// Capability name → change-detection-backed bit, **AND-merged** across
420    /// every call this container's row (and its dispatch fallbacks) may
421    /// perform (`#913`, ruled 2026-07-18): the capability is detect-capable
422    /// for this container only if EVERY read of it is detect-capable, so a
423    /// single non-detectable read folds the bit to the conservative `false`
424    /// (must-poll). BH-4's Detect phase (`crate::sleep`) consumes this to
425    /// decide a sleeping flow's re-evaluation cadence.
426    pub detect: BTreeMap<String, bool>,
427    /// Whether any part of this row hit the pessimal top element
428    /// (`docs/effects-spec.md` §3: a call whose effects inference couldn't
429    /// summarize). When set, `access` is `read_all`+`write_all` rather than
430    /// the joined capability set — conservative-total, never under-report.
431    pub opaque: bool,
432}
433
434/// Mutable fold state for one container's row join — bundled into a struct
435/// (rather than five separate `&mut` parameters) so [`join_direct`] and
436/// [`resolve_call_atom`] stay small.
437#[derive(Default)]
438struct JoinAccumulator {
439    access: Access,
440    reads: BTreeSet<String>,
441    writes: BTreeSet<String>,
442    detect: BTreeMap<String, bool>,
443    opaque: bool,
444}
445
446impl JoinAccumulator {
447    fn into_container_access(self) -> ContainerAccess {
448        ContainerAccess {
449            access: self.access,
450            reads: self.reads.into_iter().collect(),
451            writes: self.writes.into_iter().collect(),
452            detect: self.detect,
453            opaque: self.opaque,
454        }
455    }
456}
457
458/// Fold one [`DirectEffects`] row (the entry's direct part, or a dispatch's
459/// static fallback) into `acc`. Shared by both call sites in
460/// [`compute_container_access`] so dispatch fallbacks join identically to
461/// the direct part (`docs/effects-spec.md` §7: v1 does no runtime narrowing,
462/// so the fallback always applies — omitting it would under-report access).
463fn join_direct<M: Send + Sync + 'static>(
464    direct: &DirectEffects,
465    program: &Program,
466    manifest: &CapabilityManifest,
467    registry: &CapabilityRegistry<M>,
468    acc: &mut JoinAccumulator,
469) -> Result<(), CapabilityError> {
470    if direct.opaque {
471        acc.opaque = true;
472        acc.access.read_all();
473        acc.access.write_all();
474    }
475
476    for call in &direct.calls {
477        resolve_call_atom(call, program, manifest, registry, acc)?;
478    }
479    Ok(())
480}
481
482/// Resolve one call atom's manifest-declared capabilities, if any, folding
483/// them into `acc`. A call whose `NameId` doesn't resolve, or that has no
484/// manifest entry at all, contributes no access — silently, since not every
485/// `EXTERNAL` touches ECS state (§13.2's `effects` key is opt-in).
486fn resolve_call_atom<M: Send + Sync + 'static>(
487    call: &CallAtom,
488    program: &Program,
489    manifest: &CapabilityManifest,
490    registry: &CapabilityRegistry<M>,
491    acc: &mut JoinAccumulator,
492) -> Result<(), CapabilityError> {
493    let Some(external_name) = program.name_checked(call.name) else {
494        return Ok(());
495    };
496    let Some(external) = manifest.external(external_name) else {
497        return Ok(());
498    };
499    for name in &external.effects.reads {
500        let id = resolve_capability(registry, external_name, name)?;
501        acc.access.add_read(id);
502        acc.reads.insert(name.clone());
503    }
504    for name in &external.effects.writes {
505        let id = resolve_capability(registry, external_name, name)?;
506        acc.access.add_write(id);
507        acc.writes.insert(name.clone());
508    }
509    for (name, bit) in &external.effects.detect {
510        // #913 (ruled 2026-07-18, decision-log): AND/conservative merge, NOT
511        // last-write-wins. A capability is change-detection-backed for this
512        // container only if EVERY read of it is detect-capable; two externals
513        // touching the same capability with conflicting `detect` bits fold to
514        // the conservative `false` (must-poll). A missed wake is the
515        // engine-race class; an extra poll is a wasted microsecond (§3
516        // soundness direction: over-report, never under-report). BH-4's Detect
517        // phase (`crate::sleep`) consumes exactly this merged bit.
518        acc.detect
519            .entry(name.clone())
520            .and_modify(|merged| *merged = *merged && *bit)
521            .or_insert(*bit);
522    }
523    Ok(())
524}
525
526fn resolve_capability<M: Send + Sync + 'static>(
527    registry: &CapabilityRegistry<M>,
528    external_name: &str,
529    capability: &str,
530) -> Result<ComponentId, CapabilityError> {
531    registry
532        .component_id(capability)
533        .ok_or_else(|| CapabilityError::UnknownCapability {
534            external: external_name.to_string(),
535            capability: capability.to_string(),
536        })
537}
538
539/// The row join (`docs/effects-spec.md` §9): compute every container's
540/// [`ContainerAccess`] from a story's decoded `EffectRows` table (T2-3/PR
541/// #878), joined against `manifest` and `registry`.
542///
543/// Errors on the first manifest-declared capability name the registry
544/// doesn't recognize (the tier-1 admission rule — a clear, load-time
545/// failure rather than a silently-incomplete access set).
546pub fn compute_container_access<M: Send + Sync + 'static>(
547    program: &Program,
548    effect_rows: &[EffectRowEntry],
549    manifest: &CapabilityManifest,
550    registry: &CapabilityRegistry<M>,
551) -> Result<ContainerAccessTable, CapabilityError> {
552    let mut out = BTreeMap::new();
553    for row in effect_rows {
554        let mut acc = JoinAccumulator::default();
555        join_direct(&row.direct, program, manifest, registry, &mut acc)?;
556        // §7: v1 has no narrowing logic on the host side yet, so every
557        // dispatch's conservative static fallback always folds in — never
558        // conditionally, regardless of its `narrowable` bit.
559        for dispatch in &row.dispatches {
560            join_direct(&dispatch.fallback, program, manifest, registry, &mut acc)?;
561        }
562        out.insert(row.def, acc.into_container_access());
563    }
564    Ok(out)
565}
566
567/// Fold one [`DirectEffects`] row's calls into `missing` — the
568/// [`missing_capabilities`] counterpart to [`join_direct`], collecting
569/// every unregistered name instead of erroring on the first one.
570fn collect_missing_from_direct<M: Send + Sync + 'static>(
571    direct: &DirectEffects,
572    program: &Program,
573    manifest: &CapabilityManifest,
574    registry: &CapabilityRegistry<M>,
575    missing: &mut BTreeSet<(String, String)>,
576) {
577    for call in &direct.calls {
578        let Some(external_name) = program.name_checked(call.name) else {
579            continue;
580        };
581        let Some(external) = manifest.external(external_name) else {
582            continue;
583        };
584        for name in external
585            .effects
586            .reads
587            .iter()
588            .chain(external.effects.writes.iter())
589        {
590            if registry.component_id(name).is_none() {
591                missing.insert((external_name.to_string(), name.clone()));
592            }
593        }
594    }
595}
596
597/// The load-boundary admission check (issue #912, RULED option (b)): every
598/// manifest-declared capability name — from externals this story's effect
599/// rows actually call, direct part and every dispatch's static fallback,
600/// the same walk [`compute_container_access`] does — that `registry`
601/// doesn't recognize. Empty means this story's capabilities all resolve
602/// under this marker's registry and the load may proceed.
603///
604/// Unlike [`compute_container_access`] (which errors on the first miss,
605/// suited to the call-time join), this collects every miss so a load
606/// rejection ([`CapabilityError::LoadRejected`]) can name the full gap in
607/// one shot instead of a fix/reload/fix cycle. Deduplicated and sorted
608/// (`BTreeSet`) — CLAUDE.md's determinism rule.
609#[must_use]
610pub fn missing_capabilities<M: Send + Sync + 'static>(
611    program: &Program,
612    effect_rows: &[EffectRowEntry],
613    manifest: &CapabilityManifest,
614    registry: &CapabilityRegistry<M>,
615) -> Vec<MissingCapability> {
616    let mut missing = BTreeSet::new();
617    for row in effect_rows {
618        collect_missing_from_direct(&row.direct, program, manifest, registry, &mut missing);
619        for dispatch in &row.dispatches {
620            collect_missing_from_direct(
621                &dispatch.fallback,
622                program,
623                manifest,
624                registry,
625                &mut missing,
626            );
627        }
628    }
629    missing
630        .into_iter()
631        .map(|(external, capability)| MissingCapability {
632            external,
633            capability,
634        })
635        .collect()
636}
637
638/// The shared load-boundary gate (issue #912's admission rule, extended by
639/// #997 to every load-shaped entry point): runs [`missing_capabilities`] and,
640/// if it finds any gap, builds the [`CapabilityError::LoadRejected`] error
641/// naming `marker`, `story`, and every missing capability. `Ok(())` means the
642/// story's capabilities all resolve under this marker's registry and the
643/// construction may proceed.
644///
645/// **Every** story-construction path that builds a `FlowInstance` from a
646/// `ProgramAsset` must call this before doing so — not just the initial
647/// `fulfill_flow_requests` load. PR #989 (closing #912) only wired this into
648/// that one path; #997 found the dev-only hot-reload reconstruction in
649/// `crate::replay::replay_on_reload` builds a fresh `FlowInstance` against
650/// the (possibly changed) reloaded program without re-running the check,
651/// letting a story that lost a manifest-required capability across a reload
652/// slip past the hard-error boundary. Both call sites now share this one
653/// function so a third load-shaped path can't repeat the gap.
654pub fn check_load_capability_gate<M: Send + Sync + 'static>(
655    program: &Program,
656    effect_rows: &[EffectRowEntry],
657    manifest: &CapabilityManifest,
658    registry: &CapabilityRegistry<M>,
659    story: String,
660) -> Result<(), CapabilityError> {
661    let missing = missing_capabilities(program, effect_rows, manifest, registry);
662    if missing.is_empty() {
663        Ok(())
664    } else {
665        Err(CapabilityError::LoadRejected {
666            marker: std::any::type_name::<M>(),
667            story,
668            missing,
669        })
670    }
671}
672
673// ── Load/unload boundary ──────────────────────────────────────────────────
674
675/// Per-story table of joined [`ContainerAccess`], keyed by the loaded
676/// [`ProgramAsset`]'s `AssetId` (a `bevy-brink` app may have several stories
677/// loaded — under one marker or several — at once). Rebuilt by
678/// [`rebuild_capability_table`] at the story load/unload boundary (§12.5's
679/// ruled invariant — "story load/unload is when the params rebuild").
680#[derive(Resource)]
681pub struct CapabilityTable<M: Send + Sync + 'static = ()> {
682    per_story: BTreeMap<AssetId<ProgramAsset>, Result<ContainerAccessTable, CapabilityError>>,
683    _marker: PhantomData<fn() -> M>,
684}
685
686impl<M: Send + Sync + 'static> Default for CapabilityTable<M> {
687    fn default() -> Self {
688        Self {
689            per_story: BTreeMap::new(),
690            _marker: PhantomData,
691        }
692    }
693}
694
695impl<M: Send + Sync + 'static> CapabilityTable<M> {
696    /// The join result for a loaded story, if any story with this asset id
697    /// has been processed yet. `Some(Err(_))` means the join failed (an
698    /// unregistered capability) — the story loaded, but has no usable access
699    /// table.
700    #[must_use]
701    pub fn get(
702        &self,
703        id: AssetId<ProgramAsset>,
704    ) -> Option<&Result<ContainerAccessTable, CapabilityError>> {
705        self.per_story.get(&id)
706    }
707
708    /// The joined access table for a loaded story, if the join succeeded.
709    #[must_use]
710    pub fn access_for(&self, id: AssetId<ProgramAsset>) -> Option<&ContainerAccessTable> {
711        self.per_story.get(&id)?.as_ref().ok()
712    }
713
714    /// Test-only constructor bypassing the load/unload boundary system
715    /// (`rebuild_capability_table`) — lets a unit test (`crate::ground_truth`'s,
716    /// the only caller) exercise [`CapabilityTable::access_for`] against a
717    /// hand-built join result without spinning up a full `App`/`ProgramAsset`
718    /// load cycle. Gated on `effect-trace` too (not just `test`) since that's
719    /// the only feature combination that compiles a caller.
720    #[cfg(all(test, feature = "effect-trace"))]
721    pub(crate) fn insert_for_test(
722        &mut self,
723        id: AssetId<ProgramAsset>,
724        result: Result<ContainerAccessTable, CapabilityError>,
725    ) {
726        self.per_story.insert(id, result);
727    }
728}
729
730/// Plugin-managed system: rebuild a loaded story's [`ContainerAccess`] table
731/// whenever its [`ProgramAsset`] (re)loads, and drop it when the asset
732/// unloads — the load/unload boundary §12.5 rules access sets rebuild at.
733///
734/// A failed join (an unregistered capability name) is logged loudly and
735/// recorded as `Err` in the table rather than left stale or silently
736/// dropped — the caller can inspect [`CapabilityTable::get`] directly
737/// instead of scraping logs (this is what BH-1's headless tests do).
738#[expect(
739    clippy::needless_pass_by_value,
740    reason = "bevy systems take Res/ResMut/MessageReader by value"
741)]
742pub fn rebuild_capability_table<M: Send + Sync + 'static>(
743    mut events: MessageReader<AssetEvent<ProgramAsset>>,
744    programs: Res<Assets<ProgramAsset>>,
745    manifest: Res<CapabilityManifest>,
746    registry: Res<CapabilityRegistry<M>>,
747    mut table: ResMut<CapabilityTable<M>>,
748) {
749    for event in events.read() {
750        match event {
751            AssetEvent::Added { id }
752            | AssetEvent::Modified { id }
753            | AssetEvent::LoadedWithDependencies { id } => {
754                let Some(asset) = programs.get(*id) else {
755                    continue;
756                };
757                let result = compute_container_access(
758                    &asset.program,
759                    &asset.effect_rows,
760                    &manifest,
761                    &registry,
762                );
763                if let Err(err) = &result {
764                    error!("brink capability join failed for a loaded story: {err}");
765                }
766                table.per_story.insert(*id, result);
767            }
768            AssetEvent::Removed { id } | AssetEvent::Unused { id } => {
769                table.per_story.remove(id);
770            }
771        }
772    }
773}
774
775/// Render a human-readable `container -> access set` table (BH-B's scenario
776/// harness + interactive debugging, per this issue's "dev-visible dump"
777/// deliverable). Deterministic: the input is keyed by `DefinitionId`
778/// (`BTreeMap` order) and each container's name lists are pre-sorted.
779#[must_use]
780pub fn dump_container_access(program: &Program, table: &ContainerAccessTable) -> String {
781    use std::fmt::Write as _;
782
783    let mut out = String::new();
784    for (def, access) in table {
785        let label = program
786            .divert_target_path(*def)
787            .unwrap_or_else(|| format!("<{def}>"));
788        let opaque_tag = if access.opaque {
789            " OPAQUE(read_all+write_all)"
790        } else {
791            ""
792        };
793        let _ = writeln!(
794            out,
795            "{label}: reads=[{}] writes=[{}]{opaque_tag}",
796            access.reads.join(", "),
797            access.writes.join(", "),
798        );
799        for (name, detect_bit) in &access.detect {
800            let _ = writeln!(out, "    detect[{name}] = {detect_bit}");
801        }
802    }
803    out
804}
805
806#[cfg(test)]
807mod tests {
808    use bevy_app::App;
809    use bevy_ecs::component::Component;
810    use brink_format::{
811        CallAtom, CapabilityParam, DefinitionId, DefinitionTag, DirectEffects, DispatchEntry,
812        EffectRowEntry,
813    };
814
815    use super::*;
816    use crate::test_support::compile_test_story;
817
818    #[derive(Component)]
819    struct Transform;
820
821    #[derive(Component)]
822    struct AudioSink;
823
824    fn atom(name: brink_format::NameId) -> CallAtom {
825        CallAtom {
826            name,
827            capability: CapabilityParam::Any,
828            handle_param: None,
829        }
830    }
831
832    #[test]
833    fn manifest_round_trips_the_13_2_grammar() {
834        let json = r#"
835        {
836            "externals": [
837                {
838                    "name": "get_position",
839                    "params": [{"name": "npc", "ty": "Handle<Npc>"}],
840                    "effects": {
841                        "reads": ["Transform"],
842                        "detect": {"Transform": true}
843                    }
844                }
845            ]
846        }
847        "#;
848        let manifest = CapabilityManifest::from_json(json).expect("valid manifest json");
849        assert_eq!(manifest.externals.len(), 1);
850        let ext = &manifest.externals[0];
851        assert_eq!(ext.name, "get_position");
852        assert_eq!(ext.effects.reads, vec!["Transform".to_string()]);
853        assert!(ext.effects.writes.is_empty());
854        assert_eq!(ext.effects.detect.get("Transform"), Some(&true));
855
856        let serialized = serde_json::to_string(&manifest).expect("serialize back to json");
857        let round_tripped =
858            CapabilityManifest::from_json(&serialized).expect("re-parse the serialized manifest");
859        assert_eq!(manifest, round_tripped);
860    }
861
862    #[test]
863    fn manifest_json_ignores_unknown_fields() {
864        // The same manifest file also carries `params`/`kind`/`doc`/`widgets`/
865        // `path` for the compiler/IDE side (brink_ir::host_manifest) — this
866        // parse must not choke on any of it.
867        let json = r#"
868        {
869            "externals": [
870                {"name": "play_sfx", "kind": "effect", "doc": "plays a sound", "path": ["Audio"]}
871            ]
872        }
873        "#;
874        let manifest = CapabilityManifest::from_json(json).expect("unknown fields are ignored");
875        assert_eq!(manifest.externals[0].name, "play_sfx");
876        assert_eq!(manifest.externals[0].effects, CapabilityEffects::default());
877    }
878
879    #[test]
880    fn malformed_manifest_json_is_an_error() {
881        let err = CapabilityManifest::from_json("not json").unwrap_err();
882        assert!(matches!(err, CapabilityError::ManifestJson(_)));
883    }
884
885    #[test]
886    fn register_capability_indexes_component_id_by_name() {
887        let mut app = App::new();
888        app.register_capability::<(), Transform>("Transform");
889        app.register_capability::<(), AudioSink>("AudioSink");
890
891        let registry = app.world().resource::<CapabilityRegistry<()>>();
892        assert!(registry.component_id("Transform").is_some());
893        assert!(registry.component_id("AudioSink").is_some());
894        assert_eq!(
895            registry.component_id("Transform"),
896            registry.component_id("Transform")
897        );
898        assert_ne!(
899            registry.component_id("Transform"),
900            registry.component_id("AudioSink")
901        );
902        assert_eq!(
903            registry.names().collect::<Vec<_>>(),
904            vec!["AudioSink", "Transform"]
905        );
906    }
907
908    #[test]
909    fn unknown_capability_name_is_a_load_time_error() {
910        let mut app = App::new();
911        app.register_capability::<(), Transform>("Transform");
912        let registry = app.world().resource::<CapabilityRegistry<()>>();
913
914        let mut manifest = CapabilityManifest::default();
915        manifest.externals.push(CapabilityManifestExternal {
916            name: "get_position".to_string(),
917            effects: CapabilityEffects {
918                reads: vec!["Nonexistent".to_string()],
919                writes: vec![],
920                detect: BTreeMap::new(),
921            },
922        });
923
924        let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
925        let (program, _tables, _ctx) = compile_test_story(source);
926        let name_id = program
927            .name_id("get_position")
928            .expect("interned as a call kind");
929
930        let row = EffectRowEntry {
931            def: DefinitionId::new(DefinitionTag::Address, 0),
932            is_entry: true,
933            direct: DirectEffects {
934                reads: vec![],
935                writes: vec![],
936                calls: vec![atom(name_id)],
937                opaque: false,
938                emits: false,
939                tags: false,
940                faults: false,
941            },
942            dispatches: vec![],
943        };
944
945        let err = compute_container_access(&program, &[row], &manifest, registry).unwrap_err();
946        assert!(matches!(
947            &err,
948            CapabilityError::UnknownCapability { external, capability }
949                if external == "get_position" && capability == "Nonexistent"
950        ));
951    }
952
953    #[test]
954    fn known_capability_joins_into_component_access_and_names() {
955        let mut app = App::new();
956        app.register_capability::<(), Transform>("Transform");
957        app.register_capability::<(), AudioSink>("AudioSink");
958        let registry = app.world().resource::<CapabilityRegistry<()>>();
959        let transform_id = registry.component_id("Transform").expect("registered");
960        let audio_id = registry.component_id("AudioSink").expect("registered");
961
962        let mut manifest = CapabilityManifest::default();
963        manifest.externals.push(CapabilityManifestExternal {
964            name: "get_position".to_string(),
965            effects: CapabilityEffects {
966                reads: vec!["Transform".to_string()],
967                writes: vec![],
968                detect: [("Transform".to_string(), true)].into_iter().collect(),
969            },
970        });
971        manifest.externals.push(CapabilityManifestExternal {
972            name: "play_sfx".to_string(),
973            effects: CapabilityEffects {
974                reads: vec![],
975                writes: vec!["AudioSink".to_string()],
976                detect: BTreeMap::new(),
977            },
978        });
979
980        let source = "EXTERNAL get_position(id)\nEXTERNAL play_sfx(id)\n=== start ===\n~ temp x = get_position(0)\n~ play_sfx(0)\nHello.\n-> END\n";
981        let (program, _tables, _ctx) = compile_test_story(source);
982        let get_position = program.name_id("get_position").expect("interned");
983        let play_sfx = program.name_id("play_sfx").expect("interned");
984
985        let row = EffectRowEntry {
986            def: DefinitionId::new(DefinitionTag::Address, 0),
987            is_entry: true,
988            direct: DirectEffects {
989                reads: vec![],
990                writes: vec![],
991                calls: vec![atom(get_position), atom(play_sfx)],
992                opaque: false,
993                emits: false,
994                tags: false,
995                faults: false,
996            },
997            dispatches: vec![],
998        };
999
1000        let table =
1001            compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1002                .expect("join succeeds");
1003        let access = table.get(&row.def).expect("row's container present");
1004        assert!(access.access.has_read(transform_id));
1005        assert!(!access.access.has_write(transform_id));
1006        assert!(access.access.has_write(audio_id));
1007        assert_eq!(access.reads, vec!["Transform".to_string()]);
1008        assert_eq!(access.writes, vec!["AudioSink".to_string()]);
1009        assert_eq!(access.detect.get("Transform"), Some(&true));
1010        assert!(!access.opaque);
1011    }
1012
1013    /// #913 (ruled 2026-07-18): when one container calls two externals that
1014    /// touch the **same** capability with **conflicting** `detect` bits, the
1015    /// folded bit is the AND (conservative `false` / must-poll) — never the
1016    /// accidental last-write-wins that `BTreeMap::insert` used to give. Order
1017    /// must not matter: `true`-then-`false` and `false`-then-`true` both fold
1018    /// to `false`.
1019    #[test]
1020    fn conflicting_detect_bits_fold_conservative_and_not_last_write_wins() {
1021        let mut app = App::new();
1022        app.register_capability::<(), Transform>("Transform");
1023        let registry = app.world().resource::<CapabilityRegistry<()>>();
1024
1025        // Two externals, both reading `Transform`, one detect-capable (true),
1026        // one opaque (false). A container that calls both must classify
1027        // `Transform` as must-poll (false) regardless of manifest order.
1028        let mut manifest = CapabilityManifest::default();
1029        manifest.externals.push(CapabilityManifestExternal {
1030            name: "watch_pos".to_string(),
1031            effects: CapabilityEffects {
1032                reads: vec!["Transform".to_string()],
1033                writes: vec![],
1034                detect: [("Transform".to_string(), true)].into_iter().collect(),
1035            },
1036        });
1037        manifest.externals.push(CapabilityManifestExternal {
1038            name: "poke_pos".to_string(),
1039            effects: CapabilityEffects {
1040                reads: vec!["Transform".to_string()],
1041                writes: vec![],
1042                detect: [("Transform".to_string(), false)].into_iter().collect(),
1043            },
1044        });
1045
1046        let source = "EXTERNAL watch_pos(id)\nEXTERNAL poke_pos(id)\n=== start ===\n~ temp x = watch_pos(0)\n~ temp y = poke_pos(0)\nHi.\n-> END\n";
1047        let (program, _tables, _ctx) = compile_test_story(source);
1048        let watch = program.name_id("watch_pos").expect("interned");
1049        let poke = program.name_id("poke_pos").expect("interned");
1050
1051        // Order A: watch (true) then poke (false).
1052        let row_a = EffectRowEntry {
1053            def: DefinitionId::new(DefinitionTag::Address, 0),
1054            is_entry: true,
1055            direct: DirectEffects {
1056                reads: vec![],
1057                writes: vec![],
1058                calls: vec![atom(watch), atom(poke)],
1059                opaque: false,
1060                emits: false,
1061                tags: false,
1062                faults: false,
1063            },
1064            dispatches: vec![],
1065        };
1066        let table_a =
1067            compute_container_access(&program, std::slice::from_ref(&row_a), &manifest, registry)
1068                .expect("join succeeds");
1069        assert_eq!(
1070            table_a[&row_a.def].detect.get("Transform"),
1071            Some(&false),
1072            "true-then-false must AND to false (must-poll), not last-write-wins to false-by-luck"
1073        );
1074
1075        // Order B: poke (false) then watch (true) — last write is `true`; a
1076        // last-write-wins fold would wrongly yield `true`. AND still gives false.
1077        let row_b = EffectRowEntry {
1078            def: DefinitionId::new(DefinitionTag::Address, 0),
1079            is_entry: true,
1080            direct: DirectEffects {
1081                reads: vec![],
1082                writes: vec![],
1083                calls: vec![atom(poke), atom(watch)],
1084                opaque: false,
1085                emits: false,
1086                tags: false,
1087                faults: false,
1088            },
1089            dispatches: vec![],
1090        };
1091        let table_b =
1092            compute_container_access(&program, std::slice::from_ref(&row_b), &manifest, registry)
1093                .expect("join succeeds");
1094        assert_eq!(
1095            table_b[&row_b.def].detect.get("Transform"),
1096            Some(&false),
1097            "false-then-true must AND to false — the regression #913 fixes: \
1098             last-write-wins would have left it `true` and risked a missed wake"
1099        );
1100    }
1101
1102    /// Two detect-capable reads of the same capability keep the bit `true`
1103    /// (AND of `true`s) — the merge is conservative, not blindly pessimistic.
1104    #[test]
1105    fn all_detect_capable_reads_keep_the_bit_true() {
1106        let mut app = App::new();
1107        app.register_capability::<(), Transform>("Transform");
1108        let registry = app.world().resource::<CapabilityRegistry<()>>();
1109
1110        let mut manifest = CapabilityManifest::default();
1111        for ext in ["watch_a", "watch_b"] {
1112            manifest.externals.push(CapabilityManifestExternal {
1113                name: ext.to_string(),
1114                effects: CapabilityEffects {
1115                    reads: vec!["Transform".to_string()],
1116                    writes: vec![],
1117                    detect: [("Transform".to_string(), true)].into_iter().collect(),
1118                },
1119            });
1120        }
1121        let source = "EXTERNAL watch_a(id)\nEXTERNAL watch_b(id)\n=== start ===\n~ temp x = watch_a(0)\n~ temp y = watch_b(0)\nHi.\n-> END\n";
1122        let (program, _tables, _ctx) = compile_test_story(source);
1123        let a = program.name_id("watch_a").expect("interned");
1124        let b = program.name_id("watch_b").expect("interned");
1125        let row = EffectRowEntry {
1126            def: DefinitionId::new(DefinitionTag::Address, 0),
1127            is_entry: true,
1128            direct: DirectEffects {
1129                reads: vec![],
1130                writes: vec![],
1131                calls: vec![atom(a), atom(b)],
1132                opaque: false,
1133                emits: false,
1134                tags: false,
1135                faults: false,
1136            },
1137            dispatches: vec![],
1138        };
1139        let table =
1140            compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1141                .expect("join succeeds");
1142        assert_eq!(table[&row.def].detect.get("Transform"), Some(&true));
1143    }
1144
1145    #[test]
1146    fn opaque_row_reads_and_writes_everything() {
1147        let registry = CapabilityRegistry::<()>::default();
1148        let manifest = CapabilityManifest::default();
1149        let program_source = "=== start ===\nHello.\n-> END\n";
1150        let (program, _tables, _ctx) = compile_test_story(program_source);
1151
1152        let row = EffectRowEntry {
1153            def: DefinitionId::new(DefinitionTag::Address, 0),
1154            is_entry: true,
1155            direct: DirectEffects {
1156                reads: vec![],
1157                writes: vec![],
1158                calls: vec![],
1159                opaque: true,
1160                emits: false,
1161                tags: false,
1162                faults: false,
1163            },
1164            dispatches: vec![],
1165        };
1166
1167        let table =
1168            compute_container_access(&program, std::slice::from_ref(&row), &manifest, &registry)
1169                .expect("join succeeds");
1170        let access = table.get(&row.def).expect("row's container present");
1171        assert!(access.opaque);
1172        assert!(access.access.has_read_all());
1173        assert!(access.access.has_write_all());
1174    }
1175
1176    #[test]
1177    fn dispatch_fallback_rows_always_fold_in() {
1178        // §7: v1 has no narrowing, so a populated dispatch's fallback must
1179        // join exactly like the direct part — even though it's marked
1180        // `narrowable`, since no host-side narrowing exists yet to act on
1181        // that bit. Omitting it would under-report access.
1182        let mut app = App::new();
1183        app.register_capability::<(), Transform>("Transform");
1184        let registry = app.world().resource::<CapabilityRegistry<()>>();
1185        let transform_id = registry.component_id("Transform").expect("registered");
1186
1187        let mut manifest = CapabilityManifest::default();
1188        manifest.externals.push(CapabilityManifestExternal {
1189            name: "get_position".to_string(),
1190            effects: CapabilityEffects {
1191                reads: vec!["Transform".to_string()],
1192                writes: vec![],
1193                detect: BTreeMap::new(),
1194            },
1195        });
1196
1197        let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1198        let (program, _tables, _ctx) = compile_test_story(source);
1199        let get_position = program.name_id("get_position").expect("interned");
1200
1201        let row = EffectRowEntry {
1202            def: DefinitionId::new(DefinitionTag::Address, 0),
1203            is_entry: true,
1204            direct: DirectEffects::default(),
1205            dispatches: vec![DispatchEntry {
1206                cell: DefinitionId::new(DefinitionTag::Address, 1),
1207                narrowable: true,
1208                fallback: DirectEffects {
1209                    reads: vec![],
1210                    writes: vec![],
1211                    calls: vec![atom(get_position)],
1212                    opaque: false,
1213                    emits: false,
1214                    tags: false,
1215                    faults: false,
1216                },
1217            }],
1218        };
1219
1220        let table =
1221            compute_container_access(&program, std::slice::from_ref(&row), &manifest, registry)
1222                .expect("join succeeds");
1223        let access = table.get(&row.def).expect("row's container present");
1224        assert!(access.access.has_read(transform_id));
1225        assert_eq!(access.reads, vec!["Transform".to_string()]);
1226    }
1227
1228    #[test]
1229    fn dump_renders_names_and_detect_bits_deterministically() {
1230        let mut table: ContainerAccessTable = BTreeMap::new();
1231        let access = ContainerAccess {
1232            reads: vec!["Transform".to_string()],
1233            writes: vec!["AudioSink".to_string()],
1234            detect: [("Transform".to_string(), true)].into_iter().collect(),
1235            ..ContainerAccess::default()
1236        };
1237        table.insert(DefinitionId::new(DefinitionTag::Address, 0), access);
1238
1239        let source = "=== start ===\nHello.\n-> END\n";
1240        let (program, _tables, _ctx) = compile_test_story(source);
1241        let rendered = dump_container_access(&program, &table);
1242        assert!(rendered.contains("reads=[Transform]"));
1243        assert!(rendered.contains("writes=[AudioSink]"));
1244        assert!(rendered.contains("detect[Transform] = true"));
1245    }
1246
1247    /// Reachability proof (this issue's own gate): a real app that only
1248    /// calls the public surface — `add_plugins(BrinkPlugin::default())`,
1249    /// `register_capability`, `insert_resource(CapabilityManifest)` — gets
1250    /// its loaded story's `CapabilityTable` populated automatically, with no
1251    /// manual call to `compute_container_access` anywhere. This is the exact
1252    /// path a host app takes: the join runs off `BrinkPlugin`'s own
1253    /// `rebuild_capability_table` system reacting to the `ProgramAsset`'s
1254    /// `AssetEvent::Added`, not a test-only hook.
1255    #[test]
1256    fn wired_via_brink_plugin_rebuilds_capability_table_on_story_load() {
1257        let mut app = crate::test_support::make_test_app();
1258        app.register_capability::<(), Transform>("Transform");
1259
1260        let mut manifest = CapabilityManifest::default();
1261        manifest.externals.push(CapabilityManifestExternal {
1262            name: "get_position".to_string(),
1263            effects: CapabilityEffects {
1264                reads: vec!["Transform".to_string()],
1265                writes: vec![],
1266                detect: BTreeMap::new(),
1267            },
1268        });
1269        app.insert_resource(manifest);
1270
1271        let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1272        let out = brink_compiler::compile("t.ink", move |p| {
1273            if p == "t.ink" {
1274                Ok(source.to_string())
1275            } else {
1276                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1277            }
1278        })
1279        .expect("compile");
1280        let mut inkb = Vec::new();
1281        brink_format::write_inkb(&out.data, &mut inkb);
1282        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
1283        let (program, _tables) = brink_runtime::link(&loaded).expect("link");
1284        let (_, initial_context) = brink_runtime::FlowInstance::new_at_root(&program);
1285
1286        // Kept alive for the whole test (a dropped strong Handle queues its
1287        // own AssetEvent::Removed via `Assets::track_assets`, which would
1288        // race the Added event this test actually wants to observe).
1289        let program_handle =
1290            app.world_mut()
1291                .resource_mut::<Assets<ProgramAsset>>()
1292                .add(ProgramAsset {
1293                    program,
1294                    initial_context,
1295                    effect_rows: loaded.effect_rows,
1296                });
1297        let program_id = program_handle.id();
1298
1299        // BrinkPlugin<()>'s rebuild_capability_table system reacts to the
1300        // AssetEvent::Added the `add` above queued. Bevy's asset events flush
1301        // `queued_events` into the readable `Messages<AssetEvent<_>>` buffer
1302        // in `PostUpdate` — after our system's own `Update` stage — so the
1303        // event isn't visible to a `MessageReader` until the *following*
1304        // tick; two updates are the correct wait, not a workaround.
1305        app.update();
1306        app.update();
1307
1308        let table = app.world().resource::<CapabilityTable<()>>();
1309        let access_table = table
1310            .access_for(program_id)
1311            .expect("capability join ran for the loaded story off the plugin's own system");
1312        // Harden past non-emptiness (issue #911, BH follow-up deliverable
1313        // 2): this story has exactly one container (`start`), which calls
1314        // `get_position` — assert the *actual* joined set the plugin's own
1315        // system produced (reads == [Transform], no writes, not opaque),
1316        // not merely that the table has *some* entry in it.
1317        assert_eq!(
1318            access_table.len(),
1319            1,
1320            "expected exactly one container row (the story's single `start` knot): {access_table:?}"
1321        );
1322        let access = access_table
1323            .values()
1324            .next()
1325            .expect("checked len() == 1 above");
1326        let transform_id = app
1327            .world()
1328            .resource::<CapabilityRegistry<()>>()
1329            .component_id("Transform")
1330            .expect("Transform was registered above");
1331        assert_eq!(
1332            access.reads,
1333            vec!["Transform".to_string()],
1334            "joined reads should be exactly what get_position's manifest entry declares"
1335        );
1336        assert!(
1337            access.writes.is_empty(),
1338            "get_position's manifest entry declares no writes"
1339        );
1340        assert!(
1341            access.access.has_read(transform_id),
1342            "the joined bevy Access should carry a read on Transform's ComponentId"
1343        );
1344        assert!(!access.access.has_write(transform_id));
1345        assert!(
1346            !access.opaque,
1347            "no call in this story hits the opaque fallback"
1348        );
1349        drop(program_handle);
1350    }
1351
1352    /// The unload half of the load/unload boundary invariant (§12.5): once
1353    /// the `ProgramAsset` is dropped from `Assets`, the next tick's
1354    /// `AssetEvent::Removed` clears the story's entry out of the table.
1355    #[test]
1356    fn unloading_a_story_drops_its_capability_table_entry() {
1357        let mut app = crate::test_support::make_test_app();
1358        app.insert_resource(CapabilityManifest::default());
1359
1360        let source = "=== start ===\nHello.\n-> END\n";
1361        let (program, _tables, initial_context) = compile_test_story(source);
1362        // Kept alive until the deliberate `remove` below — see the note on
1363        // the sibling reachability test about handle-drop races.
1364        let program_handle =
1365            app.world_mut()
1366                .resource_mut::<Assets<ProgramAsset>>()
1367                .add(ProgramAsset {
1368                    program,
1369                    initial_context,
1370                    effect_rows: vec![],
1371                });
1372        let program_id = program_handle.id();
1373        app.update();
1374        app.update(); // see the sibling test's note: events flush one tick late
1375        assert!(
1376            app.world()
1377                .resource::<CapabilityTable<()>>()
1378                .get(program_id)
1379                .is_some()
1380        );
1381
1382        app.world_mut()
1383            .resource_mut::<Assets<ProgramAsset>>()
1384            .remove(program_id);
1385        app.update();
1386        app.update();
1387        assert!(
1388            app.world()
1389                .resource::<CapabilityTable<()>>()
1390                .get(program_id)
1391                .is_none()
1392        );
1393        drop(program_handle);
1394    }
1395
1396    // ── Issue #912: load-boundary admission check ──────────────────────
1397
1398    /// [`missing_capabilities`] must collect **every** manifest-declared
1399    /// capability the registry doesn't recognize — not just the first,
1400    /// unlike [`compute_container_access`]'s call-time short-circuit —
1401    /// since the whole point of a load-boundary error is to show the host
1402    /// the full gap in one shot.
1403    #[test]
1404    fn missing_capabilities_collects_every_gap_not_just_the_first() {
1405        let registry = CapabilityRegistry::<()>::default(); // nothing registered at all
1406
1407        let mut manifest = CapabilityManifest::default();
1408        manifest.externals.push(CapabilityManifestExternal {
1409            name: "get_position".to_string(),
1410            effects: CapabilityEffects {
1411                reads: vec!["Transform".to_string()],
1412                writes: vec![],
1413                detect: BTreeMap::new(),
1414            },
1415        });
1416        manifest.externals.push(CapabilityManifestExternal {
1417            name: "play_sfx".to_string(),
1418            effects: CapabilityEffects {
1419                reads: vec![],
1420                writes: vec!["AudioSink".to_string()],
1421                detect: BTreeMap::new(),
1422            },
1423        });
1424
1425        let source = "EXTERNAL get_position(id)\nEXTERNAL play_sfx(id)\n\
1426                       === start ===\n~ temp x = get_position(0)\n~ play_sfx(0)\nHello.\n-> END\n";
1427        let (program, _tables, _ctx) = compile_test_story(source);
1428        let get_position = program.name_id("get_position").expect("interned");
1429        let play_sfx = program.name_id("play_sfx").expect("interned");
1430
1431        let row = EffectRowEntry {
1432            def: DefinitionId::new(DefinitionTag::Address, 0),
1433            is_entry: true,
1434            direct: DirectEffects {
1435                reads: vec![],
1436                writes: vec![],
1437                calls: vec![atom(get_position), atom(play_sfx)],
1438                opaque: false,
1439                emits: false,
1440                tags: false,
1441                faults: false,
1442            },
1443            dispatches: vec![],
1444        };
1445
1446        let missing = missing_capabilities(&program, &[row], &manifest, &registry);
1447        assert_eq!(
1448            missing.len(),
1449            2,
1450            "both externals' missing capabilities should be reported: {missing:?}"
1451        );
1452        assert!(missing.contains(&MissingCapability {
1453            external: "get_position".to_string(),
1454            capability: "Transform".to_string(),
1455        }));
1456        assert!(missing.contains(&MissingCapability {
1457            external: "play_sfx".to_string(),
1458            capability: "AudioSink".to_string(),
1459        }));
1460    }
1461
1462    /// A capability the registry *does* recognize contributes nothing to
1463    /// `missing_capabilities` — the happy path a single-satisfied marker
1464    /// takes at load time.
1465    #[test]
1466    fn missing_capabilities_is_empty_when_registry_covers_every_declared_name() {
1467        let mut app = App::new();
1468        app.register_capability::<(), Transform>("Transform");
1469        let registry = app.world().resource::<CapabilityRegistry<()>>();
1470
1471        let mut manifest = CapabilityManifest::default();
1472        manifest.externals.push(CapabilityManifestExternal {
1473            name: "get_position".to_string(),
1474            effects: CapabilityEffects {
1475                reads: vec!["Transform".to_string()],
1476                writes: vec![],
1477                detect: BTreeMap::new(),
1478            },
1479        });
1480
1481        let source = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nHello.\n-> END\n";
1482        let (program, _tables, _ctx) = compile_test_story(source);
1483        let get_position = program.name_id("get_position").expect("interned");
1484
1485        let row = EffectRowEntry {
1486            def: DefinitionId::new(DefinitionTag::Address, 0),
1487            is_entry: true,
1488            direct: DirectEffects {
1489                reads: vec![],
1490                writes: vec![],
1491                calls: vec![atom(get_position)],
1492                opaque: false,
1493                emits: false,
1494                tags: false,
1495                faults: false,
1496            },
1497            dispatches: vec![],
1498        };
1499
1500        let missing = missing_capabilities(&program, &[row], &manifest, registry);
1501        assert!(missing.is_empty(), "got {missing:?}");
1502    }
1503
1504    /// The load-rejection error (this issue's user-facing surface) must
1505    /// name the marker, the story, and every missing capability — not a
1506    /// generic "something's missing" message the host has to go dig for.
1507    #[test]
1508    fn load_rejected_error_names_marker_story_and_every_missing_capability() {
1509        let err = CapabilityError::LoadRejected {
1510            marker: "my_game::DreamSequence",
1511            story: "dialogue.ink".to_string(),
1512            missing: vec![
1513                MissingCapability {
1514                    external: "get_position".to_string(),
1515                    capability: "Transform".to_string(),
1516                },
1517                MissingCapability {
1518                    external: "play_sfx".to_string(),
1519                    capability: "AudioSink".to_string(),
1520                },
1521            ],
1522        };
1523        let message = err.to_string();
1524        assert!(
1525            message.contains("my_game::DreamSequence"),
1526            "should name the marker: {message}"
1527        );
1528        assert!(
1529            message.contains("dialogue.ink"),
1530            "should name the story: {message}"
1531        );
1532        assert!(
1533            message.contains("Transform") && message.contains("get_position"),
1534            "should name the first missing capability and its external: {message}"
1535        );
1536        assert!(
1537            message.contains("AudioSink") && message.contains("play_sfx"),
1538            "should name the second missing capability and its external too: {message}"
1539        );
1540    }
1541}