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