Skip to main content

graphrefly_core/
boundary.rs

1//! The FFI surface — the only path from Core to user code.
2//!
3//! Mirrors `BindingBoundary` in
4//! `~/src/graphrefly-ts/src/__experiments__/handle-core/core.ts:122–126`.
5//!
6//! # Boundary discipline (handle-protocol cleaving plane)
7//!
8//! The Core never sees user values `T`. When the dispatcher needs to invoke
9//! user code, run a custom equals oracle, or release a value-handle's
10//! refcount, it calls into [`BindingBoundary`]. The binding-side implementation
11//! resolves handles to values, runs the user code, and returns either a new
12//! handle or a no-op signal.
13//!
14//! In a Rust core compiled to a napi-rs / pyo3 / wasm-bindgen cdylib, the
15//! `impl BindingBoundary` lives in the bindings crate; it owns the
16//! value registry (`HashMap<HandleId, T>` plus a value→handle dedup map).
17//!
18//! Per the rust-port session doc Part 2: this trait is the *only* mandatory
19//! FFI crossing per fn-fire. Internal protocol bookkeeping (DIRTY propagation,
20//! batch coalescing, equals-substitution under identity, version counters,
21//! PAUSE/RESUME, INVALIDATE, first-run gate) stays Core-internal — zero FFI.
22
23use smallvec::SmallVec;
24
25use crate::handle::{FnId, HandleId, NodeId, NO_HANDLE};
26
27/// Per-dep batch data passed to [`BindingBoundary::invoke_fn`].
28///
29/// Mirrors the canonical spec R2.9.b `DepRecord` shape at the FFI boundary.
30/// Each entry represents one dep's state for the current wave:
31///
32/// - `data` — DATA handles accumulated this wave (R1.3.6.b coalescing).
33///   Empty means the dep settled RESOLVED or was not involved.
34/// - `prev_data` — last DATA handle from the end of the previous wave.
35///   [`NO_HANDLE`] if the dep has never emitted DATA.
36/// - `involved` — `true` iff the dep was dirtied-then-settled this wave.
37///   Distinguishes "RESOLVED in wave" (`involved && data.is_empty()`) from
38///   "not involved" (`!involved && data.is_empty()`).
39#[derive(Clone, Debug)]
40pub struct DepBatch {
41    /// DATA handles accumulated this wave. Outside `batch()` scope, at most
42    /// 1 element. Inside `batch()`, K consecutive emits on the same source
43    /// produce K entries per R1.3.6.b coalescing.
44    pub data: SmallVec<[HandleId; 1]>,
45    /// Last DATA handle from the end of the previous wave. [`NO_HANDLE`]
46    /// means the dep has never emitted DATA.
47    pub prev_data: HandleId,
48    /// Whether this dep was involved (dirtied → settled) in the current wave.
49    pub involved: bool,
50}
51
52impl DepBatch {
53    /// The "latest" handle for this dep — the last DATA in the current wave's
54    /// batch, falling back to `prev_data` if no DATA arrived this wave.
55    /// Returns [`NO_HANDLE`] only when the dep has never emitted.
56    #[must_use]
57    pub fn latest(&self) -> HandleId {
58        self.data.last().copied().unwrap_or(self.prev_data)
59    }
60
61    /// Convenience: is this dep in sentinel state (never emitted DATA)?
62    #[must_use]
63    pub fn is_sentinel(&self) -> bool {
64        self.prev_data == NO_HANDLE && self.data.is_empty()
65    }
66}
67
68/// Lifecycle trigger discriminator for [`BindingBoundary::cleanup_for`].
69///
70/// Slice E2 (R2.4.5 / Lock 4.A / Lock 4.A′) — the named-hook cleanup spec
71/// returned by user fns has three independent slots; Core fires each slot
72/// at its own lifecycle moment via `cleanup_for(node_id, CleanupTrigger)`.
73///
74/// All three triggers fire **lock-released** per Slice E D045 handshake
75/// discipline. The binding is responsible for resolving the trigger to its
76/// stored cleanup closure (typically a `node_id → NodeFnCleanup` map) and
77/// invoking the matching slot.
78#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
79pub enum CleanupTrigger {
80    /// **R2.4.5 `onRerun`** — fires before the next fn run within the same
81    /// activation cycle. Core fires this in `fire_regular` between the
82    /// lock-held dep-snapshot phase and the lock-released `invoke_fn` call,
83    /// gated on `has_fired_once == true` (so first-fire never sees an
84    /// `onRerun`). The user closure receives no arguments and is expected
85    /// to release fn-local resources from the previous run before the
86    /// fresh `invoke_fn` allocates new ones.
87    OnRerun,
88    /// **R2.4.5 `onDeactivation`** — fires when subscriber count drops to
89    /// zero. Core fires this from `Subscription::Drop` (alongside the
90    /// existing [`BindingBoundary::producer_deactivate`] call), gated on
91    /// `has_fired_once == true`. Order: cleanup-first (`OnDeactivation`)
92    /// then `producer_deactivate`, because cleanup may release handles the
93    /// producer subscription owns (D056). Per D059, bindings SHOULD clear
94    /// `current_cleanup` on this trigger — the next subscribe + first-fire
95    /// will re-register a fresh closure.
96    OnDeactivation,
97    /// **R2.4.5 `onInvalidate`** — fires when an `[[INVALIDATE]]` arrives at
98    /// the node and clears its cache. Per **R1.3.9.b** strict reading
99    /// (D057): fires **at most once per wave per node**, regardless of
100    /// fan-in shape. Per **R1.3.9.c**: never fires when the node's cache
101    /// is the never-populated sentinel (a node that has not yet emitted
102    /// has nothing to clean up). Per **D058**: fires at cache-clear time,
103    /// not at wire-delivery time — pause buffering doesn't defer the
104    /// hook. **Per D061**: when the wave is panic-discarded mid-flight,
105    /// queued OnInvalidate hooks are dropped silently (the cleanup never
106    /// fires for the panicked wave); see [`BindingBoundary::cleanup_for`]
107    /// rustdoc for the panic-discard guarantee gap and the bindings'
108    /// idempotent-cleanup recommendation.
109    OnInvalidate,
110}
111
112/// A single emission within a [`FnResult::Batch`] — one element of an
113/// `actions.down(msgs)` call. Processed in sequence within the same wave.
114#[derive(Clone, Debug)]
115#[must_use = "FnEmission may contain handles that must be processed or released"]
116pub enum FnEmission {
117    /// DATA payload. Processed via `commit_emission` — sets cache, queues
118    /// Dirty (if not already dirty per R1.3.1.a) + Data, propagates to
119    /// children. No equals substitution in Batch context (R1.3.2.d /
120    /// R1.3.3.c: multi-message waves pass through verbatim).
121    Data(HandleId),
122    /// COMPLETE terminal. Cascades per R1.3.4 / Lock 2.B.
123    Complete,
124    /// ERROR terminal with error-value handle. Cascades per R1.3.4;
125    /// ERROR dominates COMPLETE (Lock 2.B).
126    Error(HandleId),
127}
128
129/// What the binding side returns when the Core invokes a fn via
130/// [`BindingBoundary::invoke_fn`].
131///
132/// Models the three emission modes from the canonical spec:
133/// - `FnResult::Data` — single DATA, equals substitution applies (R1.3.2).
134///   Maps to `actions.emit(v)` in sugar constructors.
135/// - `FnResult::Batch` — multi-message wave, no equals substitution
136///   (R1.3.2.d / R1.3.3.c). Maps to `actions.down(msgs)`.
137/// - `FnResult::Noop` — no emission. RESOLVED if node was DIRTY.
138///
139/// Per R2.4.5, the fn return value in the canonical spec is cleanup hooks
140/// only — all emission is explicit via actions. The Rust Core folds the
141/// emission into `FnResult` as a pragmatic simplification; the binding
142/// layer maps between the two representations.
143#[derive(Clone, Debug)]
144pub enum FnResult {
145    /// fn produced a single value. The Core treats this as outgoing DATA —
146    /// equals-substitution against the cache may rewrite it to RESOLVED
147    /// on the wire (R1.3.2).
148    Data {
149        handle: HandleId,
150        /// For dynamic nodes only: the dep indices fn actually read this run.
151        /// Static derived nodes pass `None`. See dynamic-node semantics in the
152        /// canonical spec §2.8 / Lock 2.B.
153        tracked: Option<Vec<usize>>,
154    },
155
156    /// fn ran but produced no emission this wave. The Core sends RESOLVED
157    /// to subscribers if the node was already DIRTY this wave; otherwise no
158    /// outgoing message.
159    Noop {
160        /// Same as `Data::tracked` — dynamic nodes only.
161        tracked: Option<Vec<usize>>,
162    },
163
164    /// Multi-message wave — models `actions.down(msgs)`. Emissions are
165    /// processed in sequence within the same wave. No equals substitution
166    /// on any Data emission (R1.3.2.d: substitution only on single-DATA
167    /// waves; R1.3.3.c: multi-DATA passes verbatim). DIRTY auto-prefix
168    /// only on first Data per R1.3.1.a.
169    Batch {
170        emissions: SmallVec<[FnEmission; 2]>,
171        /// Same as `Data::tracked` — dynamic nodes only.
172        tracked: Option<Vec<usize>>,
173    },
174}
175
176/// The FFI surface: every Core → user-code crossing goes through one of these
177/// three methods.
178///
179/// # Thread safety
180///
181/// `Send + Sync` because the Core dispatcher is sync but may be called from
182/// multiple binding threads (e.g. multiple Node Workers sharing one Core via
183/// `Arc<Core>`). Implementors must serialize access to the value registry
184/// internally if needed. Free-threaded Python parity is the target — see
185/// `~/src/graphrefly-py` `compat/asyncio.py` for the current shape that
186/// will simplify dramatically once this trait is the substrate.
187pub trait BindingBoundary: Send + Sync {
188    /// Invoke a user function. The Core knows the fn's identity (`fn_id`) and
189    /// the current dep batch data; the binding side dereferences handles,
190    /// runs the fn, registers the output, and returns the new handle.
191    ///
192    /// `dep_data` carries per-dep batch arrays (R1.3.6.b coalescing): outside
193    /// `batch()` scope each dep has at most 1 DATA handle; inside `batch()`,
194    /// K consecutive emits on the same source produce K entries per dep.
195    /// The binding side bulk-dereferences all handles, calls user code, and
196    /// returns the result — one FFI call per fn fire regardless of dep count
197    /// or batch depth.
198    ///
199    /// Errors thrown by user code are reported by returning a [`FnResult::Data`]
200    /// with a handle that resolves to an error value; the Core then forwards
201    /// `[ERROR, handle]` per R1.2.5. (Or the binding side surfaces them via a
202    /// separate channel — exact error-propagation discipline is binding-side.)
203    fn invoke_fn(&self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch]) -> FnResult;
204
205    /// Custom equals oracle. Called only when a node declares
206    /// `EqualsMode::Custom`. Identity equals (the default) is a `u64` compare
207    /// inside the Core — zero FFI per check.
208    ///
209    /// Per `H2 IdentityEqualsIsPureCore` ASSUME in
210    /// `~/src/graphrefly-ts/docs/research/handle-protocol.tla`,
211    /// the binding-side impl MUST extend identity (i.e. always treat
212    /// `a == b` as equal at the handle level). Otherwise a node could observe
213    /// its own cached value as different from itself — a fundamental violation.
214    fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool;
215
216    /// Decrement the refcount on `handle`. Called when the Core no longer
217    /// holds `handle` in any cache slot or message buffer. The binding side
218    /// drops the underlying value when its refcount reaches zero.
219    ///
220    /// Implementing this as a no-op is safe during prototyping; it matters
221    /// for memory pressure under sustained load. The TS prototype's
222    /// `bindings.ts` uses this to drive a `Map<HandleId, { value, refcount }>`.
223    fn release_handle(&self, handle: HandleId);
224
225    /// Increment the refcount on `handle`. Called when the Core takes an
226    /// additional reference to an existing handle that the binding side
227    /// already interned — currently used by the pause buffer (a buffered
228    /// `Data(H)` outlives the cache slot that originally interned `H`, so
229    /// the buffer needs its own refcount share to keep `H` alive across
230    /// later cache replacements).
231    ///
232    /// Default: no-op. Bindings that don't track refcounts (e.g., trivial
233    /// always-alive registries) can leave the default. Bindings that do
234    /// (TS `bindings.ts`, the test runtime, the napi-rs bench harness) must
235    /// override to bump the per-handle refcount.
236    fn retain_handle(&self, _handle: HandleId) {}
237
238    // -----------------------------------------------------------------
239    // Operator FFI surface (Slice C-1, D009). Bulk projection methods
240    // for the built-in operator dispatch path. Default impls panic so
241    // bindings that don't ship operators (e.g., minimal test bindings
242    // that only exercise raw fn-fire) don't pay the registry cost; the
243    // dispatch path only routes here when an `Operator(_)` node fires,
244    // so a binding that doesn't register operator nodes never reaches
245    // these defaults.
246    //
247    // Each method returns the per-input result. Caller (Core) owns the
248    // resulting handles' retains — the binding must `retain_handle`-bump
249    // any handles it returns that share an existing registry slot.
250    // -----------------------------------------------------------------
251
252    /// `OperatorOp::Map` — element-wise transform. For each input handle,
253    /// the binding dereferences to `T`, calls the user `Fn(T) -> R`, and
254    /// returns the new handle. Output length equals input length.
255    fn project_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[HandleId; 1]> {
256        unimplemented!("project_each: this binding does not support operators (D009)")
257    }
258
259    /// `OperatorOp::Filter` — element-wise predicate. For each input
260    /// handle, the binding dereferences to `T`, calls the user
261    /// `Fn(T) -> bool`, and returns the boolean. Output length equals
262    /// input length.
263    fn predicate_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[bool; 4]> {
264        unimplemented!("predicate_each: this binding does not support operators (D009)")
265    }
266
267    /// `OperatorOp::Scan` / `OperatorOp::Reduce` — left-fold. The binding
268    /// dereferences `acc` to `R` and each input to `T`, runs
269    /// `Fn(R, T) -> R` for each input feeding the previous result forward,
270    /// and returns each intermediate `R` as a fresh handle. Output length
271    /// equals input length (Scan emits each entry; Reduce uses only the
272    /// last). The starting `acc` is owned by Core; the binding must NOT
273    /// release it.
274    fn fold_each(
275        &self,
276        _fn_id: FnId,
277        _acc: HandleId,
278        _inputs: &[HandleId],
279    ) -> SmallVec<[HandleId; 1]> {
280        unimplemented!("fold_each: this binding does not support operators (D009)")
281    }
282
283    /// `OperatorOp::Pairwise` — pack `(prev, current)` into a tuple value.
284    /// Called once per pair (Core iterates and updates `prev` between
285    /// calls). Returns the binding-side handle for the new tuple value.
286    fn pairwise_pack(&self, _fn_id: FnId, _prev: HandleId, _current: HandleId) -> HandleId {
287        unimplemented!("pairwise_pack: this binding does not support operators (D009)")
288    }
289
290    /// `OperatorOp::Combine` / `OperatorOp::WithLatestFrom` — pack N
291    /// handles into a single tuple/array handle (Slice C-2, D020). The
292    /// binding dereferences each input handle to `T`, constructs a tuple
293    /// value (e.g., `[T0, T1, ..., Tn]`), and returns the new handle.
294    /// The returned handle has a pre-bumped retain (caller takes ownership
295    /// without additional `retain_handle` call).
296    fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId {
297        unimplemented!("pack_tuple: this binding does not support combinator operators (D020)")
298    }
299
300    // -----------------------------------------------------------------
301    // Producer lifecycle (Slice D, D031, D035). Producers are nodes
302    // with no deps + a fn — fn fires once on first subscribe and may
303    // call `Core::subscribe` from inside its body to wire up upstream
304    // sources (the zip / concat / race / takeUntil pattern).
305    //
306    // The binding maintains its own per-producer state (subscription
307    // handles, captured closure state) outside Core. When the LAST
308    // subscriber unsubscribes from a producer, Core invokes
309    // `producer_deactivate(node_id)` so the binding can drop that
310    // state — which transitively drops the producer's upstream
311    // subscriptions via `Subscription::Drop`.
312    //
313    // The hook fires lock-released (after the state lock is dropped),
314    // so the binding's deactivation impl may re-enter Core if needed
315    // (e.g., calling `release_handle` on captured handle shares).
316    //
317    // Symmetric with FnCtx: Core hands the binding a lifecycle signal
318    // and lets the binding shape its ergonomic surface (the
319    // `ProducerCtx` helper in `graphrefly-operators::producer` is one
320    // such shape; bindings may roll their own).
321    // -----------------------------------------------------------------
322
323    /// Called when a producer node loses its last subscriber. The binding
324    /// should drop any per-node state for `node_id` — captured closures,
325    /// `Vec<Subscription>` to upstream sources, etc. Default no-op for
326    /// bindings that don't ship producers.
327    ///
328    /// Fires lock-released; re-entrance into Core (e.g., `release_handle`
329    /// on captured handle shares, or even `subscribe` for re-activation
330    /// scenarios — though the latter is unusual) is permitted.
331    fn producer_deactivate(&self, _node_id: NodeId) {}
332
333    /// R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused
334    /// node's pause buffer overflows the configured cap. Called once per
335    /// overflow event (the first drop of a pause cycle); the returned
336    /// handle becomes the payload of `Message::Error` that Core then
337    /// emits via the standard terminal cascade.
338    ///
339    /// Bindings that ship their own diagnostic shape (e.g. structured
340    /// `{ code, nodeId, dropped, configuredMax, lockHeldDurationMs }`
341    /// JSON for the JS / Python sides) override this to intern such a
342    /// value and return its handle.
343    ///
344    /// **Default returns `None`** — Rust core falls back to the silent
345    /// drop + `ResumeReport.dropped` path. This preserves backward
346    /// compatibility for bindings that haven't yet wired up R1.3.8.c.
347    /// New bindings SHOULD implement this method to satisfy the
348    /// canonical-spec invariant.
349    ///
350    /// **Returning `None` consumes the overflow event for the cycle**
351    /// (QA A9, 2026-05-07): the per-pause-cycle `overflow_reported` flag
352    /// is set BEFORE this hook is called, so a `None` return doesn't
353    /// re-attempt synthesis on subsequent overflows in the same cycle.
354    /// If the binding is going to dynamically wire up the hook
355    /// (configuration-driven), do so before any pause cycle that may
356    /// overflow.
357    ///
358    /// Fires lock-released. The binding may re-enter Core (typical
359    /// implementation just calls `intern_value` and returns).
360    ///
361    /// `lock_held_duration_ms` is the wall-clock-monotonic duration in
362    /// milliseconds since the node first transitioned `Active → Paused`
363    /// at the start of this pause cycle; it gives consumers a sense of
364    /// how long a leaked controller held the lockset before overflow.
365    /// Sub-millisecond durations truncate to `0` (QA A8, 2026-05-07).
366    fn synthesize_pause_overflow_error(
367        &self,
368        _node_id: NodeId,
369        _dropped_count: u32,
370        _configured_max: usize,
371        _lock_held_duration_ms: u64,
372    ) -> Option<HandleId> {
373        None
374    }
375
376    // -----------------------------------------------------------------
377    // Cleanup-hook lifecycle (Slice E2 — R2.4.5 / R2.4.6 / Lock 4.A / 4.A′
378    // / Lock 6.D). Decisions: D054 (lifecycle-trigger hooks; binding owns
379    // ctx state), D055 (binding-side `Mutex<HashMap<NodeId, NodeCtxState>>`,
380    // wipe only on resubscribable terminal reset), D056 (cleanup-first
381    // before producer_deactivate), D057 (strict per-wave-per-node dedup),
382    // D058 (fire at cache-clear time), D059 (one-shot current_cleanup on
383    // OnDeactivation), D060 (binding-side panic isolation, drain
384    // iterates-don't-short-circuit), D061 (panic-discard wave drops
385    // deferred queue silently). Full design lives in
386    // `~/src/graphrefly-ts/archive/docs/SESSION-rust-port-fn-ctx-cleanup.md`.
387    //
388    // Bindings opt in by overriding `cleanup_for` and `wipe_ctx`. Default
389    // no-ops keep non-cleanup-aware bindings (e.g. minimal test bindings,
390    // bench harnesses) compiling unchanged.
391    // -----------------------------------------------------------------
392
393    /// Fire a registered user-cleanup hook for `node_id` at the lifecycle
394    /// moment indicated by `trigger`.
395    ///
396    /// # Binding-side contract
397    ///
398    /// Per D055, bindings own a `Mutex<HashMap<NodeId, NodeCtxState>>` where
399    /// `NodeCtxState = { store, current_cleanup }`. On each `invoke_fn` the
400    /// binding overwrites `current_cleanup` with whatever cleanup spec the
401    /// user fn returned. When Core later fires `cleanup_for(node, trigger)`,
402    /// the binding's impl looks up `current_cleanup.<trigger_slot>` and
403    /// invokes it if present. Absent slots are no-ops.
404    ///
405    /// **Lock discipline.** Bindings MUST release their `node_ctx` lock
406    /// before invoking the user closure (clone the closure handle out of
407    /// the map, drop the lock, fire). Holding the binding-side lock
408    /// across the user closure deadlocks if the user re-enters the
409    /// binding's high-level API from inside the cleanup.
410    ///
411    /// **Re-entrance into Core (D045 / D060).** Permitted: `release_handle`,
412    /// `Core::up(other_node, Pause/Resume/Invalidate/Teardown)`, `Core::emit`
413    /// for unrelated nodes, `Core::subscribe` / drop a `Subscription`. Not
414    /// permitted (undefined behavior): `Core::emit(self_node, ...)` from
415    /// inside `OnRerun` (creates a fresh emit during the wave's pre-fire
416    /// window); `Core::subscribe(self_node)` from inside `OnDeactivation`
417    /// (self-resubscribe race during deactivation).
418    ///
419    /// **Panic isolation (D060).** Core stays panic-naive about user code
420    /// — bindings SHOULD wrap user-closure invocations in `catch_unwind`
421    /// and surface failures via the host language's idiom (JS exception
422    /// → console.error, Python panic → warning, Rust panic → log + propagate
423    /// per binding policy). Core's deferred-drain for `OnInvalidate` wraps
424    /// each `cleanup_for` call in `catch_unwind` itself to prevent a single
425    /// panic from short-circuiting the per-wave drain (all queued cleanup
426    /// attempts run; the last panic re-raises after the drain completes).
427    /// `OnRerun` and `OnDeactivation` fire inline lock-released — a
428    /// panicking `cleanup_for` propagates out of `fire_regular` Phase 1.5
429    /// or `Subscription::Drop` respectively (Drop guarantees apply: state
430    /// lock already released).
431    ///
432    /// **Panic-discard guarantee gap (D061).** When a wave is panic-discarded
433    /// (an `invoke_fn` panics mid-wave), the queued `OnInvalidate` cleanup
434    /// hooks are dropped silently — the cascade's recorded cache-clears
435    /// never fire their cleanups. External-resource cleanup (file handles,
436    /// network sockets, external transactions) attached to `OnInvalidate`
437    /// MUST be idempotent at process exit / next successful invalidate
438    /// cycle. `OnRerun` panic-discard is moot (panic in `OnRerun` aborts
439    /// the wave's `fire_regular` before it can corrupt state). `OnDeactivation`
440    /// panic-discard is moot (Drop is invoked during stack unwinding;
441    /// double-panic during drop aborts per `std::process::abort` semantics).
442    ///
443    /// **Per-trigger lifecycle.** See [`CleanupTrigger`] for per-variant
444    /// firing rules. After each trigger fires, the binding decides whether
445    /// to clear `current_cleanup`:
446    /// - `OnRerun`: do NOT clear; the next `invoke_fn` will overwrite.
447    /// - `OnInvalidate`: do NOT clear; multiple INVALIDATEs across waves
448    ///   can re-fire the same closure.
449    /// - `OnDeactivation`: clear (D059) — one-shot per activation cycle;
450    ///   `store` persists separately per R2.4.6.
451    ///
452    /// Default no-op so bindings without cleanup-hook support compile
453    /// unchanged.
454    fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) {}
455
456    /// Wipe the binding-side ctx state for `node_id`.
457    ///
458    /// Called by Core ONLY on resubscribable terminal reset, per **R2.4.6**:
459    /// `ctx.store` is "wiped automatically: on resubscribable terminal
460    /// reset (when a `resubscribable: true` node hits `COMPLETE`/`ERROR`
461    /// and is later resubscribed)". Bindings drop their `NodeCtxState`
462    /// entry for `node_id`, releasing both `store` and any residual
463    /// `current_cleanup`.
464    ///
465    /// Default deactivation does NOT trigger wipe — `store` persists
466    /// across deactivation/reactivation cycles by spec design (mirrored
467    /// here to match canonical; current TS impl wipes on deactivation
468    /// per docstring at `node.ts:189-190` and is on the Phase 13.6.B
469    /// migration list per canonical-spec §11 item 3).
470    ///
471    /// Fires lock-released. Re-entrance into Core is permitted; typical
472    /// implementations just call `node_ctx.lock().remove(&node_id)`.
473    /// Default no-op.
474    fn wipe_ctx(&self, _node_id: NodeId) {}
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::handle::{FnId, HandleId, NodeId};
481    use std::sync::atomic::{AtomicU64, Ordering};
482
483    /// Test double mirroring `bindings.ts` `BindingBoundary` test patterns —
484    /// counts FFI crossings per method to verify the cleaving plane's
485    /// "zero FFI on identity-equals path" claim experimentally.
486    #[allow(clippy::struct_field_names)]
487    struct TestBinding {
488        invoke_count: AtomicU64,
489        equals_count: AtomicU64,
490        release_count: AtomicU64,
491    }
492
493    impl TestBinding {
494        fn new() -> Self {
495            Self {
496                invoke_count: AtomicU64::new(0),
497                equals_count: AtomicU64::new(0),
498                release_count: AtomicU64::new(0),
499            }
500        }
501    }
502
503    impl BindingBoundary for TestBinding {
504        fn invoke_fn(&self, _node_id: NodeId, _fn_id: FnId, dep_data: &[DepBatch]) -> FnResult {
505            self.invoke_count.fetch_add(1, Ordering::SeqCst);
506            // Echo first dep's latest handle as result; not realistic but exercises the path.
507            let handle = dep_data.first().map_or(HandleId::new(99), DepBatch::latest);
508            FnResult::Data {
509                handle,
510                tracked: None,
511            }
512        }
513
514        fn custom_equals(&self, _equals_handle: FnId, a: HandleId, b: HandleId) -> bool {
515            self.equals_count.fetch_add(1, Ordering::SeqCst);
516            a == b
517        }
518
519        fn release_handle(&self, _handle: HandleId) {
520            self.release_count.fetch_add(1, Ordering::SeqCst);
521        }
522    }
523
524    #[test]
525    fn boundary_calls_route_correctly() {
526        let b = TestBinding::new();
527        let dep = DepBatch {
528            data: smallvec::smallvec![HandleId::new(7)],
529            prev_data: NO_HANDLE,
530            involved: true,
531        };
532        let result = b.invoke_fn(NodeId::new(1), FnId::new(2), &[dep]);
533        match result {
534            FnResult::Data { handle, .. } => assert_eq!(handle, HandleId::new(7)),
535            FnResult::Noop { .. } | FnResult::Batch { .. } => panic!("expected Data variant"),
536        }
537        assert!(b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(7)));
538        assert!(!b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(8)));
539        b.release_handle(HandleId::new(7));
540
541        assert_eq!(b.invoke_count.load(Ordering::SeqCst), 1);
542        assert_eq!(b.equals_count.load(Ordering::SeqCst), 2);
543        assert_eq!(b.release_count.load(Ordering::SeqCst), 1);
544    }
545
546    #[test]
547    fn binding_is_send_and_sync() {
548        // Compile-time check: BindingBoundary impls must be Send + Sync.
549        // If TestBinding ever gains a !Send field, this fails to compile.
550        fn assert_send_sync<T: Send + Sync>() {}
551        // dyn-trait variant is the production shape (Core holds Arc<dyn BindingBoundary>).
552        fn assert_dyn_send_sync<T: ?Sized + Send + Sync>() {}
553        assert_send_sync::<TestBinding>();
554        assert_dyn_send_sync::<dyn BindingBoundary>();
555    }
556}