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