Skip to main content

caixa_core/
behavior.rs

1//! OTP-shaped behavior callbacks — the typed slot of `caixa.lisp`
2//! that points at the `.lisp` files implementing the lifecycle.
3//!
4//! See `theory/INSPIRATIONS.md` §II.3 for the prior-art frame
5//! (`gen_server`, `gen_statem`, `gen_event`). Authors implement the
6//! callbacks; the runtime owns init / message dispatch / terminate.
7//!
8//! ```lisp
9//! (defcaixa
10//!   :nome     "my-service"
11//!   :versao   "0.1.0"
12//!   :kind     Servico
13//!   :behavior ((:on-init         "lib/init.lisp")
14//!              (:on-call         "lib/handlers.lisp")
15//!              (:on-cast         "lib/handlers.lisp")
16//!              (:on-info         "lib/handlers.lisp")
17//!              (:on-state-change "lib/migrations.lisp")
18//!              (:on-terminate    "lib/cleanup.lisp"))
19//!   :servicos ("servicos/my-service.computeunit.yaml"))
20//! ```
21//!
22//! Each slot is optional — caixas without explicit callbacks fall
23//! back to the runtime defaults (no-op init, raw HTTP dispatch,
24//! noop terminate). The `StandardLayout` invariant in `layout.rs`
25//! verifies every declared path exists on disk before the build.
26
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31
32/// Path-to-callback bindings for an OTP-shaped Servico.
33///
34/// All fields optional. The wasm-engine looks up the callback by
35/// kind at instance start; if absent, the runtime default is used.
36#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct BehaviorSpec {
39    /// Called once before the instance accepts traffic. Analog of
40    /// `gen_server:init/1`. Runs to completion or the instance
41    /// fails to start.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub on_init: Option<PathBuf>,
44
45    /// Synchronous request/response handler. Analog of
46    /// `gen_server:handle_call/3` — reply is awaited by the caller.
47    /// For HTTP servicos this is the wasi:http/incoming-handler.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub on_call: Option<PathBuf>,
50
51    /// Asynchronous fire-and-forget handler. Analog of
52    /// `gen_server:handle_cast/2` — caller does not wait. For HTTP
53    /// servicos this maps onto `Accepted: 202` shapes.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub on_cast: Option<PathBuf>,
56
57    /// System / out-of-band message handler. Analog of
58    /// `gen_server:handle_info/2` — timeouts, downstream `nodedown`,
59    /// monitor signals, scheduler ticks.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub on_info: Option<PathBuf>,
62
63    /// State migration callback for hot-upgrades. Analog of
64    /// `gen_server:code_change/3` — receives old state + version,
65    /// returns new state. Composes with the `:upgrade-from` slot
66    /// declared at the Caixa root.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub on_state_change: Option<PathBuf>,
69
70    /// Cleanup callback before the instance shuts down. Analog of
71    /// `gen_server:terminate/2`. Best-effort — runs only when the
72    /// instance terminates gracefully (not on hard kill).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub on_terminate: Option<PathBuf>,
75}
76
77impl BehaviorSpec {
78    /// Iterate over every declared callback path tagged with the
79    /// kebab-case `:on-*` slot it came from. Used by the layout
80    /// checker (existence) and by [`BehaviorSpec::validate`]
81    /// (value-shape) so diagnostics can name the offending slot.
82    ///
83    /// Each per-arm kebab-case label is routed through the peer
84    /// [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] consts declared next to
85    /// the [`crate::M2_BEHAVIOR_KEY_ON_*`] renderer-side wire-key
86    /// peers, so both halves of the M2 `:behavior` sub-slot's dual
87    /// axis (author-facing kebab-case label + renderer-side camelCase
88    /// wire key) route through one canonical declaration per arm.
89    ///
90    /// Each per-arm `Option<&Path>` path-value is routed through the
91    /// sibling lifted [`BehaviorSpec::on_init`] / [`BehaviorSpec::on_call`]
92    /// / [`BehaviorSpec::on_cast`] / [`BehaviorSpec::on_info`] /
93    /// [`BehaviorSpec::on_state_change`] / [`BehaviorSpec::on_terminate`]
94    /// per-slot accessors, so the iterator's per-arm typed dispatch
95    /// composes with every future accessor-side extension (a
96    /// per-prior-`:versao` state-migration callback the operator pins
97    /// through a future `:behavior :on-state-change-overrides` slot the
98    /// `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine callback-dispatch
99    /// wire acknowledges, a per-tenant callback alias table the M4 CR
100    /// materializer resolves per-CR, a per-cluster callback overlay the
101    /// operator pins through a future placement-scoped slot) as a unit:
102    /// the layout checker's existence sweep + the sibling
103    /// [`BehaviorSpec::validate`] value-shape gate consume whichever
104    /// accept-set the accessor exposes, so both halves of the diagnostic
105    /// surface migrate together. Prior to this converge the six per-arm
106    /// `self.on_*.as_ref()` raw-field-access sites bypassed the accessor
107    /// dispatch — the accessors owned the accept-set on the read side but
108    /// the iterator that both production consumers actually read
109    /// projected through the raw `Option<PathBuf>` field, silently
110    /// disagreeing with every accessor extension until the two-site
111    /// rewrite reached both halves in lockstep.
112    pub fn declared_slots(&self) -> impl Iterator<Item = (&'static str, &Path)> {
113        [
114            (
115                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
116                self.on_init(),
117            ),
118            (
119                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
120                self.on_call(),
121            ),
122            (
123                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
124                self.on_cast(),
125            ),
126            (
127                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
128                self.on_info(),
129            ),
130            (
131                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
132                self.on_state_change(),
133            ),
134            (
135                crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
136                self.on_terminate(),
137            ),
138        ]
139        .into_iter()
140        .filter_map(|(slot, opt)| opt.map(|p| (slot, p)))
141    }
142
143    /// Iterate over every declared callback path. Used by the
144    /// layout checker.
145    pub fn declared_paths(&self) -> impl Iterator<Item = &Path> {
146        self.declared_slots().map(|(_slot, p)| p)
147    }
148
149    /// Substrate-canonical per-`:behavior` emptiness predicate every
150    /// M2 renderer that overlays the typed slot onto a cluster artifact
151    /// keys off — `true` iff every one of the six `Option<PathBuf>`-
152    /// carrying `:on-*` callback slots ([`Self::on_init`] /
153    /// [`Self::on_call`] / [`Self::on_cast`] / [`Self::on_info`] /
154    /// [`Self::on_state_change`] / [`Self::on_terminate`]) is unset, so
155    /// an authored-but-unset `:behavior (())` round-trips to a rendered
156    /// artifact that's structurally identical to one that omits the
157    /// slot entirely.
158    ///
159    /// Peer of the sibling per-typed-slot emptiness predicates on the
160    /// M2 / M3 typed-slot surface — [`crate::LimitsSpec::is_empty`]
161    /// (limits.rs:378) on the paired per-Servico Lunatic-per-process
162    /// sandbox-cap axis and [`crate::aplicacao::MeshPolicy::is_empty`]
163    /// (aplicacao.rs:2655) on the M3 per-Aplicacao `:politicas` mesh-
164    /// cap axis: same "one-line-per-axis `&& self.<axis>.is_none()`
165    /// chain, one substrate primitive per typed slot" discipline
166    /// extended onto the M2 `:behavior` slot family, so every future
167    /// axis added to `BehaviorSpec` (a per-cluster callback overlay the
168    /// M4 CR materializer resolves per-CR, a per-tenant callback alias
169    /// the operator pins through a future `:placement`-scoped slot, a
170    /// seventh OTP-shape `gen_server` callback the roadmap
171    /// [`ABSORPTION-ROADMAP`](https://github.com/pleme-io/theory/blob/main/ABSORPTION-ROADMAP.md)
172    /// grows once the six canonical arms stop covering the substrate's
173    /// discovered callback shape) reaches this predicate at one edit
174    /// (one added struct field + one added `&& self.<axis>.is_none()`
175    /// line) rather than a coordinated rewrite of every renderer that
176    /// reads the emptiness semantic through an open-coded per-field
177    /// chain.
178    ///
179    /// `pub const fn` — matches the sibling
180    /// [`crate::LimitsSpec::is_empty`] / [`crate::aplicacao::MeshPolicy::is_empty`]
181    /// `pub const fn` shape verbatim, so every downstream consumer that
182    /// wants to fold an emptiness decision into a `const` position (a
183    /// `const IS_EMPTY_DEFAULT: bool = BehaviorSpec { … }.is_empty();`
184    /// derivation the future M4 CR materializer's admission-time
185    /// default-overlay-emit gate reaches for, a `const`-context probe
186    /// the future wasm-operator's per-Servico startup-log skip-empty-
187    /// `:behavior` short-circuit consults, a compile-time lookup table
188    /// the LSP hover renderer materializes per typed-slot fixture)
189    /// reads through one const dispatch rather than being forced onto
190    /// the runtime code path.
191    ///
192    /// Reads the raw `Option<PathBuf>` field on each of the six slots
193    /// directly (via [`Option::is_none`], `const`-stable since Rust
194    /// 1.48) rather than routing through the paired
195    /// [`Self::on_init`] / [`Self::on_call`] / [`Self::on_cast`] /
196    /// [`Self::on_info`] / [`Self::on_state_change`] /
197    /// [`Self::on_terminate`] accessors, because those accessors
198    /// project through [`Option::as_deref`] onto
199    /// `Option<&std::path::Path>` and neither `Option::as_deref` nor
200    /// [`std::path::PathBuf::as_path`] is `const`-stable today (per the
201    /// tracking issue on `const` `Deref` — cannot be called from a
202    /// `const fn` body without an unstable feature gate). The
203    /// per-field emptiness answer is byte-equal to the accessor
204    /// projection's `Option::is_some` regardless (an `Option<T>` is
205    /// `None` iff its `as_deref`-projected image is `None`), so the
206    /// direct-field read preserves the sibling `LimitsSpec::is_empty`
207    /// / `MeshPolicy::is_empty` semantics verbatim. Pin test
208    /// [`tests::behavior_spec_is_empty_agrees_with_declared_paths_across_all_slot_permutations`]
209    /// sweeps every one of the 2^6 = 64 six-slot Some/None
210    /// permutations and asserts the direct-field predicate returns the
211    /// same `bool` as the iterator-based [`Self::declared_paths`] /
212    /// `.next().is_none()` chain the pre-promotion body inlined, so a
213    /// future slot addition that forgets to extend either side surfaces
214    /// as a build-time test failure at `behavior.rs`, not as a silent
215    /// per-consumer drift at renderer / operator / admission-webhook
216    /// dispatch time far from the added-field commit.
217    #[must_use]
218    pub const fn is_empty(&self) -> bool {
219        self.on_init.is_none()
220            && self.on_call.is_none()
221            && self.on_cast.is_none()
222            && self.on_info.is_none()
223            && self.on_state_change.is_none()
224            && self.on_terminate.is_none()
225    }
226
227    /// Substrate-canonical per-`:behavior` `:on-state-change`
228    /// OTP-`gen_server:code_change/3`-shaped state-migration callback
229    /// path scalar accessor every consumer of the Servico's hot-upgrade
230    /// dispatch keys off — returns the author-declared
231    /// `:behavior :on-state-change` typed callback path verbatim as an
232    /// `Option<&Path>`, borrowed from the typed slot's own
233    /// `Option<PathBuf>` storage. `None` when the slot is absent (the
234    /// canonical "no state-migration callback declared — the caixa
235    /// exposes no hot-upgrade state-fold path, so any `:upgrade-from`
236    /// entry carrying a `(:state-change …)` instruction is structurally
237    /// half a composition" arm the peer
238    /// [`crate::validate_upgrade_from_against_behavior`] cross-slot gate
239    /// keys off through this accessor).
240    ///
241    /// The `:behavior :on-state-change` slot carries the OTP
242    /// `gen_server:code_change/3` callback contract (the module-level
243    /// [`BehaviorSpec::on_state_change`] docstring pins the analog verbatim:
244    /// "State migration callback for hot-upgrades. Analog of
245    /// `gen_server:code_change/3` — receives old state + version, returns
246    /// new state. Composes with the `:upgrade-from` slot declared at the
247    /// Caixa root."). The composition it half-forms is realized in OTP by
248    /// `release_handler:install_release/1`, which invokes the running
249    /// `gen_server`'s `code_change/3` during the appup's `code_change` /
250    /// `update, m, soft` step — the appup's instruction triggers the
251    /// callback, the callback folds the prior-version state shape into
252    /// the current-version shape, and the operator advances to the next
253    /// instruction only after the callback returns successfully
254    /// (`theory/INSPIRATIONS.md` §II.3 — OTP `gen_server` +
255    /// `release_handler` state-migration wire, translated onto pleme-io's
256    /// typed `:behavior` + `:upgrade-from` slot pair). caixa decomposes
257    /// the same composition into two typed slots: the per-version
258    /// migration logic lives in the `(:state-change "lib/migrations/…lisp")`
259    /// instruction's `:script` (the `:upgrade-from` author surface,
260    /// resolved through [`crate::UpgradeInstruction::StateChange`]), and
261    /// the runtime hook the operator dispatches the migration through
262    /// lives in the `:behavior :on-state-change` callback (the
263    /// `:behavior` author surface, resolved through this accessor). The
264    /// [`crate::validate_upgrade_from_against_behavior`] cross-slot gate
265    /// closes the composition at validate time by refusing a Caixa that
266    /// carries a `:state-change` instruction without declaring
267    /// `:on-state-change` — the sole caixa-core consumer that reads the
268    /// callback's `Option<&Path>` presence rather than the callback path
269    /// itself.
270    ///
271    /// Prior to this lift the `.on_state_change` field was accessed
272    /// inline at two sites — [`BehaviorSpec::declared_slots`]'s
273    /// `:on-state-change` arm's `self.on_state_change.as_ref()` map into
274    /// the six-tuple iterator, and the sibling
275    /// [`crate::validate_upgrade_from_against_behavior`] cross-slot gate's
276    /// `behavior.and_then(|b| b.on_state_change.as_ref()).is_some()`
277    /// short-circuit — two open-coded field-accesses that expressed no
278    /// compile-time link back to the typed slot. A future extension of
279    /// the `:behavior :on-state-change` axis to a richer author surface —
280    /// a per-prior-`:versao` state-migration callback the operator pins
281    /// through a future `:behavior :on-state-change-overrides` slot the
282    /// `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine callback-dispatch
283    /// wire acknowledges, a per-tenant migration alias table the M4 CR
284    /// materializer resolves per-CR, a per-Aplicacao dynamic
285    /// state-change derivation the future adaptive hot-upgrade engine
286    /// computes from the sibling `:upgrade-from` instruction chain —
287    /// would have had to be threaded through both open-coded copies in
288    /// lockstep or the `declared_slots` iterator (the tag surface every
289    /// per-slot diagnostic reads) and the
290    /// `validate_upgrade_from_against_behavior` gate (the composition
291    /// closure every hot-upgrade admission reads) would silently
292    /// disagree on which callback a given [`BehaviorSpec`] resolves to.
293    /// Lifting the resolution to a typed method on the substrate
294    /// primitive means every downstream consumer of the Servico's
295    /// per-`:behavior` state-migration callback surface reaches for
296    /// exactly one typed dispatch — the resolver's accept-set migrates
297    /// as a unit on any future axis addition.
298    ///
299    /// First `Option<&Path>`-return accessor on the M2 `:behavior` slot
300    /// family (peer of the sibling per-`:placement`
301    /// [`crate::Placement::shard_key`] 7cd2a28 /
302    /// [`crate::Placement::affinity`] 74ec2d3 `Option<&str>` accessors
303    /// on the M3 mesh-slot family — same "one typed dispatch on the
304    /// substrate primitive, thin projections at each consumer"
305    /// discipline extended onto the peer per-`:behavior`
306    /// `Option<PathBuf>` optional-scalar axis; opens the "optional
307    /// per-slot `Option<&Path>` scalar" projection pattern the sibling
308    /// per-`:behavior` `:on-init` / `:on-call` / `:on-cast` / `:on-info`
309    /// / `:on-terminate` future lifts fold on). Named
310    /// `on_state_change()` to match the storage field's name; the
311    /// accessor's identity name maps onto the canonical
312    /// `theory/INSPIRATIONS.md` §II.3 vocabulary the slot's docstring
313    /// already carries.
314    #[must_use]
315    pub fn on_state_change(&self) -> Option<&Path> {
316        self.on_state_change.as_deref()
317    }
318
319    /// Substrate-canonical per-`:behavior` `:on-init` OTP-`gen_server:init/1`-
320    /// shaped once-per-instance-start callback-path scalar accessor every
321    /// consumer of the Servico's instance-start dispatch keys off — returns
322    /// the author-declared `:behavior :on-init` typed callback path
323    /// verbatim as an `Option<&Path>`, borrowed from the typed slot's own
324    /// `Option<PathBuf>` storage. `None` when the slot is absent (the
325    /// canonical "no init callback declared — the runtime falls back to
326    /// the wasm-engine's no-op instance-start default" arm the runtime's
327    /// callback-lookup consults at instance-start time; peer of the
328    /// sibling [`BehaviorSpec::on_state_change`] `None`-arm's
329    /// "no state-migration callback" semantic on the sibling axis).
330    ///
331    /// The `:behavior :on-init` slot carries the OTP
332    /// `gen_server:init/1` callback contract (the module-level
333    /// [`BehaviorSpec::on_init`] docstring pins the analog verbatim:
334    /// "Called once before the instance accepts traffic. Analog of
335    /// `gen_server:init/1`. Runs to completion or the instance fails to
336    /// start."). Its position in the OTP lifecycle is first — the
337    /// runtime instantiates the wasm process, dispatches the init
338    /// callback, and only then flips the instance's readiness state so
339    /// downstream traffic (`:on-call` / `:on-cast`) is accepted
340    /// (`theory/INSPIRATIONS.md` §II.3 — OTP `gen_server` behavior's
341    /// six-callback lifecycle, translated onto pleme-io's typed
342    /// `:behavior` slot family; `theory/CAIXA-SDLC.md` §I — the
343    /// author-surface pins `:on-init` as the first arm of the
344    /// `:behavior` overlay every Servico may declare).
345    ///
346    /// Prior to this lift the `.on_init` field was accessed inline at
347    /// one production site — [`BehaviorSpec::declared_slots`]'s
348    /// `:on-init` arm's `self.on_init.as_ref()` map into the six-tuple
349    /// iterator that both the layout checker (existence sweep at
350    /// `layout.rs:900`) and the sibling `BehaviorSpec::validate`
351    /// value-shape gate consume — an open-coded field-access that
352    /// expressed no compile-time link back to the typed slot. A future
353    /// extension of the `:behavior :on-init` axis to a richer author
354    /// surface — a per-tenant init-callback override the M4 CR
355    /// materializer resolves per-CR, a per-cluster instance-start
356    /// callback overlay the `theory/ABSORPTION-ROADMAP.md` M2.5
357    /// wasm-engine callback-dispatch wire acknowledges, a per-Aplicacao
358    /// dynamic init-callback derivation the future adaptive
359    /// hot-instantiation engine computes from the sibling `:limits`
360    /// wasm-engine sandbox — would have had to be threaded through the
361    /// open-coded field-access in `declared_slots` (the tag surface every
362    /// per-slot diagnostic reads) or the `declared_slots` iterator would
363    /// silently disagree on which callback a given [`BehaviorSpec`]
364    /// resolves to. Lifting the resolution to a typed method on the
365    /// substrate primitive means every downstream consumer of the
366    /// Servico's per-`:behavior` init-callback surface reaches for
367    /// exactly one typed dispatch — the resolver's accept-set migrates
368    /// as a unit on any future axis addition.
369    ///
370    /// Second `Option<&Path>`-return accessor on the M2 `:behavior` slot
371    /// family (sibling of the prior [`BehaviorSpec::on_state_change`]
372    /// 9b4ecde `Option<&Path>` accessor on the peer per-`:behavior`
373    /// `:on-state-change` axis — same "one typed dispatch on the
374    /// substrate primitive, thin projections at each consumer"
375    /// discipline extended onto the peer per-`:behavior`
376    /// `Option<PathBuf>` optional-scalar axis; continues the "optional
377    /// per-slot `Option<&Path>` scalar" projection pattern the sibling
378    /// per-`:behavior` `:on-call` / `:on-cast` / `:on-info` /
379    /// `:on-terminate` future lifts fold on). Named `on_init()` to match
380    /// the storage field's name; the accessor's identity name maps onto
381    /// the canonical `theory/INSPIRATIONS.md` §II.3 vocabulary the
382    /// slot's docstring already carries.
383    #[must_use]
384    pub fn on_init(&self) -> Option<&Path> {
385        self.on_init.as_deref()
386    }
387
388    /// Substrate-canonical per-`:behavior` `:on-call`
389    /// OTP-`gen_server:handle_call/3`-shaped synchronous
390    /// request/response callback-path scalar accessor every consumer of
391    /// the Servico's synchronous-dispatch callback path keys off —
392    /// returns the author-declared `:behavior :on-call` typed callback
393    /// path verbatim as an `Option<&Path>`, borrowed from the typed
394    /// slot's own `Option<PathBuf>` storage. `None` when the slot is
395    /// absent (the canonical "no synchronous-call callback declared —
396    /// the runtime falls back to the wasm-engine's raw
397    /// `wasi:http/incoming-handler` default that surfaces the request to
398    /// the underlying HTTP proxy world verbatim without any
399    /// author-supplied reply-shape interposed" arm the M2.5 wasm-engine
400    /// callback-dispatch wire consults at every synchronous incoming
401    /// call; peer of the sibling [`BehaviorSpec::on_init`] /
402    /// [`BehaviorSpec::on_state_change`] `None`-arm's "no
403    /// instance-start / state-migration callback" semantic on the
404    /// sibling axes).
405    ///
406    /// The `:behavior :on-call` slot carries the OTP
407    /// `gen_server:handle_call/3` callback contract (the module-level
408    /// [`BehaviorSpec::on_call`] docstring pins the analog verbatim:
409    /// "Synchronous request/response handler. Analog of
410    /// `gen_server:handle_call/3` — reply is awaited by the caller. For
411    /// HTTP servicos this is the wasi:http/incoming-handler."). Its
412    /// position in the OTP dispatch triad is the request/response half:
413    /// the runtime routes every synchronous incoming message (every
414    /// `wasi:http/incoming-handler` invocation whose caller awaits a
415    /// reply, every synchronous WIT-typed peer edge whose contract
416    /// carries a reply payload) through the callback; the callback runs
417    /// to completion, computes the reply, and the runtime hands the
418    /// reply back to the awaiting caller before flipping the process
419    /// back to the mailbox-drain state (`theory/INSPIRATIONS.md` §II.3 —
420    /// OTP `gen_server` behavior's six-callback lifecycle, translated
421    /// onto pleme-io's typed `:behavior` slot family;
422    /// `theory/CAIXA-SDLC.md` §I — the author-surface pins `:on-call` as
423    /// the second arm of the `:behavior` overlay every Servico may
424    /// declare, sibling to `:on-cast` / `:on-info` on the peer
425    /// asynchronous-dispatch axes; `theory/RUNTIME-PATTERNS.md` §II —
426    /// the synchronous-request-response pattern the runtime realizes
427    /// through this callback).
428    ///
429    /// Prior to this lift the `.on_call` field was accessed inline at
430    /// one production site — [`BehaviorSpec::declared_slots`]'s
431    /// `:on-call` arm's `self.on_call.as_ref()` map into the six-tuple
432    /// iterator that both the layout checker (existence sweep at
433    /// `layout.rs:900`) and the sibling `BehaviorSpec::validate`
434    /// value-shape gate consume — an open-coded field-access that
435    /// expressed no compile-time link back to the typed slot. A future
436    /// extension of the `:behavior :on-call` axis to a richer author
437    /// surface — a per-tenant call-callback override the M4 CR
438    /// materializer resolves per-CR, a per-cluster synchronous-dispatch
439    /// callback overlay the `theory/ABSORPTION-ROADMAP.md` M2.5
440    /// wasm-engine callback-dispatch wire acknowledges, a per-contrato
441    /// per-`:wit`-world call-callback derivation the future adaptive
442    /// dispatch engine computes from the sibling `:contratos` M3
443    /// mesh-slot edges — would have had to be threaded through the
444    /// open-coded field-access in `declared_slots` (the tag surface
445    /// every per-slot diagnostic reads) or the `declared_slots`
446    /// iterator would silently disagree on which callback a given
447    /// [`BehaviorSpec`] resolves to. Lifting the resolution to a typed
448    /// method on the substrate primitive means every downstream
449    /// consumer of the Servico's per-`:behavior` synchronous-call
450    /// callback surface reaches for exactly one typed dispatch — the
451    /// resolver's accept-set migrates as a unit on any future axis
452    /// addition.
453    ///
454    /// Third `Option<&Path>`-return accessor on the M2 `:behavior` slot
455    /// family (sibling of the prior [`BehaviorSpec::on_state_change`]
456    /// 9b4ecde and [`BehaviorSpec::on_init`] d66c702 `Option<&Path>`
457    /// accessors on the peer per-`:behavior` `:on-state-change` /
458    /// `:on-init` axes — same "one typed dispatch on the substrate
459    /// primitive, thin projections at each consumer" discipline extended
460    /// onto the peer per-`:behavior` `Option<PathBuf>` optional-scalar
461    /// axis; continues the "optional per-slot `Option<&Path>` scalar"
462    /// projection pattern the sibling per-`:behavior` `:on-cast` /
463    /// `:on-info` / `:on-terminate` future lifts fold on). Named
464    /// `on_call()` to match the storage field's name; the accessor's
465    /// identity name maps onto the canonical
466    /// `theory/INSPIRATIONS.md` §II.3 vocabulary the slot's docstring
467    /// already carries.
468    #[must_use]
469    pub fn on_call(&self) -> Option<&Path> {
470        self.on_call.as_deref()
471    }
472
473    /// Substrate-canonical per-`:behavior` `:on-cast`
474    /// OTP-`gen_server:handle_cast/2`-shaped asynchronous
475    /// fire-and-forget callback-path scalar accessor every consumer of
476    /// the Servico's asynchronous-dispatch callback path keys off —
477    /// returns the author-declared `:behavior :on-cast` typed callback
478    /// path verbatim as an `Option<&Path>`, borrowed from the typed
479    /// slot's own `Option<PathBuf>` storage. `None` when the slot is
480    /// absent (the canonical "no asynchronous-cast callback declared —
481    /// the runtime falls back to the wasm-engine's default `Accepted:
482    /// 202` fire-and-forget response shape that surfaces the request to
483    /// the underlying HTTP proxy world verbatim without any
484    /// author-supplied post-accept-side-effect interposed" arm the M2.5
485    /// wasm-engine callback-dispatch wire consults at every asynchronous
486    /// incoming call; peer of the sibling [`BehaviorSpec::on_init`] /
487    /// [`BehaviorSpec::on_call`] / [`BehaviorSpec::on_state_change`]
488    /// `None`-arm's "no instance-start / synchronous-call /
489    /// state-migration callback" semantic on the sibling axes).
490    ///
491    /// The `:behavior :on-cast` slot carries the OTP
492    /// `gen_server:handle_cast/2` callback contract (the module-level
493    /// [`BehaviorSpec::on_cast`] docstring pins the analog verbatim:
494    /// "Asynchronous fire-and-forget handler. Analog of
495    /// `gen_server:handle_cast/2` — caller does not wait. For HTTP
496    /// servicos this maps onto `Accepted: 202` shapes."). Its position
497    /// in the OTP dispatch triad is the fire-and-forget half sibling to
498    /// the synchronous request/response `:on-call` half: the runtime
499    /// routes every asynchronous incoming message (every
500    /// `wasi:http/incoming-handler` invocation whose caller does not
501    /// await a reply and whose runtime response the wasm-engine
502    /// short-circuits into an `Accepted: 202` shape at accept time,
503    /// every asynchronous WIT-typed peer edge whose contract carries no
504    /// reply payload, every NATS `nats:pub-sub` subscriber the future
505    /// M3 mesh-slot NATS bridge dispatches through the `:on-cast`
506    /// callback the way the sibling `wasi:http/proxy` HTTP bridge
507    /// dispatches through `:on-call`) through the callback; the callback
508    /// runs to completion on the actor's own mailbox turn without any
509    /// reply-shape awaiting caller, and the runtime returns to the
510    /// mailbox-drain state as soon as the callback returns
511    /// (`theory/INSPIRATIONS.md` §II.3 — OTP `gen_server` behavior's
512    /// six-callback lifecycle, translated onto pleme-io's typed
513    /// `:behavior` slot family; `theory/CAIXA-SDLC.md` §I — the
514    /// author-surface pins `:on-cast` as the third arm of the
515    /// `:behavior` overlay every Servico may declare, sibling to
516    /// `:on-call` on the peer synchronous-dispatch axis and `:on-info`
517    /// on the peer out-of-band-dispatch axis; `theory/RUNTIME-PATTERNS.md`
518    /// §II — the asynchronous-fire-and-forget pattern the runtime
519    /// realizes through this callback).
520    ///
521    /// Prior to this lift the `.on_cast` field was accessed inline at
522    /// one production site — [`BehaviorSpec::declared_slots`]'s
523    /// `:on-cast` arm's `self.on_cast.as_ref()` map into the six-tuple
524    /// iterator that both the layout checker (existence sweep at
525    /// `layout.rs:900`) and the sibling `BehaviorSpec::validate`
526    /// value-shape gate consume — an open-coded field-access that
527    /// expressed no compile-time link back to the typed slot. A future
528    /// extension of the `:behavior :on-cast` axis to a richer author
529    /// surface — a per-tenant cast-callback override the M4 CR
530    /// materializer resolves per-CR, a per-cluster asynchronous-dispatch
531    /// callback overlay the `theory/ABSORPTION-ROADMAP.md` M2.5
532    /// wasm-engine callback-dispatch wire acknowledges, a
533    /// per-`nats:pub-sub`-subject cast-callback derivation the future
534    /// M3 mesh-slot NATS bridge computes from the sibling `:contratos`
535    /// M3 mesh-slot edges' `:subject` axis — would have had to be
536    /// threaded through the open-coded field-access in `declared_slots`
537    /// (the tag surface every per-slot diagnostic reads) or the
538    /// `declared_slots` iterator would silently disagree on which
539    /// callback a given [`BehaviorSpec`] resolves to. Lifting the
540    /// resolution to a typed method on the substrate primitive means
541    /// every downstream consumer of the Servico's per-`:behavior`
542    /// asynchronous-cast callback surface reaches for exactly one typed
543    /// dispatch — the resolver's accept-set migrates as a unit on any
544    /// future axis addition.
545    ///
546    /// Fourth `Option<&Path>`-return accessor on the M2 `:behavior` slot
547    /// family (sibling of the prior [`BehaviorSpec::on_state_change`]
548    /// 9b4ecde, [`BehaviorSpec::on_init`] d66c702, and
549    /// [`BehaviorSpec::on_call`] 156ddbe `Option<&Path>` accessors on
550    /// the peer per-`:behavior` `:on-state-change` / `:on-init` /
551    /// `:on-call` axes — same "one typed dispatch on the substrate
552    /// primitive, thin projections at each consumer" discipline extended
553    /// onto the peer per-`:behavior` `Option<PathBuf>` optional-scalar
554    /// axis; continues the "optional per-slot `Option<&Path>` scalar"
555    /// projection pattern the sibling per-`:behavior` `:on-info` /
556    /// `:on-terminate` future lifts fold on). Named `on_cast()` to match
557    /// the storage field's name; the accessor's identity name maps onto
558    /// the canonical `theory/INSPIRATIONS.md` §II.3 vocabulary the
559    /// slot's docstring already carries.
560    #[must_use]
561    pub fn on_cast(&self) -> Option<&Path> {
562        self.on_cast.as_deref()
563    }
564
565    /// Substrate-canonical per-`:behavior` `:on-info`
566    /// OTP-`gen_server:handle_info/2`-shaped system / out-of-band
567    /// message-handler callback-path scalar accessor every consumer of
568    /// the Servico's out-of-band-dispatch callback path keys off —
569    /// returns the author-declared `:behavior :on-info` typed callback
570    /// path verbatim as an `Option<&Path>`, borrowed from the typed
571    /// slot's own `Option<PathBuf>` storage. `None` when the slot is
572    /// absent (the canonical "no out-of-band-info callback declared —
573    /// the runtime silently drops every non-`:on-call` / non-`:on-cast`
574    /// mailbox message the wasm-engine's `gen_server`-shaped dispatcher
575    /// classifies as system / out-of-band (timeouts, downstream
576    /// `nodedown`, monitor `DOWN` signals, scheduler ticks, wasm-engine
577    /// `wasi:clocks` timer fires, adaptive-dispatch backpressure
578    /// notifications the M2.5 wasm-engine callback-dispatch wire emits
579    /// on peer-Servico circuit-open transitions) without any
580    /// author-supplied side-effect interposed" arm the M2.5 wasm-engine
581    /// callback-dispatch wire consults at every out-of-band mailbox
582    /// turn; peer of the sibling [`BehaviorSpec::on_init`] /
583    /// [`BehaviorSpec::on_call`] / [`BehaviorSpec::on_cast`] /
584    /// [`BehaviorSpec::on_state_change`] `None`-arm's "no
585    /// instance-start / synchronous-call / asynchronous-cast /
586    /// state-migration callback" semantic on the sibling axes).
587    ///
588    /// The `:behavior :on-info` slot carries the OTP
589    /// `gen_server:handle_info/2` callback contract (the module-level
590    /// [`BehaviorSpec::on_info`] docstring pins the analog verbatim:
591    /// "System / out-of-band message handler. Analog of
592    /// `gen_server:handle_info/2` — timeouts, downstream `nodedown`,
593    /// monitor signals, scheduler ticks."). Its position in the OTP
594    /// dispatch triad is the out-of-band half sibling to the
595    /// synchronous request/response `:on-call` and asynchronous
596    /// fire-and-forget `:on-cast` halves: the runtime routes every
597    /// mailbox message the `gen_server`-shaped dispatcher classifies as
598    /// neither a `:on-call` synchronous request (no reply-awaiting
599    /// caller) nor a `:on-cast` asynchronous WIT-typed edge (no peer
600    /// Servico originated the message via a declared `:contratos`
601    /// entry) through the callback; the callback runs to completion on
602    /// the actor's own mailbox turn with no reply-shape awaiting caller
603    /// and no peer-Servico dispatch semantics, and the runtime returns
604    /// to the mailbox-drain state as soon as the callback returns
605    /// (`theory/INSPIRATIONS.md` §II.3 — OTP `gen_server` behavior's
606    /// six-callback lifecycle, translated onto pleme-io's typed
607    /// `:behavior` slot family; `theory/CAIXA-SDLC.md` §I — the
608    /// author-surface pins `:on-info` as the fourth arm of the
609    /// `:behavior` overlay every Servico may declare, sibling to
610    /// `:on-cast` on the peer asynchronous-dispatch axis and
611    /// `:on-terminate` on the peer lifecycle-tail axis;
612    /// `theory/RUNTIME-PATTERNS.md` §II — the out-of-band-info pattern
613    /// the runtime realizes through this callback).
614    ///
615    /// Prior to this lift the `.on_info` field was accessed inline at
616    /// one production site — [`BehaviorSpec::declared_slots`]'s
617    /// `:on-info` arm's `self.on_info.as_ref()` map into the six-tuple
618    /// iterator that both the layout checker (existence sweep at
619    /// `layout.rs:900`) and the sibling `BehaviorSpec::validate`
620    /// value-shape gate consume — an open-coded field-access that
621    /// expressed no compile-time link back to the typed slot. A future
622    /// extension of the `:behavior :on-info` axis to a richer author
623    /// surface — a per-tenant info-callback override the M4 CR
624    /// materializer resolves per-CR, a per-cluster
625    /// out-of-band-dispatch callback overlay the
626    /// `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
627    /// callback-dispatch wire acknowledges, a per-monitor-signal
628    /// callback derivation the future adaptive-dispatch engine
629    /// computes from the sibling `:politicas :circuit-breaker` axis
630    /// (routing peer-Servico circuit-open notifications through the
631    /// info-callback the way Erlang routes `DOWN` messages through
632    /// `handle_info/2`) — would have had to be threaded through the
633    /// open-coded field-access in `declared_slots` (the tag surface
634    /// every per-slot diagnostic reads) or the `declared_slots`
635    /// iterator would silently disagree on which callback a given
636    /// [`BehaviorSpec`] resolves to. Lifting the resolution to a typed
637    /// method on the substrate primitive means every downstream
638    /// consumer of the Servico's per-`:behavior` out-of-band-info
639    /// callback surface reaches for exactly one typed dispatch — the
640    /// resolver's accept-set migrates as a unit on any future axis
641    /// addition.
642    ///
643    /// Fifth `Option<&Path>`-return accessor on the M2 `:behavior` slot
644    /// family (sibling of the prior [`BehaviorSpec::on_state_change`]
645    /// 9b4ecde, [`BehaviorSpec::on_init`] d66c702,
646    /// [`BehaviorSpec::on_call`] 156ddbe, and [`BehaviorSpec::on_cast`]
647    /// 99616ac `Option<&Path>` accessors on the peer per-`:behavior`
648    /// `:on-state-change` / `:on-init` / `:on-call` / `:on-cast` axes —
649    /// same "one typed dispatch on the substrate primitive, thin
650    /// projections at each consumer" discipline extended onto the peer
651    /// per-`:behavior` `Option<PathBuf>` optional-scalar axis;
652    /// continues the "optional per-slot `Option<&Path>` scalar"
653    /// projection pattern the last-remaining sibling per-`:behavior`
654    /// `:on-terminate` future lift folds on). Named `on_info()` to
655    /// match the storage field's name; the accessor's identity name
656    /// maps onto the canonical `theory/INSPIRATIONS.md` §II.3
657    /// vocabulary the slot's docstring already carries.
658    #[must_use]
659    pub fn on_info(&self) -> Option<&Path> {
660        self.on_info.as_deref()
661    }
662
663    /// Substrate-canonical per-`:behavior` `:on-terminate`
664    /// OTP-`gen_server:terminate/2`-shaped graceful-shutdown cleanup
665    /// callback-path scalar accessor every consumer of the Servico's
666    /// lifecycle-tail dispatch keys off — returns the author-declared
667    /// `:behavior :on-terminate` typed callback path verbatim as an
668    /// `Option<&Path>`, borrowed from the typed slot's own
669    /// `Option<PathBuf>` storage. `None` when the slot is absent (the
670    /// canonical "no terminate callback declared — the runtime tears
671    /// down the wasm instance without dispatching any author-supplied
672    /// cleanup side-effect, the Lunatic-per-process sandbox reclaims
673    /// every wasm32 linear-memory page + fuel budget the sibling
674    /// `:limits` axes accept-set caps, and every outstanding
675    /// `wasi:http/incoming-handler` / `wasi:keyvalue/store` / NATS
676    /// `nats:pub-sub` open handle the WIT-component-model closes the
677    /// wasm process's export-side at process-tear-down time is dropped
678    /// on the floor without any author-visible flush" arm the M2.5
679    /// wasm-engine callback-dispatch wire consults at every graceful
680    /// tear-down turn; peer of the sibling [`BehaviorSpec::on_init`] /
681    /// [`BehaviorSpec::on_call`] / [`BehaviorSpec::on_cast`] /
682    /// [`BehaviorSpec::on_info`] / [`BehaviorSpec::on_state_change`]
683    /// `None`-arm's "no instance-start / synchronous-call /
684    /// asynchronous-cast / out-of-band-info / state-migration
685    /// callback" semantic on the sibling axes).
686    ///
687    /// The `:behavior :on-terminate` slot carries the OTP
688    /// `gen_server:terminate/2` callback contract (the module-level
689    /// [`BehaviorSpec::on_terminate`] docstring pins the analog verbatim:
690    /// "Cleanup callback before the instance shuts down. Analog of
691    /// `gen_server:terminate/2`. Best-effort — runs only when the
692    /// instance terminates gracefully (not on hard kill)."). Its position
693    /// in the OTP lifecycle is the lifecycle-tail complement of the
694    /// `:on-init` head — the runtime instantiates the wasm process,
695    /// dispatches `:on-init`, dispatches every `:on-call` / `:on-cast`
696    /// / `:on-info` mailbox turn the instance accepts across its
697    /// lifetime, and only at graceful tear-down time (a supervisor's
698    /// `RestForOne` restart pass, a rolling wasm-engine hot-upgrade the
699    /// sibling `:upgrade-from` axis's appup instructions describe, an
700    /// operator-driven Aplicacao teardown the M4 CR materializer emits
701    /// as a Kubernetes deletion event, a per-tenant per-`:placement`
702    /// evict the future adaptive-placement engine computes on
703    /// per-cluster capacity pressure) dispatches the terminate callback
704    /// (`theory/INSPIRATIONS.md` §II.3 — OTP `gen_server` behavior's
705    /// six-callback lifecycle, translated onto pleme-io's typed
706    /// `:behavior` slot family; `theory/CAIXA-SDLC.md` §I — the
707    /// author-surface pins `:on-terminate` as the sixth and final arm
708    /// of the `:behavior` overlay every Servico may declare, sibling to
709    /// `:on-init` on the peer lifecycle-head axis and `:on-state-change`
710    /// on the peer hot-upgrade-composition axis;
711    /// `theory/RUNTIME-PATTERNS.md` §II — the graceful-tear-down cleanup
712    /// pattern the runtime realizes through this callback). The
713    /// callback runs to completion on the actor's own mailbox turn
714    /// before the runtime returns the wasm process's resources to the
715    /// wasm-engine pool; the Lunatic-per-process sandbox guarantees the
716    /// callback cannot exceed the sibling `:limits :wall-clock` axis's
717    /// per-call cap, so a runaway cleanup path cannot wedge the
718    /// tear-down (the caller receives the same
719    /// `LimitsError::WallClockExceeded`-shaped runtime diagnostic the
720    /// sibling `:on-*` dispatch arms surface on the peer cap-exceed
721    /// path). The "best-effort — runs only when the instance terminates
722    /// gracefully (not on hard kill)" clause of the module-level
723    /// docstring is the OTP `terminate/2` clause verbatim: the runtime
724    /// dispatches the callback on every controlled tear-down but never
725    /// on `EXIT`-kill / `SIGKILL` / wasm-engine OOM eviction / fuel
726    /// starvation cap-exceed.
727    ///
728    /// Prior to this lift the `.on_terminate` field was accessed inline
729    /// at one production site — [`BehaviorSpec::declared_slots`]'s
730    /// `:on-terminate` arm's `self.on_terminate.as_ref()` map into the
731    /// six-tuple iterator that both the layout checker (existence sweep
732    /// at `layout.rs:900`) and the sibling `BehaviorSpec::validate`
733    /// value-shape gate consume — an open-coded field-access that
734    /// expressed no compile-time link back to the typed slot. A future
735    /// extension of the `:behavior :on-terminate` axis to a richer
736    /// author surface — a per-tenant terminate-callback override the
737    /// M4 CR materializer resolves per-CR, a per-cluster
738    /// graceful-tear-down callback overlay the
739    /// `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
740    /// callback-dispatch wire acknowledges, a per-supervisor
741    /// terminate-callback derivation the future adaptive-supervision
742    /// engine computes from the sibling `:estrategia` restart-strategy
743    /// axis (routing a `RestForOne` cascade's per-child terminate
744    /// through the callback the way Erlang routes `terminate/2` before
745    /// each `restart_child/2` retry), a per-`:upgrade-from` version
746    /// migration terminate-callback the future rolling hot-upgrade
747    /// engine computes from the sibling `:upgrade-from` instruction
748    /// chain (dispatching the terminate callback with the outgoing
749    /// version's state before the sibling `:on-state-change` callback
750    /// folds it into the incoming version's shape) — would have had to
751    /// be threaded through the open-coded field-access in
752    /// `declared_slots` (the tag surface every per-slot diagnostic
753    /// reads) or the `declared_slots` iterator would silently disagree
754    /// on which callback a given [`BehaviorSpec`] resolves to. Lifting
755    /// the resolution to a typed method on the substrate primitive
756    /// means every downstream consumer of the Servico's per-`:behavior`
757    /// graceful-tear-down callback surface reaches for exactly one
758    /// typed dispatch — the resolver's accept-set migrates as a unit on
759    /// any future axis addition.
760    ///
761    /// Sixth and final `Option<&Path>`-return accessor on the M2
762    /// `:behavior` slot family (sibling of the prior
763    /// [`BehaviorSpec::on_state_change`] 9b4ecde,
764    /// [`BehaviorSpec::on_init`] d66c702, [`BehaviorSpec::on_call`]
765    /// 156ddbe, [`BehaviorSpec::on_cast`] 99616ac, and
766    /// [`BehaviorSpec::on_info`] 4846cef `Option<&Path>` accessors on
767    /// the peer per-`:behavior` `:on-state-change` / `:on-init` /
768    /// `:on-call` / `:on-cast` / `:on-info` axes — same "one typed
769    /// dispatch on the substrate primitive, thin projections at each
770    /// consumer" discipline extended onto the peer per-`:behavior`
771    /// `Option<PathBuf>` optional-scalar axis; closes the last
772    /// unlifted per-`:behavior` `Option<&Path>` scalar-value axis, so
773    /// every arm of the six-callback OTP `gen_server` lifecycle the
774    /// slot family models now routes through one typed dispatch on the
775    /// substrate primitive). Named `on_terminate()` to match the
776    /// storage field's name; the accessor's identity name maps onto the
777    /// canonical `theory/INSPIRATIONS.md` §II.3 vocabulary the slot's
778    /// docstring already carries.
779    #[must_use]
780    pub fn on_terminate(&self) -> Option<&Path> {
781        self.on_terminate.as_deref()
782    }
783
784    /// Reject operationally-meaningless callback path values on every
785    /// declared slot. Each slot remains optional — omitting a field
786    /// expresses "fall back to the runtime default callback"; the bug
787    /// being closed is *carrying* a foot-shaped path value, which the
788    /// layout checker's `root.join(p)` would either silently treat as
789    /// the project root (`PathBuf::new()`), escape the project root
790    /// (absolute path replaces `root` per `Path::join` semantics), or
791    /// traverse out of the root via `..` components.
792    ///
793    /// Four invariants per slot, evaluated in declaration order
794    /// (`:on-init` → `:on-call` → `:on-cast` → `:on-info` →
795    /// `:on-state-change` → `:on-terminate`) so the diagnostic for
796    /// multi-malformed manifests is deterministic:
797    ///
798    ///   - non-empty path string,
799    ///   - relative path (Lunatic-style sandbox: callbacks live under
800    ///     the caixa root, never in `/etc/...`),
801    ///   - no `..` components (relative paths must not escape the
802    ///     caixa root via parent-directory traversal),
803    ///   - terminating `.lisp` extension (the wasm-engine reads every
804    ///     callback path as tatara-lisp source — a `.txt` / `.rs` /
805    ///     `.lisp.bak` / no-extension shape is structurally a parser
806    ///     error at instance-start time, far from the source
807    ///     caixa.lisp).
808    ///
809    /// Mirrors the discipline applied to `:limits` axes
810    /// (`LimitsSpec::validate`) and to the M3 mesh `:entrada :paths`
811    /// invariants (`AplicacaoSpec::validate`) — every typed value
812    /// carried by a slot is either absent or value-shape valid.
813    pub fn validate(&self) -> Result<(), BehaviorError> {
814        for (slot, path) in self.declared_slots() {
815            validate_callback_path(slot, path)?;
816        }
817        Ok(())
818    }
819}
820
821fn validate_callback_path(slot: &'static str, path: &Path) -> Result<(), BehaviorError> {
822    // Delegate the four-arm cascade (empty / absolute / parent-escape /
823    // non-`.lisp`-extension) to the lifted
824    // [`crate::render::require_sandboxed_lisp_path`] helper — same
825    // `Empty → Absolute → ParentEscape → NonLispExtension` arm-ordering
826    // this function previously inlined verbatim, now shared with
827    // [`crate::UpgradeInstruction::validate`]'s `StateChange` arm so
828    // every author-supplied tatara-lisp source path on every M2 typed
829    // slot consults one gate, not two-and-counting verbatim copies of
830    // the same four-arm cascade. Each closure wraps the tag in the
831    // same per-slot `BehaviorError` variant the original inline code
832    // raised, so the diagnostic shape every caller depends on (the
833    // per-slot diagnostic naming `:behavior :on-init`, etc., with the
834    // offending `path` threaded through each non-`Empty` arm) is
835    // preserved by construction. See
836    // [`crate::render::require_sandboxed_lisp_path`] for the
837    // smallest-scope-arm-fires-last ordering rationale and the
838    // three-path drift-detection posture the helper's docstring pins.
839    crate::render::require_sandboxed_lisp_path(
840        path,
841        || BehaviorError::empty_path(slot),
842        || BehaviorError::absolute_path(slot, path),
843        || BehaviorError::parent_escape(slot, path),
844        || BehaviorError::non_lisp_extension(slot, path),
845    )
846}
847
848#[derive(Debug, Error, PartialEq, Eq)]
849pub enum BehaviorError {
850    #[error(
851        ":behavior {slot} path is empty (omit the slot to fall back to the runtime default \
852         callback; do not declare an empty path)"
853    )]
854    EmptyPath { slot: &'static str },
855    #[error(
856        ":behavior {slot} path {} is absolute — callbacks must be relative to the caixa root, \
857         since the layout checker's `root.join(p)` would otherwise escape the project sandbox \
858         (Path::join replaces the base with an absolute right-hand side)",
859        path.display()
860    )]
861    AbsolutePath { slot: &'static str, path: PathBuf },
862    #[error(
863        ":behavior {slot} path {} contains a `..` component — callbacks must not traverse \
864         above the caixa root",
865        path.display()
866    )]
867    ParentEscape { slot: &'static str, path: PathBuf },
868    #[error(
869        ":behavior {slot} path {} does not terminate in the `.lisp` extension — the M2.5 \
870         wasm-engine instantiator reads every callback path as tatara-lisp source through \
871         `tatara_lisp::read` at instance-start time, so any other extension (`.txt`, `.rs`, \
872         `.lisp.bak`) or no-extension shape is structurally a parser error far from the \
873         source caixa.lisp, with no field naming the offending `:on-*` slot. Pin a \
874         relative path under the caixa root whose terminating extension is \
875         lowercase-`.lisp` (e.g. `\"lib/init.lisp\"`, `\"lib/handlers.lisp\"`, \
876         `\"lib/migrations/v01-to-v02.lisp\"`) or omit the slot to fall back to the \
877         runtime default callback",
878        path.display()
879    )]
880    NonLispExtension { slot: &'static str, path: PathBuf },
881}
882
883// Fold the three `BehaviorError::{AbsolutePath, ParentEscape,
884// NonLispExtension} { slot, path: path.to_path_buf() }` two-slot
885// struct-variant wire-up sites at [`validate_callback_path`]'s three
886// closures passed to [`crate::render::require_sandboxed_lisp_path`]
887// onto one substrate primitive per typed variant — the paired
888// `{ slot: &'static str, path: PathBuf }` two-slot family on
889// [`BehaviorError`], sibling on the M2 `:behavior` envelope of the peer
890// [`crate::upgrade::upgrade_from_script_ctors!`] (8e67041, 3 variants
891// on `{ from: String, script: PathBuf }`) two-slot family on the sibling
892// M2 `:upgrade-from` envelope, the peer
893// [`crate::upgrade::upgrade_script_only_ctors!`] (7468ca9, 3 variants
894// on `{ script: PathBuf }`) one-slot family that closed the second fold
895// on that sibling envelope, the peer
896// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
897// variants on `{ caixa: String }`) single-slot family on the sibling
898// `SupervisorError` envelope, the peer [`crate::dep::dep_nome_only_ctors!`]
899// (792aa92, 5 variants on `{ nome: String }`) and
900// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
901// `{ nome, caminho }`) / [`crate::dep::fonte_caminho_byte_ctors!`]
902// (0e35793, 12 variants on `{ nome, caminho, byte }`) families on the
903// sibling `DepError` envelope, the peer
904// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
905// on `{ de, para }`) / [`crate::aplicacao::contrato_target_ctors!`]
906// (14b81d5, 2 variants on `{ de, para, wit, expected }`) /
907// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
908// variants on `{ <field>: String, reason: String }`) /
909// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
910// variants on `{ de, para, <field>: String, reason: String }`) families
911// on the sibling `AplicacaoError` envelopes, the peer four `LayoutError`
912// families ([`crate::layout::layout_violation_ctors!`] 131ca0d, 16
913// variants on `{ caixa, issue }`; [`crate::layout::layout_slot_kind_ctors!`]
914// 0419438, 4 variants on `{ caixa, kind, slots }`;
915// [`crate::LayoutError::missing_entry`] 1b09f9d, 1 variant on
916// `{ kind, path }`; [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7,
917// 6 variants on `<Variant>(String)`), and the three
918// [`crate::limits::limits_codec_value_*_ctors!`] codec families (81c856c,
919// 12 codec wire-ups on `LimitsError`).
920//
921// Each of the three wire-up sites on this shape (`AbsolutePath` at the
922// per-slot absolute-path arm, `ParentEscape` at the per-slot `..`-escape
923// arm, `NonLispExtension` at the per-slot terminating-extension arm)
924// opened the identical `BehaviorError::<Variant> { slot,
925// path: path.to_path_buf() }` four-line struct-literal against the same
926// `(slot: &'static str, path: &Path)` closure-captured pair — the exact
927// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
928// names as a bug, on the same altitude the peer `UpgradeError` /
929// `SupervisorError` / `DepError` / `AplicacaoError` / `LayoutError` /
930// `LimitsError` families each closed on their sibling envelopes. The
931// three variants share one `{ slot: &'static str, path: PathBuf }`
932// shape, so the fold routes each closure through one dispatch per typed
933// variant. The sibling `EmptyPath` variant on the same envelope stays
934// on its pre-lift open-coded shape — it carries no `path` field (the
935// offending `:on-*` path value *is* the empty path this variant
936// catches), so the uniform `fn(slot: &'static str, path: &Path) -> Self`
937// signature this macro promises does not apply, and the peer helper's
938// `|| Self::EmptyPath { slot }` closure is already a one-liner. This
939// closes the first (and, given the four-variant envelope's `EmptyPath`
940// one-liner remainder, only-populated) fold family on the `BehaviorError`
941// envelope, sibling of the two folds on the peer `UpgradeError` envelope
942// established at 8e67041 (two-slot `{ from, script }`) and 7468ca9
943// (one-slot `{ script }`).
944//
945// The macro below generates one `#[must_use]` inherent constructor per
946// variant of shape `fn <ctor>(slot: &'static str, path: &std::path::Path)
947// -> Self`, so every closure collapses onto one dispatch:
948// `BehaviorError::<ctor>(slot, path)`, byte-equal to the pre-lift
949// struct-literal on the same `(&'static str, &Path)` fixture. The
950// uniform two-field construction (`slot` verbatim as `&'static str`,
951// `path.to_path_buf()`) is spelled once — inside the macro — rather
952// than at every wire-up site. The `slot` parameter stays `&'static str`
953// (not `&str`) so every arm continues to carry a program-lifetime
954// M2 `:behavior :on-*` author-key label routed through the
955// [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] const roster, matching the
956// enum-field type and the [`BehaviorSpec::declared_slots`] iterator's
957// per-arm slot axis — a runtime-borrowed `&str` would silently downgrade
958// the label lifetime and let a caller stash a non-`'static` borrow into
959// the returned error. The `&Path` parameter accepts both `&Path` and
960// `&PathBuf` (via Deref coercion), so every existing closure — each
961// captures `path: &Path` from the outer [`validate_callback_path`]
962// signature — threads through the ctor without a pre-conversion.
963//
964// Every future consumer that wants to construct one of these three
965// variants outside the three in-crate closures (a deferred wasm-engine
966// per-slot callback-shape re-checker at instance-start time re-consulting
967// the same four-arm sandboxed-lisp-path cascade the closures already
968// share via [`crate::render::require_sandboxed_lisp_path`], a future
969// `feira validate --behavior` per-caixa admission verb re-checking each
970// declared `:behavior :on-*` slot's path shape against the same axis, a
971// per-`Caixa` overlay resolver rejecting an author-supplied `:behavior
972// :on-*` path against a cluster-local snapshot) now reaches each variant
973// through one call rather than re-inlining the four-line struct-literal
974// in lockstep with the three in-crate closure sites.
975macro_rules! behavior_slot_path_ctors {
976    ($($ctor:ident => $variant:ident),* $(,)?) => {
977        impl BehaviorError {
978            $(
979                #[doc = concat!(
980                    "Construct a [`BehaviorError::",
981                    stringify!($variant),
982                    "`] naming the offending `:behavior :on-*` slot ",
983                    "label and callback `path`. Folds the uniform ",
984                    "`Self::",
985                    stringify!($variant),
986                    " { slot, path: path.to_path_buf() }` two-field ",
987                    "struct-literal onto one substrate primitive so ",
988                    "every closure passed to ",
989                    "[`crate::render::require_sandboxed_lisp_path`] at ",
990                    "[`validate_callback_path`] on this variant reads ",
991                    "through one dispatch rather than the pre-lift ",
992                    "four-line open-coded block. The `slot` label ",
993                    "threads verbatim from ",
994                    "[`BehaviorSpec::declared_slots`] and the `path` ",
995                    "from the same iterator at the call site."
996                )]
997                #[must_use]
998                pub fn $ctor(slot: &'static str, path: &std::path::Path) -> Self {
999                    Self::$variant {
1000                        slot,
1001                        path: path.to_path_buf(),
1002                    }
1003                }
1004            )*
1005        }
1006    };
1007}
1008
1009behavior_slot_path_ctors! {
1010    absolute_path => AbsolutePath,
1011    parent_escape => ParentEscape,
1012    non_lisp_extension => NonLispExtension,
1013}
1014
1015// Fold the last `BehaviorError::EmptyPath { slot: <&'static str> }` single-
1016// slot struct-variant wire-up site at [`validate_callback_path`]'s empty-path
1017// arm closure passed to [`crate::render::require_sandboxed_lisp_path`] onto
1018// one substrate primitive on `BehaviorError` — the last open-coded single-
1019// slot `{ slot: &'static str }` struct-literal on the `BehaviorError`
1020// envelope, matching the peer three-variant [`behavior_slot_path_ctors!`]
1021// family fold (b0c8389, 3 variants on `{ slot: &'static str, path: PathBuf }`)
1022// already closed on the sibling two-slot envelope of the same `BehaviorError`.
1023// After this lift every wire-up on every `BehaviorError` variant carried by
1024// [`validate_callback_path`] reads through one substrate-primitive ctor
1025// dispatch per typed variant rather than one macro closing three sites plus a
1026// hand-written empty-path closure open-coding the fourth.
1027//
1028// A macro is not warranted on the one-variant envelope shape
1029// `{ slot: &'static str }` — unlike the peer three-variant
1030// `{ slot: &'static str, path: PathBuf }` shape the [`behavior_slot_path_ctors!`]
1031// macro closes — but the same substrate-primitive discipline applies: every
1032// future consumer that wants to construct an `EmptyPath` outside
1033// [`validate_callback_path`] (a deferred wasm-engine per-slot callback-shape
1034// re-checker at instance-start time re-consulting the same four-arm
1035// sandboxed-lisp-path cascade the closure already shares via
1036// [`crate::render::require_sandboxed_lisp_path`], a future
1037// `feira validate --behavior` per-caixa admission verb re-checking each
1038// declared `:behavior :on-*` slot's path shape against the same axis, a
1039// per-`Caixa` overlay resolver rejecting an author-supplied empty
1040// `:behavior :on-*` path against a cluster-local snapshot) reaches the
1041// variant through one call rather than re-inlining the one-line struct-
1042// literal in lockstep with the in-crate closure site.
1043//
1044// The `slot` parameter stays `&'static str` (not `&str`) so the constructor
1045// continues to carry a program-lifetime M2 `:behavior :on-*` author-key label
1046// routed through the [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] const roster,
1047// matching the enum-field type, the [`BehaviorSpec::declared_slots`]
1048// iterator's per-arm slot axis, and the peer
1049// [`behavior_slot_path_ctors!`]-generated arms' `slot: &'static str`
1050// parameter verbatim — a runtime-borrowed `&str` would silently downgrade the
1051// label lifetime and let a caller stash a non-`'static` borrow into the
1052// returned error. `const fn` preserves the zero-runtime-work property of the
1053// pre-lift struct-literal verbatim, matching the sibling
1054// [`crate::supervisor::supervisor_scalar_ctors!`] / peer
1055// [`crate::aplicacao::aplicacao_policy_scalar_ctors!`] `Copy`-scalar
1056// discipline on their sibling envelopes.
1057impl BehaviorError {
1058    /// Construct a [`BehaviorError::EmptyPath`] naming the offending
1059    /// `:behavior :on-*` slot label. Folds the uniform
1060    /// `Self::EmptyPath { slot }` one-field struct-literal onto one
1061    /// substrate primitive so the closure passed to
1062    /// [`crate::render::require_sandboxed_lisp_path`] at
1063    /// [`validate_callback_path`] on this variant reads through one
1064    /// dispatch rather than the pre-lift open-coded struct-literal
1065    /// block. Peer of the sibling
1066    /// [`BehaviorError::absolute_path`] /
1067    /// [`BehaviorError::parent_escape`] /
1068    /// [`BehaviorError::non_lisp_extension`] ctors the
1069    /// [`behavior_slot_path_ctors!`] macro closed on the paired two-slot
1070    /// `{ slot: &'static str, path: PathBuf }` envelope of the same
1071    /// `BehaviorError` — the four-arm sandboxed-lisp-path cascade at
1072    /// [`validate_callback_path`] now routes every arm through one
1073    /// substrate-primitive ctor per typed variant.
1074    #[must_use]
1075    pub const fn empty_path(slot: &'static str) -> Self {
1076        Self::EmptyPath { slot }
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use crate::render::{
1084        M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
1085        M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
1086        M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1087    };
1088
1089    #[test]
1090    fn empty_behavior_round_trip() {
1091        let b = BehaviorSpec::default();
1092        assert!(b.is_empty());
1093        let json = serde_json::to_string(&b).unwrap();
1094        assert_eq!(json, "{}");
1095        let back: BehaviorSpec = serde_json::from_str("{}").unwrap();
1096        assert_eq!(back, b);
1097    }
1098
1099    #[test]
1100    fn full_behavior_round_trip_through_json() {
1101        let b = BehaviorSpec {
1102            on_init: Some(PathBuf::from("lib/init.lisp")),
1103            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1104            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1105            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1106            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1107            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1108        };
1109        let json = serde_json::to_string(&b).unwrap();
1110        let back: BehaviorSpec = serde_json::from_str(&json).unwrap();
1111        assert_eq!(b, back);
1112    }
1113
1114    #[test]
1115    fn partial_behavior_keeps_explicit_fields() {
1116        let b = BehaviorSpec {
1117            on_init: Some(PathBuf::from("lib/init.lisp")),
1118            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1119            ..Default::default()
1120        };
1121        assert!(!b.is_empty());
1122        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
1123        assert_eq!(paths.len(), 2);
1124        assert!(paths.contains(&PathBuf::from("lib/init.lisp")));
1125        assert!(paths.contains(&PathBuf::from("lib/handlers.lisp")));
1126    }
1127
1128    #[test]
1129    fn declared_paths_skips_none() {
1130        let b = BehaviorSpec {
1131            on_init: Some(PathBuf::from("a.lisp")),
1132            on_terminate: Some(PathBuf::from("b.lisp")),
1133            ..Default::default()
1134        };
1135        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
1136        assert_eq!(
1137            paths,
1138            vec![PathBuf::from("a.lisp"), PathBuf::from("b.lisp")]
1139        );
1140    }
1141
1142    #[test]
1143    fn json_keys_are_camelcase() {
1144        let b = BehaviorSpec {
1145            on_init: Some(PathBuf::from("init.lisp")),
1146            on_state_change: Some(PathBuf::from("mig.lisp")),
1147            ..Default::default()
1148        };
1149        let json = serde_json::to_string(&b).unwrap();
1150        assert!(json.contains("\"onInit\""));
1151        assert!(json.contains("\"onStateChange\""));
1152        assert!(!json.contains("\"on_init\""));
1153    }
1154
1155    #[test]
1156    fn deserialize_accepts_camelcase() {
1157        let json = r#"{"onInit":"a.lisp","onTerminate":"b.lisp"}"#;
1158        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
1159        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
1160        assert_eq!(b.on_terminate, Some(PathBuf::from("b.lisp")));
1161    }
1162
1163    // ── drift-detection: serde-derive-to-M2_BEHAVIOR_KEY_ON_* identity ────
1164
1165    #[test]
1166    fn behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts() {
1167        // Load-bearing invariant: the six `M2_BEHAVIOR_KEY_ON_*` consts
1168        // (`M2_BEHAVIOR_KEY_ON_INIT` / `M2_BEHAVIOR_KEY_ON_CALL` /
1169        // `M2_BEHAVIOR_KEY_ON_CAST` / `M2_BEHAVIOR_KEY_ON_INFO` /
1170        // `M2_BEHAVIOR_KEY_ON_STATE_CHANGE` /
1171        // `M2_BEHAVIOR_KEY_ON_TERMINATE`) name the exact camelCase JSON
1172        // keys the `#[serde(rename_all = "camelCase")]` attribute on
1173        // `BehaviorSpec` emits, and every test-side probe across the
1174        // caixa-core / caixa-flux / caixa-helm renderer test fixtures
1175        // navigates into the rendered `:behavior` overlay sub-block by
1176        // consulting one of these six `&'static str`s. Serialize a
1177        // fully-populated BehaviorSpec and pin that each canonical
1178        // byte-sequence appears verbatim in the JSON — a future
1179        // accidental `rename_all = "snake_case"` / `"kebab-case"` /
1180        // verbatim-field-name flip at the derive attribute (any of
1181        // which would silently break every test-side probe that reaches
1182        // for one of the six consts) surfaces here as a build-time test
1183        // failure at `behavior.rs`, not as an apply-time
1184        // `.get(<stale-canonical-const>)` returning `None` far from the
1185        // derive-attr drift's commit. Same discipline the sibling
1186        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
1187        // pin (d8b8b4f) established on the peer `:limits` sub-slot
1188        // axis: one canonical byte-string per typed sub-key axis,
1189        // pinned to the load-bearing serde derivation at the type
1190        // itself.
1191        let b = BehaviorSpec {
1192            on_init: Some(PathBuf::from("lib/init.lisp")),
1193            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1194            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1195            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1196            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1197            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1198        };
1199        let json = serde_json::to_string(&b).unwrap();
1200        for key in [
1201            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1202            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1203            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1204            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1205            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1206            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1207        ] {
1208            let quoted = format!("\"{key}\"");
1209            assert!(
1210                json.contains(&quoted),
1211                "serialized BehaviorSpec must carry the lifted \
1212                 M2_BEHAVIOR_KEY_ON_* byte-sequence {quoted} verbatim \
1213                 in the JSON emission (got: {json})",
1214            );
1215        }
1216    }
1217
1218    #[test]
1219    fn m2_behavior_key_consts_are_pairwise_distinct() {
1220        // Cross-axis drift-detection pin: a future collapse of two
1221        // canonical sub-key byte-strings onto the same value (e.g. an
1222        // accidental copy-paste flip of `M2_BEHAVIOR_KEY_ON_CAST` to
1223        // also read `"onCall"`) would silently reroute every test-side
1224        // probe on one axis onto the sibling axis's overlay entry and
1225        // pass every propagation-probe test that expected only the
1226        // stale axis's value. Peer of `m2_limits_key_consts_are_
1227        // pairwise_distinct` (d8b8b4f) on the sibling `:limits`
1228        // sub-slot axis.
1229        let all = [
1230            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1231            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1232            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1233            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1234            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1235            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1236        ];
1237        for (i, a) in all.iter().enumerate() {
1238            for b in all.iter().skip(i + 1) {
1239                assert_ne!(
1240                    a, b,
1241                    "M2_BEHAVIOR_KEY_ON_* consts must be pairwise-distinct \
1242                     canonical byte-sequences — got `{a}` == `{b}`",
1243                );
1244            }
1245        }
1246    }
1247
1248    #[test]
1249    fn m2_behavior_key_consts_are_lower_camel_case_shape() {
1250        // Shape-pin: every `M2_BEHAVIOR_KEY_ON_*` const must be a
1251        // lowerCamelCase byte-sequence (no `snake_case` underscores,
1252        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
1253        // whitespace / colons / dots) — the canonical shape the
1254        // `#[serde(rename_all = "camelCase")]` derive produces on
1255        // `BehaviorSpec`. A future flip to a non-camelCase attribute
1256        // at the derive surfaces both here (this test fails on the
1257        // stale-constant shape) and at
1258        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
1259        // (that test fails on the mismatch between const and derive).
1260        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
1261        // (d8b8b4f) on the sibling `:limits` sub-slot axis.
1262        for key in [
1263            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1264            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1265            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1266            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1267            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1268            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1269        ] {
1270            assert!(
1271                !key.is_empty(),
1272                "M2_BEHAVIOR_KEY_ON_* must be non-empty (got {key:?})"
1273            );
1274            let first = key.chars().next().unwrap();
1275            assert!(
1276                first.is_ascii_lowercase(),
1277                "M2_BEHAVIOR_KEY_ON_* must lead with an ASCII-lowercase \
1278                 byte (got {key:?}, leads with {first:?})",
1279            );
1280            assert!(
1281                key.chars().all(|c| c.is_ascii_alphanumeric()),
1282                "M2_BEHAVIOR_KEY_ON_* must be ASCII-alphanumeric only \
1283                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn deserialize_omits_unknown_fields_via_default() {
1290        // Forward-compatible: a future caixa.lisp with extra fields
1291        // round-trips without losing the known ones.
1292        let json = r#"{"onInit":"a.lisp"}"#;
1293        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
1294        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
1295        assert!(b.on_call.is_none());
1296    }
1297
1298    // ── value-shape invariants on declared callback paths ──────────
1299
1300    #[test]
1301    fn validate_default_is_ok() {
1302        BehaviorSpec::default().validate().unwrap();
1303    }
1304
1305    #[test]
1306    fn validate_every_slot_relative_is_ok() {
1307        let b = BehaviorSpec {
1308            on_init: Some(PathBuf::from("lib/init.lisp")),
1309            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1310            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1311            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1312            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1313            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1314        };
1315        b.validate().unwrap();
1316    }
1317
1318    #[test]
1319    fn validate_rejects_empty_path_per_slot() {
1320        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1321            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1322                on_init: Some(p),
1323                ..Default::default()
1324            }),
1325            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1326                on_call: Some(p),
1327                ..Default::default()
1328            }),
1329            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1330                on_cast: Some(p),
1331                ..Default::default()
1332            }),
1333            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1334                on_info: Some(p),
1335                ..Default::default()
1336            }),
1337            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1338                on_state_change: Some(p),
1339                ..Default::default()
1340            }),
1341            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1342                on_terminate: Some(p),
1343                ..Default::default()
1344            }),
1345        ];
1346        for (expected_slot, build) in cases {
1347            let err = build(PathBuf::new()).validate().unwrap_err();
1348            assert!(
1349                matches!(err, BehaviorError::EmptyPath { slot } if slot == expected_slot),
1350                "slot {expected_slot}: got {err:?}",
1351            );
1352        }
1353    }
1354
1355    #[test]
1356    fn validate_rejects_absolute_path() {
1357        let b = BehaviorSpec {
1358            on_init: Some(PathBuf::from("/etc/passwd")),
1359            ..Default::default()
1360        };
1361        let err = b.validate().unwrap_err();
1362        assert!(matches!(
1363            err,
1364            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1365        ));
1366    }
1367
1368    #[test]
1369    fn validate_rejects_parent_escape() {
1370        let b = BehaviorSpec {
1371            on_state_change: Some(PathBuf::from("../sibling/migrations.lisp")),
1372            ..Default::default()
1373        };
1374        let err = b.validate().unwrap_err();
1375        assert!(matches!(
1376            err,
1377            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE
1378        ));
1379    }
1380
1381    #[test]
1382    fn validate_rejects_parent_escape_mid_path() {
1383        // `lib/../../escaped.lisp` is still a parent-traversal — must
1384        // be caught regardless of where the `..` component sits.
1385        let b = BehaviorSpec {
1386            on_terminate: Some(PathBuf::from("lib/../../escaped.lisp")),
1387            ..Default::default()
1388        };
1389        let err = b.validate().unwrap_err();
1390        assert!(matches!(
1391            err,
1392            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE
1393        ));
1394    }
1395
1396    #[test]
1397    fn validate_diagnostic_order_is_deterministic() {
1398        // Multiple bad slots — the first declared (`:on-init`) wins
1399        // so authors see a stable, single-slot diagnostic.
1400        let b = BehaviorSpec {
1401            on_init: Some(PathBuf::new()),
1402            on_call: Some(PathBuf::from("/etc/passwd")),
1403            on_terminate: Some(PathBuf::from("../escape.lisp")),
1404            ..Default::default()
1405        };
1406        let err = b.validate().unwrap_err();
1407        assert!(matches!(
1408            err,
1409            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1410        ));
1411    }
1412
1413    // ── `.lisp`-extension gate on every `:behavior :on-*` axis ─────
1414
1415    #[test]
1416    fn validate_rejects_non_lisp_extension_per_slot() {
1417        // Loop the same offending non-`.lisp` path through every M2
1418        // typed `:behavior` slot — the diagnostic must name the
1419        // offending slot, not collapse to a generic "bad extension"
1420        // shape. Same per-slot diagnostic posture every peer
1421        // `BehaviorError` arm carries (`EmptyPath`, `AbsolutePath`,
1422        // `ParentEscape`).
1423        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1424            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1425                on_init: Some(p),
1426                ..Default::default()
1427            }),
1428            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1429                on_call: Some(p),
1430                ..Default::default()
1431            }),
1432            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1433                on_cast: Some(p),
1434                ..Default::default()
1435            }),
1436            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1437                on_info: Some(p),
1438                ..Default::default()
1439            }),
1440            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1441                on_state_change: Some(p),
1442                ..Default::default()
1443            }),
1444            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1445                on_terminate: Some(p),
1446                ..Default::default()
1447            }),
1448        ];
1449        let path = PathBuf::from("lib/init.txt");
1450        for (expected_slot, build) in cases {
1451            let err = build(path.clone()).validate().unwrap_err();
1452            assert!(
1453                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1454                    if *slot == expected_slot && p == &path),
1455                "slot {expected_slot}: got {err:?}",
1456            );
1457        }
1458    }
1459
1460    #[test]
1461    fn validate_rejects_no_extension() {
1462        // The no-extension shape — author dropped the suffix entirely
1463        // (`lib/init` instead of `lib/init.lisp`). `Path::extension`
1464        // returns None, so the typed check distinguishes this from
1465        // the wrong-extension shape and from a leading-dot file like
1466        // `.lisp` (which also has no `Path::extension`).
1467        let cases = [
1468            PathBuf::from("lib/init"),
1469            PathBuf::from("lib/handlers"),
1470            PathBuf::from("init"),
1471        ];
1472        for path in cases {
1473            let b = BehaviorSpec {
1474                on_init: Some(path.clone()),
1475                ..Default::default()
1476            };
1477            let err = b.validate().unwrap_err();
1478            assert!(
1479                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1480                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1481                "no-extension path {path:?}: got {err:?}",
1482            );
1483        }
1484    }
1485
1486    #[test]
1487    fn validate_rejects_wrong_extension() {
1488        // Common authoring footguns — files that pass every prior
1489        // path-shape gate but that the wasm-engine's tatara-lisp
1490        // reader cannot consume as source.
1491        let cases = [
1492            PathBuf::from("lib/init.rs"),
1493            PathBuf::from("lib/init.txt"),
1494            PathBuf::from("lib/init.md"),
1495            PathBuf::from("lib/init.json"),
1496            PathBuf::from("lib/init.yaml"),
1497            PathBuf::from("lib/init.lisp.bak"),
1498            PathBuf::from("lib/init.lispx"),
1499        ];
1500        for path in cases {
1501            let b = BehaviorSpec {
1502                on_call: Some(path.clone()),
1503                ..Default::default()
1504            };
1505            let err = b.validate().unwrap_err();
1506            assert!(
1507                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1508                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_CALL && p == &path),
1509                "wrong-extension path {path:?}: got {err:?}",
1510            );
1511        }
1512    }
1513
1514    #[test]
1515    fn validate_rejects_uppercase_lisp_extension() {
1516        // The strict-lowercase posture matches every other shape
1517        // predicate in `render.rs` — the byte-size codec is
1518        // case-sensitive on `MiB`, the duration codec on `ms` / `s` /
1519        // `m` / `h`, every DNS-1123 label is lowercase-only — so the
1520        // accepted set for `.lisp` does not silently fold to `.LISP` /
1521        // `.Lisp` / `.LiSp` even on case-insensitive volumes. A path
1522        // whose existence check would match the on-disk file via
1523        // case-insensitive lookup would still mismatch the canonical
1524        // form the codec emits, breaking the round-trip-stability
1525        // contract.
1526        let cases = [
1527            PathBuf::from("lib/init.LISP"),
1528            PathBuf::from("lib/init.Lisp"),
1529            PathBuf::from("lib/init.LiSp"),
1530        ];
1531        for path in cases {
1532            let b = BehaviorSpec {
1533                on_init: Some(path.clone()),
1534                ..Default::default()
1535            };
1536            let err = b.validate().unwrap_err();
1537            assert!(
1538                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1539                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1540                "uppercase `.lisp` {path:?}: got {err:?}",
1541            );
1542        }
1543    }
1544
1545    #[test]
1546    fn validate_accepts_canonical_lisp_paths() {
1547        // Positive control: every shape the in-tree fixtures and
1548        // module-doc examples use must pass the gate. Pins the
1549        // accepted set so a future tightening doesn't accidentally
1550        // reject the canonical authoring shape.
1551        let cases = [
1552            PathBuf::from("lib/init.lisp"),
1553            PathBuf::from("lib/handlers.lisp"),
1554            PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1555            PathBuf::from("init.lisp"),
1556            PathBuf::from("a.lisp"),
1557            PathBuf::from("./lib/init.lisp"),
1558            PathBuf::from("lib/./handlers.lisp"),
1559            PathBuf::from("lib/migrations/v.0.1.lisp"),
1560        ];
1561        for path in cases {
1562            let b = BehaviorSpec {
1563                on_init: Some(path.clone()),
1564                ..Default::default()
1565            };
1566            b.validate()
1567                .unwrap_or_else(|e| panic!("canonical `.lisp` path {path:?} must pass: {e:?}"));
1568        }
1569    }
1570
1571    #[test]
1572    fn validate_path_shape_precedes_extension_arm() {
1573        // Cross-arm ordering: a path that is *both* path-shape invalid
1574        // (empty / absolute / parent-escape) and non-`.lisp` surfaces
1575        // the more fundamental sandbox-shape diagnostic first — the
1576        // `.lisp` remediation is misleading when the offending path
1577        // can never resolve under the caixa root anyway. Mirrors the
1578        // `MemoryZero` → `MemoryBelowWasm32Page` → `MemoryExceedsWasm32Cap`
1579        // → `MemoryNotPageMultiple` smallest-scope-last cascade on the
1580        // peer `:limits :memory` axis.
1581
1582        // Empty + non-`.lisp` → empty wins (the empty case has no
1583        // extension to begin with).
1584        let b = BehaviorSpec {
1585            on_init: Some(PathBuf::new()),
1586            ..Default::default()
1587        };
1588        assert!(matches!(
1589            b.validate().unwrap_err(),
1590            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1591        ));
1592
1593        // Absolute + non-`.lisp` → absolute wins.
1594        let b = BehaviorSpec {
1595            on_init: Some(PathBuf::from("/etc/init.txt")),
1596            ..Default::default()
1597        };
1598        assert!(matches!(
1599            b.validate().unwrap_err(),
1600            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1601        ));
1602
1603        // Parent-escape + non-`.lisp` → parent-escape wins.
1604        let b = BehaviorSpec {
1605            on_init: Some(PathBuf::from("../sibling/init.txt")),
1606            ..Default::default()
1607        };
1608        assert!(matches!(
1609            b.validate().unwrap_err(),
1610            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1611        ));
1612    }
1613
1614    #[test]
1615    fn validate_extension_diagnostic_names_offending_slot_and_path() {
1616        // Self-locating diagnostic pin: the surfaced error names the
1617        // exact `:on-*` slot the author wrote and the exact offending
1618        // path verbatim, so the author can grep their caixa.lisp for
1619        // the named slot / path and fix it in one edit. Same shape
1620        // every peer per-slot `:behavior` arm exposes.
1621        let path = PathBuf::from("lib/handlers.rs");
1622        let b = BehaviorSpec {
1623            on_cast: Some(path.clone()),
1624            ..Default::default()
1625        };
1626        let err = b.validate().unwrap_err();
1627        let rendered = err.to_string();
1628        assert!(
1629            rendered.contains(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST),
1630            "diagnostic must name the `:on-cast` slot: {rendered}"
1631        );
1632        assert!(
1633            rendered.contains("lib/handlers.rs"),
1634            "diagnostic must carry the offending path verbatim: {rendered}"
1635        );
1636        assert!(
1637            rendered.contains(".lisp"),
1638            "diagnostic must name the expected `.lisp` extension: {rendered}"
1639        );
1640    }
1641
1642    #[test]
1643    fn validate_extension_arm_fires_across_multi_malformed_manifest_in_slot_order() {
1644        // Multi-malformed manifest, all four slots carrying a
1645        // non-`.lisp` extension — the first declared slot
1646        // (`:on-init`) wins, mirroring the prior
1647        // `validate_diagnostic_order_is_deterministic` pin on the
1648        // path-shape arms.
1649        let b = BehaviorSpec {
1650            on_init: Some(PathBuf::from("lib/init.rs")),
1651            on_call: Some(PathBuf::from("lib/handlers.txt")),
1652            on_state_change: Some(PathBuf::from("lib/migrations.md")),
1653            ..Default::default()
1654        };
1655        let err = b.validate().unwrap_err();
1656        assert!(matches!(
1657            &err,
1658            BehaviorError::NonLispExtension { slot, path }
1659                if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1660                    && path == &PathBuf::from("lib/init.rs")
1661        ));
1662    }
1663
1664    #[test]
1665    fn m2_behavior_author_key_consts_pin_canonical_kebab_case_labels() {
1666        // Scalar-value pin: the six author-facing kebab-case labels the
1667        // `(defcaixa … :behavior (:on-* …))` surface admits, one arm per
1668        // sub-slot. Mirrors the peer scalar-value pin the sibling
1669        // renderer-side [`crate::M2_BEHAVIOR_KEY_ON_*`] camelCase consts
1670        // carry (21fe462), so both halves of the M2 `:behavior` sub-slot
1671        // dual axis (author-facing kebab-case label + renderer-side
1672        // camelCase wire key) route through one canonical per-arm
1673        // declaration. A future rebrand (`:on-init` → `:on-start`
1674        // matching Akka's per-actor preStart naming, `:on-state-change`
1675        // → `:on-code-change` matching Erlang's verbatim `code_change/3`
1676        // name) lands as an edit to exactly one const, and every
1677        // consumer that reaches for the label picks it up at build time
1678        // rather than at runtime as a downstream mismatch.
1679        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, ":on-init");
1680        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, ":on-call");
1681        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, ":on-cast");
1682        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, ":on-info");
1683        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, ":on-state-change");
1684        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, ":on-terminate");
1685    }
1686
1687    #[test]
1688    fn declared_slots_labels_route_through_lifted_author_key_consts() {
1689        // Production-through-const pin: the six per-arm labels
1690        // [`BehaviorSpec::declared_slots`] threads through as the
1691        // `(slot, path)` iterator's first component route through the
1692        // lifted [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] consts, in
1693        // declaration order. A future re-order or drift at the tagger
1694        // (a rename that reaches the tagger but not the const, or vice
1695        // versa) surfaces here at build time rather than at runtime as
1696        // a diagnostic naming a stale kebab-case slot far from the
1697        // rename's commit.
1698        let b = BehaviorSpec {
1699            on_init: Some(PathBuf::from("a.lisp")),
1700            on_call: Some(PathBuf::from("b.lisp")),
1701            on_cast: Some(PathBuf::from("c.lisp")),
1702            on_info: Some(PathBuf::from("d.lisp")),
1703            on_state_change: Some(PathBuf::from("e.lisp")),
1704            on_terminate: Some(PathBuf::from("f.lisp")),
1705        };
1706        let labels: Vec<&'static str> = b.declared_slots().map(|(s, _)| s).collect();
1707        assert_eq!(
1708            labels,
1709            vec![
1710                M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
1711                M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
1712                M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
1713                M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
1714                M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1715                M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1716            ]
1717        );
1718    }
1719
1720    #[test]
1721    fn declared_slots_paths_route_through_lifted_on_star_accessors() {
1722        // Production-through-accessor pin: the six per-arm
1723        // `Option<&Path>` path-values [`BehaviorSpec::declared_slots`]
1724        // threads through as the `(slot, path)` iterator's second
1725        // component route through the lifted per-slot
1726        // [`BehaviorSpec::on_init`] / [`BehaviorSpec::on_call`] /
1727        // [`BehaviorSpec::on_cast`] / [`BehaviorSpec::on_info`] /
1728        // [`BehaviorSpec::on_state_change`] / [`BehaviorSpec::on_terminate`]
1729        // accessors, so every future accessor-side extension of an
1730        // `:on-*` slot (a per-prior-`:versao` state-migration callback
1731        // the operator pins through a future `:behavior
1732        // :on-state-change-overrides` slot the
1733        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
1734        // callback-dispatch wire acknowledges, a per-tenant callback
1735        // alias table the M4 CR materializer resolves per-CR, a
1736        // per-cluster callback overlay the operator pins through a
1737        // future placement-scoped slot) reaches both production
1738        // consumers of the iterator (the layout checker's existence
1739        // sweep in `layout.rs` + the sibling [`BehaviorSpec::validate`]
1740        // value-shape gate) by construction, without a coordinated
1741        // rewrite of the iterator's six raw-field-access sites and the
1742        // six accessor bodies in lockstep. Peer of the sibling
1743        // `declared_slots_labels_route_through_lifted_author_key_consts`
1744        // pin on the tag-surface axis — same "one typed dispatch on the
1745        // substrate primitive, thin projections at each consumer"
1746        // discipline extended onto the peer per-arm `Option<&Path>`
1747        // path-value axis.
1748        //
1749        // Byte-equal today (each `on_*()` accessor is a thin
1750        // `.as_deref()` on the raw `Option<PathBuf>` field); the pin
1751        // catches any future accessor-side extension whose iterator
1752        // read regresses to the raw field.
1753        let b = BehaviorSpec {
1754            on_init: Some(PathBuf::from("lib/init.lisp")),
1755            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1756            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1757            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1758            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1759            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1760        };
1761        let entries: Vec<(&'static str, &Path)> = b.declared_slots().collect();
1762        assert_eq!(
1763            entries,
1764            vec![
1765                (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, b.on_init().unwrap()),
1766                (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, b.on_call().unwrap()),
1767                (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, b.on_cast().unwrap()),
1768                (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, b.on_info().unwrap()),
1769                (
1770                    M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1771                    b.on_state_change().unwrap(),
1772                ),
1773                (
1774                    M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1775                    b.on_terminate().unwrap(),
1776                ),
1777            ],
1778            "declared_slots must route each of its six per-arm \
1779             Option<&Path> path-values through the sibling lifted \
1780             BehaviorSpec::on_* accessor for its slot, so future \
1781             accessor-side extensions reach both the layout checker \
1782             + validate gate by construction (got {entries:?})",
1783        );
1784    }
1785
1786    #[test]
1787    fn declared_paths_routes_through_lifted_on_star_accessors() {
1788        // Sibling of `declared_slots_paths_route_through_lifted_
1789        // on_star_accessors` on the peer path-only projection axis:
1790        // [`BehaviorSpec::declared_paths`] must project each declared
1791        // callback path through the lifted per-slot
1792        // [`BehaviorSpec::on_*`] accessor, so the layout checker's
1793        // `for p in b.declared_paths() { root.join(p) }` on-disk
1794        // existence sweep at `layout.rs:901` reaches every future
1795        // accessor-side extension without a coordinated rewrite of the
1796        // sibling `declared_slots` internal iterator + the accessor
1797        // bodies in lockstep.
1798        let b = BehaviorSpec {
1799            on_init: Some(PathBuf::from("lib/init.lisp")),
1800            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1801            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1802            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1803            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1804            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1805        };
1806        let paths: Vec<&Path> = b.declared_paths().collect();
1807        assert_eq!(
1808            paths,
1809            vec![
1810                b.on_init().unwrap(),
1811                b.on_call().unwrap(),
1812                b.on_cast().unwrap(),
1813                b.on_info().unwrap(),
1814                b.on_state_change().unwrap(),
1815                b.on_terminate().unwrap(),
1816            ],
1817            "declared_paths must project each callback path through \
1818             the sibling lifted BehaviorSpec::on_* accessor for its \
1819             slot (got {paths:?})",
1820        );
1821    }
1822
1823    // ── per-`:behavior :on-state-change` accessor pins ─────────────────────
1824
1825    #[test]
1826    fn behavior_on_state_change_returns_option_path_verbatim_across_permutations() {
1827        // Canonical per-`:behavior` `:on-state-change` OTP-`code_change/3`-
1828        // shaped callback-path scalar pin: [`BehaviorSpec::on_state_change`]
1829        // must return the `:behavior :on-state-change` typed `PathBuf`
1830        // verbatim as an `Option<&Path>`, borrowed from the raw
1831        // `Option<PathBuf>` field access across the three canonical
1832        // shape-arms — `None` (no callback declared — the caixa exposes
1833        // no hot-upgrade state-fold path), `Some("lib/migrations.lisp")`
1834        // (the canonical single-file shape the module-doc example uses),
1835        // `Some("lib/migrations/v01-to-v02.lisp")` (the per-version
1836        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1837        // wasm-engine callback-dispatch wire acknowledges).
1838        //
1839        // Peer of the sibling per-`:placement` [`crate::Placement::shard_key`]
1840        // (7cd2a28) / [`crate::Placement::affinity`] (74ec2d3)
1841        // `Option<&str>` accessor pin on the sibling `Option<Str>`-return
1842        // axis, extended to the peer per-`:behavior` typed-`PathBuf`
1843        // optional-scalar shape — first `Option<&Path>`-return accessor
1844        // on the M2 `:behavior` slot family. Pins against a future silent
1845        // detour that re-derived the callback path from a peer axis (an
1846        // accidental `.on_call`-collapse that assumed the two
1847        // `Option<PathBuf>` axes carry the same value), a `None` →
1848        // `Some(empty)` collapse (the canonical
1849        // `Option<PathBuf>` → `PathBuf::new()` footgun the
1850        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
1851        // path-shape axis), or a per-arm variant swap that landed on one
1852        // consumer without the other.
1853        for path in [
1854            None,
1855            Some(PathBuf::from("lib/migrations.lisp")),
1856            Some(PathBuf::from("lib/migrations/v01-to-v02.lisp")),
1857        ] {
1858            let b = BehaviorSpec {
1859                on_state_change: path.clone(),
1860                ..BehaviorSpec::default()
1861            };
1862            assert_eq!(
1863                b.on_state_change(),
1864                path.as_deref(),
1865                "BehaviorSpec::on_state_change must return the \
1866                 :behavior :on-state-change PathBuf verbatim as \
1867                 Option<&Path> (got {:?}, expected {:?})",
1868                b.on_state_change(),
1869                path.as_deref(),
1870            );
1871            assert_eq!(
1872                b.on_state_change(),
1873                b.on_state_change.as_deref(),
1874                "BehaviorSpec::on_state_change must byte-equal the \
1875                 raw .on_state_change.as_deref() field access across \
1876                 every value in the accept-set",
1877            );
1878        }
1879    }
1880
1881    #[test]
1882    fn behavior_on_state_change_is_independent_of_peer_on_star_axes() {
1883        // Cross-axis independence pin: flipping only the
1884        // `:on-state-change` axis flips [`BehaviorSpec::on_state_change`]
1885        // independently of every peer `:on-*` axis
1886        // (`:on-init` / `:on-call` / `:on-cast` / `:on-info` /
1887        // `:on-terminate`). A future silent detour that re-derived the
1888        // callback path from a peer axis (an accidental `.on_call`-
1889        // collapse, a "state-change falls back to on-info" default that
1890        // would silently rebind the callback dispatch to the wrong
1891        // slot) surfaces here as a build-time test failure.
1892        //
1893        // Mirrors the sibling `limits_is_empty_memory_arm_routes_through_accessor`
1894        // (620c067) cross-axis pin on the peer M2 `:limits` slot
1895        // family — each accessor-lift closes exactly one axis and
1896        // leaves every peer axis unshifted.
1897        let base = BehaviorSpec {
1898            on_init: Some(PathBuf::from("lib/init.lisp")),
1899            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1900            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1901            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1902            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1903            ..BehaviorSpec::default()
1904        };
1905        assert_eq!(base.on_state_change(), None);
1906        let with = BehaviorSpec {
1907            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1908            ..base.clone()
1909        };
1910        assert_eq!(
1911            with.on_state_change(),
1912            Some(PathBuf::from("lib/migrations.lisp").as_path()),
1913            "BehaviorSpec::on_state_change must project the \
1914             :on-state-change axis independently of every peer :on-* \
1915             axis (got {:?})",
1916            with.on_state_change(),
1917        );
1918    }
1919
1920    #[test]
1921    fn validate_upgrade_from_against_behavior_routes_through_on_state_change_accessor() {
1922        // Production-through-const pin: the sole caixa-core consumer of
1923        // the accessor's `Option<&Path>` presence — the
1924        // [`crate::validate_upgrade_from_against_behavior`] cross-slot
1925        // composition gate — must route through
1926        // [`BehaviorSpec::on_state_change`] rather than the raw
1927        // `.on_state_change` field, so the gate's short-circuit and
1928        // every future accessor-side extension (a per-prior-`:versao`
1929        // callback override, a per-tenant migration alias table) land
1930        // as one edit at the accessor rather than as a coordinated
1931        // two-site rewrite of the gate + accessor.
1932        //
1933        // Peer of the sibling `declared_slots_labels_route_through_
1934        // lifted_author_key_consts` (production-through-const pin on
1935        // the label surface) and the sibling
1936        // `limits_is_empty_memory_arm_routes_through_accessor`
1937        // (620c067) pin on the peer M2 `:limits` slot family.
1938        //
1939        // Positive control: a `:behavior :on-state-change` callback
1940        // declared + a `:upgrade-from` entry carrying a
1941        // `(:state-change …)` instruction admits, because the accessor
1942        // returns `Some(&Path)` and the gate's short-circuit fires.
1943        let entries = vec![crate::UpgradeFromEntry {
1944            from: "0.1.0".to_string(),
1945            instructions: vec![
1946                crate::UpgradeInstruction::LoadModule {
1947                    module: "codec".to_string(),
1948                },
1949                crate::UpgradeInstruction::StateChange {
1950                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1951                },
1952            ],
1953        }];
1954        let b = BehaviorSpec {
1955            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1956            ..BehaviorSpec::default()
1957        };
1958        assert!(b.on_state_change().is_some());
1959        crate::validate_upgrade_from_against_behavior(&entries, Some(&b))
1960            .expect("callback declared → gate admits");
1961
1962        // Negative control: dropping only the accessor's slot to `None`
1963        // (with the same `:upgrade-from` entry) flips the gate to
1964        // refusal — the accessor's `None` return is the sole predicate
1965        // the short-circuit reads.
1966        let b_no_cb = BehaviorSpec::default();
1967        assert_eq!(b_no_cb.on_state_change(), None);
1968        let err = crate::validate_upgrade_from_against_behavior(&entries, Some(&b_no_cb))
1969            .expect_err(":state-change instruction without callback → refuse");
1970        assert!(matches!(
1971            err,
1972            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1973        ));
1974
1975        // `behavior: None` is the same refusal shape — the accessor
1976        // isn't reached, but `Option::and_then` on `None` short-circuits
1977        // to `None`, so the diagnostic is identical.
1978        let err = crate::validate_upgrade_from_against_behavior(&entries, None)
1979            .expect_err("behavior absent + :state-change instruction → refuse");
1980        assert!(matches!(
1981            err,
1982            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1983        ));
1984    }
1985
1986    // ── per-`:behavior :on-init` accessor pins ─────────────────────
1987
1988    #[test]
1989    fn behavior_on_init_returns_option_path_verbatim_across_permutations() {
1990        // Canonical per-`:behavior` `:on-init` OTP-`init/1`-shaped
1991        // callback-path scalar pin: [`BehaviorSpec::on_init`] must
1992        // return the `:behavior :on-init` typed `PathBuf` verbatim as
1993        // an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
1994        // field access across the three canonical shape-arms — `None`
1995        // (no callback declared — the runtime falls back to the
1996        // wasm-engine's no-op instance-start default), `Some("lib/init.lisp")`
1997        // (the canonical single-file shape the module-doc example
1998        // uses), `Some("lib/lifecycle/init.lisp")` (the per-lifecycle
1999        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
2000        // wasm-engine callback-dispatch wire acknowledges).
2001        //
2002        // Peer of the sibling per-`:behavior` [`BehaviorSpec::on_state_change`]
2003        // (9b4ecde) `Option<&Path>` accessor pin on the sibling
2004        // `Option<PathBuf>`-return axis — second `Option<&Path>`-return
2005        // accessor on the M2 `:behavior` slot family. Pins against a
2006        // future silent detour that re-derived the callback path from a
2007        // peer axis (an accidental `.on_call`-collapse that assumed the
2008        // two `Option<PathBuf>` axes carry the same value), a `None` →
2009        // `Some(empty)` collapse (the canonical
2010        // `Option<PathBuf>` → `PathBuf::new()` footgun the
2011        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
2012        // path-shape axis), or a per-arm variant swap that landed on
2013        // one consumer without the other.
2014        for path in [
2015            None,
2016            Some(PathBuf::from("lib/init.lisp")),
2017            Some(PathBuf::from("lib/lifecycle/init.lisp")),
2018        ] {
2019            let b = BehaviorSpec {
2020                on_init: path.clone(),
2021                ..BehaviorSpec::default()
2022            };
2023            assert_eq!(
2024                b.on_init(),
2025                path.as_deref(),
2026                "BehaviorSpec::on_init must return the \
2027                 :behavior :on-init PathBuf verbatim as \
2028                 Option<&Path> (got {:?}, expected {:?})",
2029                b.on_init(),
2030                path.as_deref(),
2031            );
2032            assert_eq!(
2033                b.on_init(),
2034                b.on_init.as_deref(),
2035                "BehaviorSpec::on_init must byte-equal the \
2036                 raw .on_init.as_deref() field access across \
2037                 every value in the accept-set",
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn behavior_on_init_is_independent_of_peer_on_star_axes() {
2044        // Cross-axis independence pin: flipping only the `:on-init`
2045        // axis flips [`BehaviorSpec::on_init`] independently of every
2046        // peer `:on-*` axis (`:on-call` / `:on-cast` / `:on-info` /
2047        // `:on-state-change` / `:on-terminate`). A future silent detour
2048        // that re-derived the callback path from a peer axis (an
2049        // accidental `.on_call`-collapse, a "init falls back to
2050        // state-change" default that would silently rebind the callback
2051        // dispatch to the wrong slot) surfaces here as a build-time
2052        // test failure.
2053        //
2054        // Peer of the sibling
2055        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2056        // (9b4ecde) cross-axis pin on the sibling
2057        // `:on-state-change` axis — each accessor-lift closes exactly
2058        // one axis and leaves every peer axis unshifted.
2059        let base = BehaviorSpec {
2060            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2061            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2062            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2063            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2064            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2065            ..BehaviorSpec::default()
2066        };
2067        assert_eq!(base.on_init(), None);
2068        let with = BehaviorSpec {
2069            on_init: Some(PathBuf::from("lib/init.lisp")),
2070            ..base.clone()
2071        };
2072        assert_eq!(
2073            with.on_init(),
2074            Some(PathBuf::from("lib/init.lisp").as_path()),
2075            "BehaviorSpec::on_init must project the \
2076             :on-init axis independently of every peer :on-* \
2077             axis (got {:?})",
2078            with.on_init(),
2079        );
2080    }
2081
2082    // ── per-`:behavior :on-call` accessor pins ─────────────────────
2083
2084    #[test]
2085    fn behavior_on_call_returns_option_path_verbatim_across_permutations() {
2086        // Canonical per-`:behavior` `:on-call`
2087        // OTP-`gen_server:handle_call/3`-shaped synchronous
2088        // request/response callback-path scalar pin:
2089        // [`BehaviorSpec::on_call`] must return the `:behavior :on-call`
2090        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2091        // the raw `Option<PathBuf>` field access across the three
2092        // canonical shape-arms — `None` (no callback declared — the
2093        // runtime falls back to the wasm-engine's raw
2094        // `wasi:http/incoming-handler` default), `Some("lib/handlers.lisp")`
2095        // (the canonical single-file shape the module-doc example
2096        // uses), `Some("lib/rpc/call.lisp")` (the per-dispatch
2097        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
2098        // wasm-engine callback-dispatch wire acknowledges).
2099        //
2100        // Peer of the sibling per-`:behavior`
2101        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2102        // [`BehaviorSpec::on_init`] (d66c702) `Option<&Path>` accessor
2103        // pins on the sibling `Option<PathBuf>`-return axes — third
2104        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2105        // family. Pins against a future silent detour that re-derived
2106        // the callback path from a peer axis (an accidental
2107        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
2108        // axes carry the same value — a plausible slip because the
2109        // module-doc example shares one `lib/handlers.lisp` file
2110        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
2111        // that the tatara-lisp dispatch inside the file discriminates
2112        // on the callback-kind atom), a `None` → `Some(empty)` collapse
2113        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
2114        // the [`BehaviorError::EmptyPath`] validate arm guards on the
2115        // peer path-shape axis), or a per-arm variant swap that landed
2116        // on one consumer without the other.
2117        for path in [
2118            None,
2119            Some(PathBuf::from("lib/handlers.lisp")),
2120            Some(PathBuf::from("lib/rpc/call.lisp")),
2121        ] {
2122            let b = BehaviorSpec {
2123                on_call: path.clone(),
2124                ..BehaviorSpec::default()
2125            };
2126            assert_eq!(
2127                b.on_call(),
2128                path.as_deref(),
2129                "BehaviorSpec::on_call must return the \
2130                 :behavior :on-call PathBuf verbatim as \
2131                 Option<&Path> (got {:?}, expected {:?})",
2132                b.on_call(),
2133                path.as_deref(),
2134            );
2135            assert_eq!(
2136                b.on_call(),
2137                b.on_call.as_deref(),
2138                "BehaviorSpec::on_call must byte-equal the \
2139                 raw .on_call.as_deref() field access across \
2140                 every value in the accept-set",
2141            );
2142        }
2143    }
2144
2145    #[test]
2146    fn behavior_on_call_is_independent_of_peer_on_star_axes() {
2147        // Cross-axis independence pin: flipping only the `:on-call`
2148        // axis flips [`BehaviorSpec::on_call`] independently of every
2149        // peer `:on-*` axis (`:on-init` / `:on-cast` / `:on-info` /
2150        // `:on-state-change` / `:on-terminate`). A future silent detour
2151        // that re-derived the callback path from a peer axis (an
2152        // accidental `.on_cast`-collapse — the sibling asynchronous
2153        // fire-and-forget arm on the peer OTP dispatch triad, a
2154        // plausible confusion because both callbacks share the
2155        // `handle_*/2|3` OTP shape — that would silently rebind the
2156        // synchronous request/response dispatch to the sibling
2157        // fire-and-forget slot's callback) surfaces here as a
2158        // build-time test failure.
2159        //
2160        // Peer of the sibling
2161        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2162        // (9b4ecde) /
2163        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2164        // (d66c702) cross-axis pins on the sibling `:on-state-change` /
2165        // `:on-init` axes — each accessor-lift closes exactly one axis
2166        // and leaves every peer axis unshifted.
2167        let base = BehaviorSpec {
2168            on_init: Some(PathBuf::from("lib/init.lisp")),
2169            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2170            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2171            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2172            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2173            ..BehaviorSpec::default()
2174        };
2175        assert_eq!(base.on_call(), None);
2176        let with = BehaviorSpec {
2177            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2178            ..base.clone()
2179        };
2180        assert_eq!(
2181            with.on_call(),
2182            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2183            "BehaviorSpec::on_call must project the \
2184             :on-call axis independently of every peer :on-* \
2185             axis (got {:?})",
2186            with.on_call(),
2187        );
2188    }
2189
2190    // ── per-`:behavior :on-cast` accessor pins ─────────────────────
2191
2192    #[test]
2193    fn behavior_on_cast_returns_option_path_verbatim_across_permutations() {
2194        // Canonical per-`:behavior` `:on-cast`
2195        // OTP-`gen_server:handle_cast/2`-shaped asynchronous
2196        // fire-and-forget callback-path scalar pin:
2197        // [`BehaviorSpec::on_cast`] must return the `:behavior :on-cast`
2198        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2199        // the raw `Option<PathBuf>` field access across the three
2200        // canonical shape-arms — `None` (no callback declared — the
2201        // runtime falls back to the wasm-engine's default `Accepted:
2202        // 202` fire-and-forget shape), `Some("lib/handlers.lisp")` (the
2203        // canonical single-file shape the module-doc example uses,
2204        // shared with `:on-call` / `:on-info` on the pattern that the
2205        // tatara-lisp dispatch inside the file discriminates on the
2206        // callback-kind atom), `Some("lib/rpc/cast.lisp")` (the
2207        // per-dispatch sub-directory shape the
2208        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2209        // callback-dispatch wire acknowledges).
2210        //
2211        // Peer of the sibling per-`:behavior`
2212        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2213        // [`BehaviorSpec::on_init`] (d66c702) /
2214        // [`BehaviorSpec::on_call`] (156ddbe) `Option<&Path>` accessor
2215        // pins on the sibling `Option<PathBuf>`-return axes — fourth
2216        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2217        // family. Pins against a future silent detour that re-derived
2218        // the callback path from a peer axis (an accidental
2219        // `.on_call`-collapse that assumed the two `Option<PathBuf>`
2220        // axes carry the same value — a plausible slip because both
2221        // callbacks share the `handle_*/2|3` OTP shape and the
2222        // module-doc example shares one `lib/handlers.lisp` file
2223        // between `:on-call` / `:on-cast` / `:on-info`), a `None` →
2224        // `Some(empty)` collapse (the canonical `Option<PathBuf>` →
2225        // `PathBuf::new()` footgun the [`BehaviorError::EmptyPath`]
2226        // validate arm guards on the peer path-shape axis), or a
2227        // per-arm variant swap that landed on one consumer without the
2228        // other.
2229        for path in [
2230            None,
2231            Some(PathBuf::from("lib/handlers.lisp")),
2232            Some(PathBuf::from("lib/rpc/cast.lisp")),
2233        ] {
2234            let b = BehaviorSpec {
2235                on_cast: path.clone(),
2236                ..BehaviorSpec::default()
2237            };
2238            assert_eq!(
2239                b.on_cast(),
2240                path.as_deref(),
2241                "BehaviorSpec::on_cast must return the \
2242                 :behavior :on-cast PathBuf verbatim as \
2243                 Option<&Path> (got {:?}, expected {:?})",
2244                b.on_cast(),
2245                path.as_deref(),
2246            );
2247            assert_eq!(
2248                b.on_cast(),
2249                b.on_cast.as_deref(),
2250                "BehaviorSpec::on_cast must byte-equal the \
2251                 raw .on_cast.as_deref() field access across \
2252                 every value in the accept-set",
2253            );
2254        }
2255    }
2256
2257    #[test]
2258    fn behavior_on_cast_is_independent_of_peer_on_star_axes() {
2259        // Cross-axis independence pin: flipping only the `:on-cast`
2260        // axis flips [`BehaviorSpec::on_cast`] independently of every
2261        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-info` /
2262        // `:on-state-change` / `:on-terminate`). A future silent detour
2263        // that re-derived the callback path from a peer axis (an
2264        // accidental `.on_call`-collapse — the sibling synchronous
2265        // request/response arm on the peer OTP dispatch triad, a
2266        // plausible confusion because both callbacks share the
2267        // `handle_*/2|3` OTP shape and the module-doc example shares
2268        // one `lib/handlers.lisp` file between the two — that would
2269        // silently rebind the asynchronous fire-and-forget dispatch to
2270        // the sibling synchronous request/response slot's callback)
2271        // surfaces here as a build-time test failure.
2272        //
2273        // Peer of the sibling
2274        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2275        // (9b4ecde) /
2276        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2277        // (d66c702) /
2278        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2279        // (156ddbe) cross-axis pins on the sibling `:on-state-change` /
2280        // `:on-init` / `:on-call` axes — each accessor-lift closes
2281        // exactly one axis and leaves every peer axis unshifted.
2282        let base = BehaviorSpec {
2283            on_init: Some(PathBuf::from("lib/init.lisp")),
2284            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2285            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2286            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2287            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2288            ..BehaviorSpec::default()
2289        };
2290        assert_eq!(base.on_cast(), None);
2291        let with = BehaviorSpec {
2292            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2293            ..base.clone()
2294        };
2295        assert_eq!(
2296            with.on_cast(),
2297            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2298            "BehaviorSpec::on_cast must project the \
2299             :on-cast axis independently of every peer :on-* \
2300             axis (got {:?})",
2301            with.on_cast(),
2302        );
2303    }
2304
2305    // ── per-`:behavior :on-info` accessor pins ─────────────────────
2306
2307    #[test]
2308    fn behavior_on_info_returns_option_path_verbatim_across_permutations() {
2309        // Canonical per-`:behavior` `:on-info`
2310        // OTP-`gen_server:handle_info/2`-shaped system / out-of-band
2311        // message-handler callback-path scalar pin:
2312        // [`BehaviorSpec::on_info`] must return the `:behavior :on-info`
2313        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2314        // the raw `Option<PathBuf>` field access across the three
2315        // canonical shape-arms — `None` (no callback declared — the
2316        // runtime silently drops every out-of-band mailbox message),
2317        // `Some("lib/handlers.lisp")` (the canonical single-file shape
2318        // the module-doc example uses, shared with `:on-call` /
2319        // `:on-cast` on the pattern that the tatara-lisp dispatch
2320        // inside the file discriminates on the callback-kind atom),
2321        // `Some("lib/rpc/info.lisp")` (the per-dispatch sub-directory
2322        // shape the `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2323        // callback-dispatch wire acknowledges).
2324        //
2325        // Peer of the sibling per-`:behavior`
2326        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2327        // [`BehaviorSpec::on_init`] (d66c702) /
2328        // [`BehaviorSpec::on_call`] (156ddbe) /
2329        // [`BehaviorSpec::on_cast`] (99616ac) `Option<&Path>` accessor
2330        // pins on the sibling `Option<PathBuf>`-return axes — fifth
2331        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2332        // family. Pins against a future silent detour that re-derived
2333        // the callback path from a peer axis (an accidental
2334        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
2335        // axes carry the same value — a plausible slip because the
2336        // module-doc example shares one `lib/handlers.lisp` file
2337        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
2338        // that the tatara-lisp dispatch inside the file discriminates
2339        // on the callback-kind atom), a `None` → `Some(empty)` collapse
2340        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
2341        // the [`BehaviorError::EmptyPath`] validate arm guards on the
2342        // peer path-shape axis), or a per-arm variant swap that landed
2343        // on one consumer without the other.
2344        for path in [
2345            None,
2346            Some(PathBuf::from("lib/handlers.lisp")),
2347            Some(PathBuf::from("lib/rpc/info.lisp")),
2348        ] {
2349            let b = BehaviorSpec {
2350                on_info: path.clone(),
2351                ..BehaviorSpec::default()
2352            };
2353            assert_eq!(
2354                b.on_info(),
2355                path.as_deref(),
2356                "BehaviorSpec::on_info must return the \
2357                 :behavior :on-info PathBuf verbatim as \
2358                 Option<&Path> (got {:?}, expected {:?})",
2359                b.on_info(),
2360                path.as_deref(),
2361            );
2362            assert_eq!(
2363                b.on_info(),
2364                b.on_info.as_deref(),
2365                "BehaviorSpec::on_info must byte-equal the \
2366                 raw .on_info.as_deref() field access across \
2367                 every value in the accept-set",
2368            );
2369        }
2370    }
2371
2372    #[test]
2373    fn behavior_on_info_is_independent_of_peer_on_star_axes() {
2374        // Cross-axis independence pin: flipping only the `:on-info`
2375        // axis flips [`BehaviorSpec::on_info`] independently of every
2376        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast` /
2377        // `:on-state-change` / `:on-terminate`). A future silent detour
2378        // that re-derived the callback path from a peer axis (an
2379        // accidental `.on_cast`-collapse — the sibling asynchronous
2380        // fire-and-forget arm on the peer OTP dispatch triad, a
2381        // plausible confusion because the module-doc example shares one
2382        // `lib/handlers.lisp` file between `:on-call` / `:on-cast` /
2383        // `:on-info` — that would silently rebind the out-of-band-info
2384        // dispatch to the sibling asynchronous fire-and-forget slot's
2385        // callback) surfaces here as a build-time test failure.
2386        //
2387        // Peer of the sibling
2388        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2389        // (9b4ecde) /
2390        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2391        // (d66c702) /
2392        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2393        // (156ddbe) /
2394        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2395        // (99616ac) cross-axis pins on the sibling `:on-state-change` /
2396        // `:on-init` / `:on-call` / `:on-cast` axes — each
2397        // accessor-lift closes exactly one axis and leaves every peer
2398        // axis unshifted.
2399        let base = BehaviorSpec {
2400            on_init: Some(PathBuf::from("lib/init.lisp")),
2401            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2402            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2403            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2404            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2405            ..BehaviorSpec::default()
2406        };
2407        assert_eq!(base.on_info(), None);
2408        let with = BehaviorSpec {
2409            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2410            ..base.clone()
2411        };
2412        assert_eq!(
2413            with.on_info(),
2414            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2415            "BehaviorSpec::on_info must project the \
2416             :on-info axis independently of every peer :on-* \
2417             axis (got {:?})",
2418            with.on_info(),
2419        );
2420    }
2421
2422    // ── per-`:behavior :on-terminate` accessor pins ────────────────
2423
2424    #[test]
2425    fn behavior_on_terminate_returns_option_path_verbatim_across_permutations() {
2426        // Canonical per-`:behavior` `:on-terminate`
2427        // OTP-`gen_server:terminate/2`-shaped graceful-shutdown cleanup
2428        // callback-path scalar pin: [`BehaviorSpec::on_terminate`] must
2429        // return the `:behavior :on-terminate` typed `PathBuf` verbatim
2430        // as an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
2431        // field access across the three canonical shape-arms — `None`
2432        // (no callback declared — the runtime tears down the wasm
2433        // instance without dispatching any author-supplied cleanup
2434        // side-effect), `Some("lib/cleanup.lisp")` (the canonical
2435        // single-file shape the module-doc example uses, the
2436        // author-surface pins `lib/cleanup.lisp` as the reference
2437        // `:on-terminate` value), `Some("lib/lifecycle/terminate.lisp")`
2438        // (the per-lifecycle-arm sub-directory shape the
2439        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2440        // callback-dispatch wire acknowledges).
2441        //
2442        // Peer of the sibling per-`:behavior`
2443        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2444        // [`BehaviorSpec::on_init`] (d66c702) /
2445        // [`BehaviorSpec::on_call`] (156ddbe) /
2446        // [`BehaviorSpec::on_cast`] (99616ac) /
2447        // [`BehaviorSpec::on_info`] (4846cef) `Option<&Path>` accessor
2448        // pins on the sibling `Option<PathBuf>`-return axes — sixth and
2449        // final `Option<&Path>`-return accessor on the M2 `:behavior`
2450        // slot family, closes the last unlifted per-`:behavior`
2451        // `Option<&Path>` scalar-value axis. Pins against a future
2452        // silent detour that re-derived the callback path from a peer
2453        // axis (an accidental `.on_init`-collapse that assumed the two
2454        // lifecycle-arm `Option<PathBuf>` axes carry the same value —
2455        // a plausible slip because both are the lifecycle-head /
2456        // lifecycle-tail bookends of the OTP `gen_server` lifecycle, so
2457        // the "run once per instance" semantics rhyme across the two
2458        // arms), a `None` → `Some(empty)` collapse (the canonical
2459        // `Option<PathBuf>` → `PathBuf::new()` footgun the
2460        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
2461        // path-shape axis), or a per-arm variant swap that landed on
2462        // one consumer without the other.
2463        for path in [
2464            None,
2465            Some(PathBuf::from("lib/cleanup.lisp")),
2466            Some(PathBuf::from("lib/lifecycle/terminate.lisp")),
2467        ] {
2468            let b = BehaviorSpec {
2469                on_terminate: path.clone(),
2470                ..BehaviorSpec::default()
2471            };
2472            assert_eq!(
2473                b.on_terminate(),
2474                path.as_deref(),
2475                "BehaviorSpec::on_terminate must return the \
2476                 :behavior :on-terminate PathBuf verbatim as \
2477                 Option<&Path> (got {:?}, expected {:?})",
2478                b.on_terminate(),
2479                path.as_deref(),
2480            );
2481            assert_eq!(
2482                b.on_terminate(),
2483                b.on_terminate.as_deref(),
2484                "BehaviorSpec::on_terminate must byte-equal the \
2485                 raw .on_terminate.as_deref() field access across \
2486                 every value in the accept-set",
2487            );
2488        }
2489    }
2490
2491    #[test]
2492    fn behavior_on_terminate_is_independent_of_peer_on_star_axes() {
2493        // Cross-axis independence pin: flipping only the `:on-terminate`
2494        // axis flips [`BehaviorSpec::on_terminate`] independently of
2495        // every peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast`
2496        // / `:on-info` / `:on-state-change`). A future silent detour
2497        // that re-derived the callback path from a peer axis (an
2498        // accidental `.on_init`-collapse — the sibling lifecycle-head
2499        // arm on the peer OTP dispatch lifecycle, a plausible confusion
2500        // because both are the lifecycle-bookend arms that run
2501        // once-per-instance rather than per-mailbox-turn — that would
2502        // silently rebind the graceful-tear-down dispatch to the
2503        // sibling instance-start slot's callback) surfaces here as a
2504        // build-time test failure.
2505        //
2506        // Peer of the sibling
2507        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2508        // (9b4ecde) /
2509        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2510        // (d66c702) /
2511        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2512        // (156ddbe) /
2513        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2514        // (99616ac) /
2515        // `behavior_on_info_is_independent_of_peer_on_star_axes`
2516        // (4846cef) cross-axis pins on the sibling `:on-state-change` /
2517        // `:on-init` / `:on-call` / `:on-cast` / `:on-info` axes —
2518        // each accessor-lift closes exactly one axis and leaves every
2519        // peer axis unshifted, so the six-callback OTP `gen_server`
2520        // lifecycle the slot family models routes through one typed
2521        // dispatch per arm on the substrate primitive.
2522        let base = BehaviorSpec {
2523            on_init: Some(PathBuf::from("lib/init.lisp")),
2524            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2525            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2526            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2527            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2528            ..BehaviorSpec::default()
2529        };
2530        assert_eq!(base.on_terminate(), None);
2531        let with = BehaviorSpec {
2532            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2533            ..base.clone()
2534        };
2535        assert_eq!(
2536            with.on_terminate(),
2537            Some(PathBuf::from("lib/cleanup.lisp").as_path()),
2538            "BehaviorSpec::on_terminate must project the \
2539             :on-terminate axis independently of every peer :on-* \
2540             axis (got {:?})",
2541            with.on_terminate(),
2542        );
2543    }
2544
2545    // Per-variant equivalence pins for the [`behavior_slot_path_ctors!`]
2546    // macro definition (see the paired doc-block above the macro
2547    // definition) — every generated `<ctor>(slot: &'static str,
2548    // path: &Path) -> Self` constructor folds the uniform `Self::<Variant>
2549    // { slot, path: path.to_path_buf() }` two-field struct-literal onto
2550    // one substrate primitive. The three per-variant equivalence pins
2551    // below (fail-before-pass-after by construction — a byte-mismatched
2552    // macro arm would trip its equivalence pin first) lock each generated
2553    // constructor to its struct-literal peer under `PartialEq`, so every
2554    // closure passed to [`crate::render::require_sandboxed_lisp_path`] at
2555    // [`validate_callback_path`] on that variant produces a byte-equal
2556    // `BehaviorError` to the pre-lift open-coded struct-literal. The
2557    // cross-axis pin that follows (non-default `(slot, path)` pair over
2558    // every `M2_BEHAVIOR_AUTHOR_KEY_ON_*` label, and both `&Path` and
2559    // `&PathBuf` shapes) routes both constructor input axes verbatim
2560    // (`slot` as `&'static str` without conversion, `path` via
2561    // `.to_path_buf()`), so the fold does not silently collapse onto a
2562    // fixed `slot` or `path` value or drop the Deref-coercion arm the
2563    // wire-up sites depend on.
2564    //
2565    // Peer of the sibling `absolute_script_ctor_matches_struct_literal_wrap`
2566    // / `parent_escape_script_ctor_matches_struct_literal_wrap` /
2567    // `non_lisp_extension_script_ctor_matches_struct_literal_wrap` /
2568    // `upgrade_script_only_ctors_route_script_through_to_path_buf`
2569    // equivalence + cross-axis pins the peer
2570    // [`crate::upgrade::upgrade_script_only_ctors!`] family (7468ca9)
2571    // established on the peer `{ script: PathBuf }` one-slot envelope of
2572    // the sibling `UpgradeError`, and of the peer
2573    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap` /
2574    // `duplicate_state_change_ctor_matches_struct_literal_wrap` /
2575    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
2576    // / `upgrade_from_script_ctors_route_from_and_script_verbatim` pins
2577    // the peer [`crate::upgrade::upgrade_from_script_ctors!`] family
2578    // (8e67041) established on the peer `{ from: String, script: PathBuf }`
2579    // two-slot envelope of that same sibling; extended here onto the
2580    // `BehaviorError` `{ slot: &'static str, path: PathBuf }` two-slot
2581    // envelope so every substrate-primitive ctor family in caixa-core
2582    // guarantees the same-shape fold every wire-up on the family reads
2583    // through one dispatch.
2584
2585    #[test]
2586    fn absolute_path_ctor_matches_struct_literal_wrap() {
2587        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_INIT;
2588        let path = Path::new("/etc/nope.lisp");
2589        assert_eq!(
2590            BehaviorError::absolute_path(slot, path),
2591            BehaviorError::AbsolutePath {
2592                slot,
2593                path: path.to_path_buf(),
2594            },
2595            "generated absolute_path ctor must produce byte-equal \
2596             BehaviorError to the open-coded struct-literal wrap on the \
2597             same (&'static str, &Path) fixture",
2598        );
2599    }
2600
2601    #[test]
2602    fn parent_escape_ctor_matches_struct_literal_wrap() {
2603        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE;
2604        let path = Path::new("../oops.lisp");
2605        assert_eq!(
2606            BehaviorError::parent_escape(slot, path),
2607            BehaviorError::ParentEscape {
2608                slot,
2609                path: path.to_path_buf(),
2610            },
2611            "generated parent_escape ctor must produce byte-equal \
2612             BehaviorError to the open-coded struct-literal wrap on the \
2613             same (&'static str, &Path) fixture",
2614        );
2615    }
2616
2617    #[test]
2618    fn non_lisp_extension_ctor_matches_struct_literal_wrap() {
2619        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE;
2620        let path = Path::new("lib/cleanup.rs");
2621        assert_eq!(
2622            BehaviorError::non_lisp_extension(slot, path),
2623            BehaviorError::NonLispExtension {
2624                slot,
2625                path: path.to_path_buf(),
2626            },
2627            "generated non_lisp_extension ctor must produce byte-equal \
2628             BehaviorError to the open-coded struct-literal wrap on the \
2629             same (&'static str, &Path) fixture",
2630        );
2631    }
2632
2633    #[test]
2634    fn behavior_slot_path_ctors_route_slot_and_path_verbatim() {
2635        // Cross-axis pin: sweep both constructor input axes (`slot:
2636        // &'static str`, `path: &Path`) through non-default fixtures
2637        // over every M2 `:behavior :on-*` author-key label and both
2638        // `&Path` (direct `Path::new`) / `&PathBuf` (via Deref coercion)
2639        // shapes against every generated arm in the
2640        // [`behavior_slot_path_ctors!`] macro, so any wrapper-side
2641        // lowercase / trim / truncate / re-order / fixed-slot-or-path
2642        // substitution on the two-field construction surfaces here
2643        // rather than at a downstream diagnostic-shape mismatch. Also
2644        // exercises the `&Path` parameter under both `&Path` (direct
2645        // `Path::new`) and `&PathBuf` (via Deref coercion), matching the
2646        // shape the three closures at [`validate_callback_path`] thread
2647        // through — the wire-ups hand a `&Path` from
2648        // [`BehaviorSpec::declared_slots`]' iterator into each closure,
2649        // so the Deref-coercion arm the ctor advertises must actually
2650        // route through `.to_path_buf()` and not silently swap in a
2651        // fixed path. Peer of the sibling
2652        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
2653        // (8e67041) cross-axis pin on the sibling `UpgradeError`
2654        // `{ from, script }` two-slot envelope.
2655        let path_owned = PathBuf::from("lib/handlers.lisp");
2656        let path_ref: &Path = path_owned.as_path();
2657        for slot in [
2658            M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
2659            M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
2660            M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
2661            M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
2662            M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
2663            M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
2664        ] {
2665            for path in [path_ref, &path_owned as &Path] {
2666                assert_eq!(
2667                    BehaviorError::absolute_path(slot, path),
2668                    BehaviorError::AbsolutePath {
2669                        slot,
2670                        path: path.to_path_buf(),
2671                    },
2672                );
2673                assert_eq!(
2674                    BehaviorError::parent_escape(slot, path),
2675                    BehaviorError::ParentEscape {
2676                        slot,
2677                        path: path.to_path_buf(),
2678                    },
2679                );
2680                assert_eq!(
2681                    BehaviorError::non_lisp_extension(slot, path),
2682                    BehaviorError::NonLispExtension {
2683                        slot,
2684                        path: path.to_path_buf(),
2685                    },
2686                );
2687            }
2688        }
2689    }
2690
2691    // Per-variant equivalence pin for the [`BehaviorError::empty_path`]
2692    // one-slot inherent constructor (see the paired doc-block above the
2693    // impl definition) — the constructor folds the uniform
2694    // `Self::EmptyPath { slot }` one-field struct-literal onto one
2695    // substrate primitive. The equivalence pin below (fail-before-pass-
2696    // after by construction — a byte-mismatched constructor body would
2697    // trip this pin first) locks the generated constructor to its
2698    // struct-literal peer under `PartialEq`, so the closure passed to
2699    // [`crate::render::require_sandboxed_lisp_path`] at
2700    // [`validate_callback_path`] on this variant produces a byte-equal
2701    // `BehaviorError` to the pre-lift open-coded struct-literal. The
2702    // cross-axis pin that follows (`slot: &'static str` sweep over every
2703    // `M2_BEHAVIOR_AUTHOR_KEY_ON_*` label) routes the constructor input
2704    // axis verbatim (`slot` as `&'static str` without conversion), so
2705    // the fold does not silently collapse onto a fixed `slot` value.
2706    //
2707    // Peer of the sibling `absolute_path_ctor_matches_struct_literal_wrap`
2708    // / `parent_escape_ctor_matches_struct_literal_wrap` /
2709    // `non_lisp_extension_ctor_matches_struct_literal_wrap` /
2710    // `behavior_slot_path_ctors_route_slot_and_path_verbatim`
2711    // equivalence + cross-axis pins the peer
2712    // [`behavior_slot_path_ctors!`] family (b0c8389) established on the
2713    // paired `{ slot: &'static str, path: PathBuf }` two-slot envelope
2714    // of the same `BehaviorError` — the four-arm sandboxed-lisp-path
2715    // cascade at [`validate_callback_path`] now carries a
2716    // substrate-primitive equivalence pin at every arm rather than three
2717    // pinned arms plus a hand-written open-coded fourth.
2718
2719    #[test]
2720    fn empty_path_ctor_matches_struct_literal_wrap() {
2721        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_INIT;
2722        assert_eq!(
2723            BehaviorError::empty_path(slot),
2724            BehaviorError::EmptyPath { slot },
2725            "generated empty_path ctor must produce byte-equal \
2726             BehaviorError to the open-coded struct-literal wrap on the \
2727             same &'static str fixture",
2728        );
2729    }
2730
2731    #[test]
2732    fn empty_path_ctor_routes_slot_verbatim_across_every_on_star_key() {
2733        // Cross-axis pin: sweep the constructor's single input axis
2734        // (`slot: &'static str`) through every M2 `:behavior :on-*`
2735        // author-key label so any wrapper-side lowercase / trim /
2736        // truncate / fixed-slot substitution on the one-field
2737        // construction surfaces here rather than at a downstream
2738        // diagnostic-shape mismatch. Peer of the sibling
2739        // [`behavior_slot_path_ctors_route_slot_and_path_verbatim`]
2740        // cross-axis pin on the two-slot envelope of the same
2741        // `BehaviorError` — extended here onto the one-slot envelope so
2742        // both slot-only and slot+path constructor input axes carry a
2743        // per-`:on-*`-label sweep.
2744        for slot in [
2745            M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
2746            M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
2747            M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
2748            M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
2749            M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
2750            M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
2751        ] {
2752            assert_eq!(
2753                BehaviorError::empty_path(slot),
2754                BehaviorError::EmptyPath { slot },
2755            );
2756        }
2757    }
2758
2759    // ── const-fn-widening pins on [`BehaviorSpec::is_empty`] ───────
2760
2761    #[test]
2762    fn behavior_spec_is_empty_is_const_fn_usable_in_const_position() {
2763        // Pin the `pub const fn` shape of [`BehaviorSpec::is_empty`] —
2764        // the sibling of the M2 [`crate::LimitsSpec::is_empty`] /
2765        // M3 [`crate::aplicacao::MeshPolicy::is_empty`] `pub const fn`
2766        // emptiness predicates on the paired per-Servico / per-Aplicacao
2767        // typed-slot surfaces — by calling it from a `const` position.
2768        // A downgrade to `pub fn` (dropping `const`) or the pre-lift
2769        // `self.declared_paths().next().is_none()` body (which routes
2770        // through the iterator-alloc chain and is not `const`-callable)
2771        // makes this pin fail to compile with "cannot call non-const fn
2772        // `BehaviorSpec::is_empty` in constants", so any accidental
2773        // downgrade trips at caixa-core build time, not at a downstream
2774        // renderer / operator / admission-webhook const-context call
2775        // site far from the drift's commit.
2776        const EMPTY: BehaviorSpec = BehaviorSpec {
2777            on_init: None,
2778            on_call: None,
2779            on_cast: None,
2780            on_info: None,
2781            on_state_change: None,
2782            on_terminate: None,
2783        };
2784        const EMPTY_IS_EMPTY: bool = EMPTY.is_empty();
2785        assert_eq!(
2786            EMPTY_IS_EMPTY,
2787            BehaviorSpec::default().is_empty(),
2788            "const-position dispatch on the empty BehaviorSpec must \
2789             yield the same bool as the runtime-position dispatch on \
2790             the sibling default() fixture",
2791        );
2792    }
2793
2794    #[test]
2795    fn behavior_spec_is_empty_agrees_with_declared_paths_across_all_slot_permutations() {
2796        // Pin the semantic-equivalence contract between the lifted
2797        // `pub const fn` [`BehaviorSpec::is_empty`] direct-field body
2798        // and the pre-lift iterator-based
2799        // `self.declared_paths().next().is_none()` chain across every
2800        // one of the 2^6 = 64 six-slot Some/None permutations of the
2801        // `BehaviorSpec` `Option<PathBuf>` fields. The direct-field
2802        // body and the iterator-based projection must agree bit-for-bit
2803        // on every permutation: `is_empty()` reads each raw
2804        // `Option<PathBuf>` field via `Option::is_none`; the iterator
2805        // projects each field through the paired `on_*()` accessor's
2806        // `Option::as_deref`, which preserves `Some`/`None` shape
2807        // regardless of the `Path` inside. A future slot addition that
2808        // grows `BehaviorSpec` without extending the direct-field
2809        // `&& self.<axis>.is_none()` chain in the `is_empty()` body
2810        // makes some subset of permutations disagree (the `declared_slots`
2811        // iterator picks up the new slot's `Some(_)` via the sibling
2812        // accessor / tuple-table extension while the direct-field body
2813        // silently ignores it), and this pin fires the exact permutation
2814        // that first disagrees, so the drift surfaces at caixa-core test
2815        // time at `behavior.rs`, not at a silent per-renderer emit-
2816        // empty-slot vs. skip-slot split far from the added-field
2817        // commit.
2818        let path = PathBuf::from("lib/callback.lisp");
2819        for mask in 0u8..64u8 {
2820            let bit = |i: u8| (mask & (1u8 << i)) != 0;
2821            let b = BehaviorSpec {
2822                on_init: bit(0).then(|| path.clone()),
2823                on_call: bit(1).then(|| path.clone()),
2824                on_cast: bit(2).then(|| path.clone()),
2825                on_info: bit(3).then(|| path.clone()),
2826                on_state_change: bit(4).then(|| path.clone()),
2827                on_terminate: bit(5).then(|| path.clone()),
2828            };
2829            assert_eq!(
2830                b.is_empty(),
2831                b.declared_paths().next().is_none(),
2832                "is_empty() disagrees with declared_paths().next().is_none() \
2833                 at slot-set mask {mask:#08b}"
2834            );
2835        }
2836    }
2837}