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::EmptyPath { 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#[cfg(test)]
944mod tests {
945    use super::*;
946    use crate::render::{
947        M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
948        M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
949        M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
950    };
951
952    #[test]
953    fn empty_behavior_round_trip() {
954        let b = BehaviorSpec::default();
955        assert!(b.is_empty());
956        let json = serde_json::to_string(&b).unwrap();
957        assert_eq!(json, "{}");
958        let back: BehaviorSpec = serde_json::from_str("{}").unwrap();
959        assert_eq!(back, b);
960    }
961
962    #[test]
963    fn full_behavior_round_trip_through_json() {
964        let b = BehaviorSpec {
965            on_init: Some(PathBuf::from("lib/init.lisp")),
966            on_call: Some(PathBuf::from("lib/handlers.lisp")),
967            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
968            on_info: Some(PathBuf::from("lib/handlers.lisp")),
969            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
970            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
971        };
972        let json = serde_json::to_string(&b).unwrap();
973        let back: BehaviorSpec = serde_json::from_str(&json).unwrap();
974        assert_eq!(b, back);
975    }
976
977    #[test]
978    fn partial_behavior_keeps_explicit_fields() {
979        let b = BehaviorSpec {
980            on_init: Some(PathBuf::from("lib/init.lisp")),
981            on_call: Some(PathBuf::from("lib/handlers.lisp")),
982            ..Default::default()
983        };
984        assert!(!b.is_empty());
985        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
986        assert_eq!(paths.len(), 2);
987        assert!(paths.contains(&PathBuf::from("lib/init.lisp")));
988        assert!(paths.contains(&PathBuf::from("lib/handlers.lisp")));
989    }
990
991    #[test]
992    fn declared_paths_skips_none() {
993        let b = BehaviorSpec {
994            on_init: Some(PathBuf::from("a.lisp")),
995            on_terminate: Some(PathBuf::from("b.lisp")),
996            ..Default::default()
997        };
998        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
999        assert_eq!(
1000            paths,
1001            vec![PathBuf::from("a.lisp"), PathBuf::from("b.lisp")]
1002        );
1003    }
1004
1005    #[test]
1006    fn json_keys_are_camelcase() {
1007        let b = BehaviorSpec {
1008            on_init: Some(PathBuf::from("init.lisp")),
1009            on_state_change: Some(PathBuf::from("mig.lisp")),
1010            ..Default::default()
1011        };
1012        let json = serde_json::to_string(&b).unwrap();
1013        assert!(json.contains("\"onInit\""));
1014        assert!(json.contains("\"onStateChange\""));
1015        assert!(!json.contains("\"on_init\""));
1016    }
1017
1018    #[test]
1019    fn deserialize_accepts_camelcase() {
1020        let json = r#"{"onInit":"a.lisp","onTerminate":"b.lisp"}"#;
1021        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
1022        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
1023        assert_eq!(b.on_terminate, Some(PathBuf::from("b.lisp")));
1024    }
1025
1026    // ── drift-detection: serde-derive-to-M2_BEHAVIOR_KEY_ON_* identity ────
1027
1028    #[test]
1029    fn behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts() {
1030        // Load-bearing invariant: the six `M2_BEHAVIOR_KEY_ON_*` consts
1031        // (`M2_BEHAVIOR_KEY_ON_INIT` / `M2_BEHAVIOR_KEY_ON_CALL` /
1032        // `M2_BEHAVIOR_KEY_ON_CAST` / `M2_BEHAVIOR_KEY_ON_INFO` /
1033        // `M2_BEHAVIOR_KEY_ON_STATE_CHANGE` /
1034        // `M2_BEHAVIOR_KEY_ON_TERMINATE`) name the exact camelCase JSON
1035        // keys the `#[serde(rename_all = "camelCase")]` attribute on
1036        // `BehaviorSpec` emits, and every test-side probe across the
1037        // caixa-core / caixa-flux / caixa-helm renderer test fixtures
1038        // navigates into the rendered `:behavior` overlay sub-block by
1039        // consulting one of these six `&'static str`s. Serialize a
1040        // fully-populated BehaviorSpec and pin that each canonical
1041        // byte-sequence appears verbatim in the JSON — a future
1042        // accidental `rename_all = "snake_case"` / `"kebab-case"` /
1043        // verbatim-field-name flip at the derive attribute (any of
1044        // which would silently break every test-side probe that reaches
1045        // for one of the six consts) surfaces here as a build-time test
1046        // failure at `behavior.rs`, not as an apply-time
1047        // `.get(<stale-canonical-const>)` returning `None` far from the
1048        // derive-attr drift's commit. Same discipline the sibling
1049        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
1050        // pin (d8b8b4f) established on the peer `:limits` sub-slot
1051        // axis: one canonical byte-string per typed sub-key axis,
1052        // pinned to the load-bearing serde derivation at the type
1053        // itself.
1054        let b = BehaviorSpec {
1055            on_init: Some(PathBuf::from("lib/init.lisp")),
1056            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1057            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1058            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1059            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1060            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1061        };
1062        let json = serde_json::to_string(&b).unwrap();
1063        for key in [
1064            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1065            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1066            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1067            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1068            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1069            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1070        ] {
1071            let quoted = format!("\"{key}\"");
1072            assert!(
1073                json.contains(&quoted),
1074                "serialized BehaviorSpec must carry the lifted \
1075                 M2_BEHAVIOR_KEY_ON_* byte-sequence {quoted} verbatim \
1076                 in the JSON emission (got: {json})",
1077            );
1078        }
1079    }
1080
1081    #[test]
1082    fn m2_behavior_key_consts_are_pairwise_distinct() {
1083        // Cross-axis drift-detection pin: a future collapse of two
1084        // canonical sub-key byte-strings onto the same value (e.g. an
1085        // accidental copy-paste flip of `M2_BEHAVIOR_KEY_ON_CAST` to
1086        // also read `"onCall"`) would silently reroute every test-side
1087        // probe on one axis onto the sibling axis's overlay entry and
1088        // pass every propagation-probe test that expected only the
1089        // stale axis's value. Peer of `m2_limits_key_consts_are_
1090        // pairwise_distinct` (d8b8b4f) on the sibling `:limits`
1091        // sub-slot axis.
1092        let all = [
1093            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1094            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1095            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1096            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1097            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1098            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1099        ];
1100        for (i, a) in all.iter().enumerate() {
1101            for b in all.iter().skip(i + 1) {
1102                assert_ne!(
1103                    a, b,
1104                    "M2_BEHAVIOR_KEY_ON_* consts must be pairwise-distinct \
1105                     canonical byte-sequences — got `{a}` == `{b}`",
1106                );
1107            }
1108        }
1109    }
1110
1111    #[test]
1112    fn m2_behavior_key_consts_are_lower_camel_case_shape() {
1113        // Shape-pin: every `M2_BEHAVIOR_KEY_ON_*` const must be a
1114        // lowerCamelCase byte-sequence (no `snake_case` underscores,
1115        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
1116        // whitespace / colons / dots) — the canonical shape the
1117        // `#[serde(rename_all = "camelCase")]` derive produces on
1118        // `BehaviorSpec`. A future flip to a non-camelCase attribute
1119        // at the derive surfaces both here (this test fails on the
1120        // stale-constant shape) and at
1121        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
1122        // (that test fails on the mismatch between const and derive).
1123        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
1124        // (d8b8b4f) on the sibling `:limits` sub-slot axis.
1125        for key in [
1126            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1127            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1128            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1129            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1130            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1131            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1132        ] {
1133            assert!(
1134                !key.is_empty(),
1135                "M2_BEHAVIOR_KEY_ON_* must be non-empty (got {key:?})"
1136            );
1137            let first = key.chars().next().unwrap();
1138            assert!(
1139                first.is_ascii_lowercase(),
1140                "M2_BEHAVIOR_KEY_ON_* must lead with an ASCII-lowercase \
1141                 byte (got {key:?}, leads with {first:?})",
1142            );
1143            assert!(
1144                key.chars().all(|c| c.is_ascii_alphanumeric()),
1145                "M2_BEHAVIOR_KEY_ON_* must be ASCII-alphanumeric only \
1146                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn deserialize_omits_unknown_fields_via_default() {
1153        // Forward-compatible: a future caixa.lisp with extra fields
1154        // round-trips without losing the known ones.
1155        let json = r#"{"onInit":"a.lisp"}"#;
1156        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
1157        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
1158        assert!(b.on_call.is_none());
1159    }
1160
1161    // ── value-shape invariants on declared callback paths ──────────
1162
1163    #[test]
1164    fn validate_default_is_ok() {
1165        BehaviorSpec::default().validate().unwrap();
1166    }
1167
1168    #[test]
1169    fn validate_every_slot_relative_is_ok() {
1170        let b = BehaviorSpec {
1171            on_init: Some(PathBuf::from("lib/init.lisp")),
1172            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1173            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1174            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1175            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1176            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1177        };
1178        b.validate().unwrap();
1179    }
1180
1181    #[test]
1182    fn validate_rejects_empty_path_per_slot() {
1183        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1184            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1185                on_init: Some(p),
1186                ..Default::default()
1187            }),
1188            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1189                on_call: Some(p),
1190                ..Default::default()
1191            }),
1192            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1193                on_cast: Some(p),
1194                ..Default::default()
1195            }),
1196            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1197                on_info: Some(p),
1198                ..Default::default()
1199            }),
1200            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1201                on_state_change: Some(p),
1202                ..Default::default()
1203            }),
1204            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1205                on_terminate: Some(p),
1206                ..Default::default()
1207            }),
1208        ];
1209        for (expected_slot, build) in cases {
1210            let err = build(PathBuf::new()).validate().unwrap_err();
1211            assert!(
1212                matches!(err, BehaviorError::EmptyPath { slot } if slot == expected_slot),
1213                "slot {expected_slot}: got {err:?}",
1214            );
1215        }
1216    }
1217
1218    #[test]
1219    fn validate_rejects_absolute_path() {
1220        let b = BehaviorSpec {
1221            on_init: Some(PathBuf::from("/etc/passwd")),
1222            ..Default::default()
1223        };
1224        let err = b.validate().unwrap_err();
1225        assert!(matches!(
1226            err,
1227            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1228        ));
1229    }
1230
1231    #[test]
1232    fn validate_rejects_parent_escape() {
1233        let b = BehaviorSpec {
1234            on_state_change: Some(PathBuf::from("../sibling/migrations.lisp")),
1235            ..Default::default()
1236        };
1237        let err = b.validate().unwrap_err();
1238        assert!(matches!(
1239            err,
1240            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE
1241        ));
1242    }
1243
1244    #[test]
1245    fn validate_rejects_parent_escape_mid_path() {
1246        // `lib/../../escaped.lisp` is still a parent-traversal — must
1247        // be caught regardless of where the `..` component sits.
1248        let b = BehaviorSpec {
1249            on_terminate: Some(PathBuf::from("lib/../../escaped.lisp")),
1250            ..Default::default()
1251        };
1252        let err = b.validate().unwrap_err();
1253        assert!(matches!(
1254            err,
1255            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE
1256        ));
1257    }
1258
1259    #[test]
1260    fn validate_diagnostic_order_is_deterministic() {
1261        // Multiple bad slots — the first declared (`:on-init`) wins
1262        // so authors see a stable, single-slot diagnostic.
1263        let b = BehaviorSpec {
1264            on_init: Some(PathBuf::new()),
1265            on_call: Some(PathBuf::from("/etc/passwd")),
1266            on_terminate: Some(PathBuf::from("../escape.lisp")),
1267            ..Default::default()
1268        };
1269        let err = b.validate().unwrap_err();
1270        assert!(matches!(
1271            err,
1272            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1273        ));
1274    }
1275
1276    // ── `.lisp`-extension gate on every `:behavior :on-*` axis ─────
1277
1278    #[test]
1279    fn validate_rejects_non_lisp_extension_per_slot() {
1280        // Loop the same offending non-`.lisp` path through every M2
1281        // typed `:behavior` slot — the diagnostic must name the
1282        // offending slot, not collapse to a generic "bad extension"
1283        // shape. Same per-slot diagnostic posture every peer
1284        // `BehaviorError` arm carries (`EmptyPath`, `AbsolutePath`,
1285        // `ParentEscape`).
1286        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1287            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1288                on_init: Some(p),
1289                ..Default::default()
1290            }),
1291            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1292                on_call: Some(p),
1293                ..Default::default()
1294            }),
1295            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1296                on_cast: Some(p),
1297                ..Default::default()
1298            }),
1299            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1300                on_info: Some(p),
1301                ..Default::default()
1302            }),
1303            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1304                on_state_change: Some(p),
1305                ..Default::default()
1306            }),
1307            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1308                on_terminate: Some(p),
1309                ..Default::default()
1310            }),
1311        ];
1312        let path = PathBuf::from("lib/init.txt");
1313        for (expected_slot, build) in cases {
1314            let err = build(path.clone()).validate().unwrap_err();
1315            assert!(
1316                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1317                    if *slot == expected_slot && p == &path),
1318                "slot {expected_slot}: got {err:?}",
1319            );
1320        }
1321    }
1322
1323    #[test]
1324    fn validate_rejects_no_extension() {
1325        // The no-extension shape — author dropped the suffix entirely
1326        // (`lib/init` instead of `lib/init.lisp`). `Path::extension`
1327        // returns None, so the typed check distinguishes this from
1328        // the wrong-extension shape and from a leading-dot file like
1329        // `.lisp` (which also has no `Path::extension`).
1330        let cases = [
1331            PathBuf::from("lib/init"),
1332            PathBuf::from("lib/handlers"),
1333            PathBuf::from("init"),
1334        ];
1335        for path in cases {
1336            let b = BehaviorSpec {
1337                on_init: Some(path.clone()),
1338                ..Default::default()
1339            };
1340            let err = b.validate().unwrap_err();
1341            assert!(
1342                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1343                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1344                "no-extension path {path:?}: got {err:?}",
1345            );
1346        }
1347    }
1348
1349    #[test]
1350    fn validate_rejects_wrong_extension() {
1351        // Common authoring footguns — files that pass every prior
1352        // path-shape gate but that the wasm-engine's tatara-lisp
1353        // reader cannot consume as source.
1354        let cases = [
1355            PathBuf::from("lib/init.rs"),
1356            PathBuf::from("lib/init.txt"),
1357            PathBuf::from("lib/init.md"),
1358            PathBuf::from("lib/init.json"),
1359            PathBuf::from("lib/init.yaml"),
1360            PathBuf::from("lib/init.lisp.bak"),
1361            PathBuf::from("lib/init.lispx"),
1362        ];
1363        for path in cases {
1364            let b = BehaviorSpec {
1365                on_call: Some(path.clone()),
1366                ..Default::default()
1367            };
1368            let err = b.validate().unwrap_err();
1369            assert!(
1370                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1371                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_CALL && p == &path),
1372                "wrong-extension path {path:?}: got {err:?}",
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn validate_rejects_uppercase_lisp_extension() {
1379        // The strict-lowercase posture matches every other shape
1380        // predicate in `render.rs` — the byte-size codec is
1381        // case-sensitive on `MiB`, the duration codec on `ms` / `s` /
1382        // `m` / `h`, every DNS-1123 label is lowercase-only — so the
1383        // accepted set for `.lisp` does not silently fold to `.LISP` /
1384        // `.Lisp` / `.LiSp` even on case-insensitive volumes. A path
1385        // whose existence check would match the on-disk file via
1386        // case-insensitive lookup would still mismatch the canonical
1387        // form the codec emits, breaking the round-trip-stability
1388        // contract.
1389        let cases = [
1390            PathBuf::from("lib/init.LISP"),
1391            PathBuf::from("lib/init.Lisp"),
1392            PathBuf::from("lib/init.LiSp"),
1393        ];
1394        for path in cases {
1395            let b = BehaviorSpec {
1396                on_init: Some(path.clone()),
1397                ..Default::default()
1398            };
1399            let err = b.validate().unwrap_err();
1400            assert!(
1401                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1402                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1403                "uppercase `.lisp` {path:?}: got {err:?}",
1404            );
1405        }
1406    }
1407
1408    #[test]
1409    fn validate_accepts_canonical_lisp_paths() {
1410        // Positive control: every shape the in-tree fixtures and
1411        // module-doc examples use must pass the gate. Pins the
1412        // accepted set so a future tightening doesn't accidentally
1413        // reject the canonical authoring shape.
1414        let cases = [
1415            PathBuf::from("lib/init.lisp"),
1416            PathBuf::from("lib/handlers.lisp"),
1417            PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1418            PathBuf::from("init.lisp"),
1419            PathBuf::from("a.lisp"),
1420            PathBuf::from("./lib/init.lisp"),
1421            PathBuf::from("lib/./handlers.lisp"),
1422            PathBuf::from("lib/migrations/v.0.1.lisp"),
1423        ];
1424        for path in cases {
1425            let b = BehaviorSpec {
1426                on_init: Some(path.clone()),
1427                ..Default::default()
1428            };
1429            b.validate()
1430                .unwrap_or_else(|e| panic!("canonical `.lisp` path {path:?} must pass: {e:?}"));
1431        }
1432    }
1433
1434    #[test]
1435    fn validate_path_shape_precedes_extension_arm() {
1436        // Cross-arm ordering: a path that is *both* path-shape invalid
1437        // (empty / absolute / parent-escape) and non-`.lisp` surfaces
1438        // the more fundamental sandbox-shape diagnostic first — the
1439        // `.lisp` remediation is misleading when the offending path
1440        // can never resolve under the caixa root anyway. Mirrors the
1441        // `MemoryZero` → `MemoryBelowWasm32Page` → `MemoryExceedsWasm32Cap`
1442        // → `MemoryNotPageMultiple` smallest-scope-last cascade on the
1443        // peer `:limits :memory` axis.
1444
1445        // Empty + non-`.lisp` → empty wins (the empty case has no
1446        // extension to begin with).
1447        let b = BehaviorSpec {
1448            on_init: Some(PathBuf::new()),
1449            ..Default::default()
1450        };
1451        assert!(matches!(
1452            b.validate().unwrap_err(),
1453            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1454        ));
1455
1456        // Absolute + non-`.lisp` → absolute wins.
1457        let b = BehaviorSpec {
1458            on_init: Some(PathBuf::from("/etc/init.txt")),
1459            ..Default::default()
1460        };
1461        assert!(matches!(
1462            b.validate().unwrap_err(),
1463            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1464        ));
1465
1466        // Parent-escape + non-`.lisp` → parent-escape wins.
1467        let b = BehaviorSpec {
1468            on_init: Some(PathBuf::from("../sibling/init.txt")),
1469            ..Default::default()
1470        };
1471        assert!(matches!(
1472            b.validate().unwrap_err(),
1473            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1474        ));
1475    }
1476
1477    #[test]
1478    fn validate_extension_diagnostic_names_offending_slot_and_path() {
1479        // Self-locating diagnostic pin: the surfaced error names the
1480        // exact `:on-*` slot the author wrote and the exact offending
1481        // path verbatim, so the author can grep their caixa.lisp for
1482        // the named slot / path and fix it in one edit. Same shape
1483        // every peer per-slot `:behavior` arm exposes.
1484        let path = PathBuf::from("lib/handlers.rs");
1485        let b = BehaviorSpec {
1486            on_cast: Some(path.clone()),
1487            ..Default::default()
1488        };
1489        let err = b.validate().unwrap_err();
1490        let rendered = err.to_string();
1491        assert!(
1492            rendered.contains(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST),
1493            "diagnostic must name the `:on-cast` slot: {rendered}"
1494        );
1495        assert!(
1496            rendered.contains("lib/handlers.rs"),
1497            "diagnostic must carry the offending path verbatim: {rendered}"
1498        );
1499        assert!(
1500            rendered.contains(".lisp"),
1501            "diagnostic must name the expected `.lisp` extension: {rendered}"
1502        );
1503    }
1504
1505    #[test]
1506    fn validate_extension_arm_fires_across_multi_malformed_manifest_in_slot_order() {
1507        // Multi-malformed manifest, all four slots carrying a
1508        // non-`.lisp` extension — the first declared slot
1509        // (`:on-init`) wins, mirroring the prior
1510        // `validate_diagnostic_order_is_deterministic` pin on the
1511        // path-shape arms.
1512        let b = BehaviorSpec {
1513            on_init: Some(PathBuf::from("lib/init.rs")),
1514            on_call: Some(PathBuf::from("lib/handlers.txt")),
1515            on_state_change: Some(PathBuf::from("lib/migrations.md")),
1516            ..Default::default()
1517        };
1518        let err = b.validate().unwrap_err();
1519        assert!(matches!(
1520            &err,
1521            BehaviorError::NonLispExtension { slot, path }
1522                if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1523                    && path == &PathBuf::from("lib/init.rs")
1524        ));
1525    }
1526
1527    #[test]
1528    fn m2_behavior_author_key_consts_pin_canonical_kebab_case_labels() {
1529        // Scalar-value pin: the six author-facing kebab-case labels the
1530        // `(defcaixa … :behavior (:on-* …))` surface admits, one arm per
1531        // sub-slot. Mirrors the peer scalar-value pin the sibling
1532        // renderer-side [`crate::M2_BEHAVIOR_KEY_ON_*`] camelCase consts
1533        // carry (21fe462), so both halves of the M2 `:behavior` sub-slot
1534        // dual axis (author-facing kebab-case label + renderer-side
1535        // camelCase wire key) route through one canonical per-arm
1536        // declaration. A future rebrand (`:on-init` → `:on-start`
1537        // matching Akka's per-actor preStart naming, `:on-state-change`
1538        // → `:on-code-change` matching Erlang's verbatim `code_change/3`
1539        // name) lands as an edit to exactly one const, and every
1540        // consumer that reaches for the label picks it up at build time
1541        // rather than at runtime as a downstream mismatch.
1542        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, ":on-init");
1543        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, ":on-call");
1544        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, ":on-cast");
1545        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, ":on-info");
1546        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, ":on-state-change");
1547        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, ":on-terminate");
1548    }
1549
1550    #[test]
1551    fn declared_slots_labels_route_through_lifted_author_key_consts() {
1552        // Production-through-const pin: the six per-arm labels
1553        // [`BehaviorSpec::declared_slots`] threads through as the
1554        // `(slot, path)` iterator's first component route through the
1555        // lifted [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] consts, in
1556        // declaration order. A future re-order or drift at the tagger
1557        // (a rename that reaches the tagger but not the const, or vice
1558        // versa) surfaces here at build time rather than at runtime as
1559        // a diagnostic naming a stale kebab-case slot far from the
1560        // rename's commit.
1561        let b = BehaviorSpec {
1562            on_init: Some(PathBuf::from("a.lisp")),
1563            on_call: Some(PathBuf::from("b.lisp")),
1564            on_cast: Some(PathBuf::from("c.lisp")),
1565            on_info: Some(PathBuf::from("d.lisp")),
1566            on_state_change: Some(PathBuf::from("e.lisp")),
1567            on_terminate: Some(PathBuf::from("f.lisp")),
1568        };
1569        let labels: Vec<&'static str> = b.declared_slots().map(|(s, _)| s).collect();
1570        assert_eq!(
1571            labels,
1572            vec![
1573                M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
1574                M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
1575                M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
1576                M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
1577                M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1578                M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1579            ]
1580        );
1581    }
1582
1583    #[test]
1584    fn declared_slots_paths_route_through_lifted_on_star_accessors() {
1585        // Production-through-accessor pin: the six per-arm
1586        // `Option<&Path>` path-values [`BehaviorSpec::declared_slots`]
1587        // threads through as the `(slot, path)` iterator's second
1588        // component route through the lifted per-slot
1589        // [`BehaviorSpec::on_init`] / [`BehaviorSpec::on_call`] /
1590        // [`BehaviorSpec::on_cast`] / [`BehaviorSpec::on_info`] /
1591        // [`BehaviorSpec::on_state_change`] / [`BehaviorSpec::on_terminate`]
1592        // accessors, so every future accessor-side extension of an
1593        // `:on-*` slot (a per-prior-`:versao` state-migration callback
1594        // the operator pins through a future `:behavior
1595        // :on-state-change-overrides` slot the
1596        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
1597        // callback-dispatch wire acknowledges, a per-tenant callback
1598        // alias table the M4 CR materializer resolves per-CR, a
1599        // per-cluster callback overlay the operator pins through a
1600        // future placement-scoped slot) reaches both production
1601        // consumers of the iterator (the layout checker's existence
1602        // sweep in `layout.rs` + the sibling [`BehaviorSpec::validate`]
1603        // value-shape gate) by construction, without a coordinated
1604        // rewrite of the iterator's six raw-field-access sites and the
1605        // six accessor bodies in lockstep. Peer of the sibling
1606        // `declared_slots_labels_route_through_lifted_author_key_consts`
1607        // pin on the tag-surface axis — same "one typed dispatch on the
1608        // substrate primitive, thin projections at each consumer"
1609        // discipline extended onto the peer per-arm `Option<&Path>`
1610        // path-value axis.
1611        //
1612        // Byte-equal today (each `on_*()` accessor is a thin
1613        // `.as_deref()` on the raw `Option<PathBuf>` field); the pin
1614        // catches any future accessor-side extension whose iterator
1615        // read regresses to the raw field.
1616        let b = BehaviorSpec {
1617            on_init: Some(PathBuf::from("lib/init.lisp")),
1618            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1619            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1620            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1621            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1622            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1623        };
1624        let entries: Vec<(&'static str, &Path)> = b.declared_slots().collect();
1625        assert_eq!(
1626            entries,
1627            vec![
1628                (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, b.on_init().unwrap()),
1629                (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, b.on_call().unwrap()),
1630                (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, b.on_cast().unwrap()),
1631                (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, b.on_info().unwrap()),
1632                (
1633                    M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1634                    b.on_state_change().unwrap(),
1635                ),
1636                (
1637                    M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1638                    b.on_terminate().unwrap(),
1639                ),
1640            ],
1641            "declared_slots must route each of its six per-arm \
1642             Option<&Path> path-values through the sibling lifted \
1643             BehaviorSpec::on_* accessor for its slot, so future \
1644             accessor-side extensions reach both the layout checker \
1645             + validate gate by construction (got {entries:?})",
1646        );
1647    }
1648
1649    #[test]
1650    fn declared_paths_routes_through_lifted_on_star_accessors() {
1651        // Sibling of `declared_slots_paths_route_through_lifted_
1652        // on_star_accessors` on the peer path-only projection axis:
1653        // [`BehaviorSpec::declared_paths`] must project each declared
1654        // callback path through the lifted per-slot
1655        // [`BehaviorSpec::on_*`] accessor, so the layout checker's
1656        // `for p in b.declared_paths() { root.join(p) }` on-disk
1657        // existence sweep at `layout.rs:901` reaches every future
1658        // accessor-side extension without a coordinated rewrite of the
1659        // sibling `declared_slots` internal iterator + the accessor
1660        // bodies in lockstep.
1661        let b = BehaviorSpec {
1662            on_init: Some(PathBuf::from("lib/init.lisp")),
1663            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1664            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1665            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1666            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1667            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1668        };
1669        let paths: Vec<&Path> = b.declared_paths().collect();
1670        assert_eq!(
1671            paths,
1672            vec![
1673                b.on_init().unwrap(),
1674                b.on_call().unwrap(),
1675                b.on_cast().unwrap(),
1676                b.on_info().unwrap(),
1677                b.on_state_change().unwrap(),
1678                b.on_terminate().unwrap(),
1679            ],
1680            "declared_paths must project each callback path through \
1681             the sibling lifted BehaviorSpec::on_* accessor for its \
1682             slot (got {paths:?})",
1683        );
1684    }
1685
1686    // ── per-`:behavior :on-state-change` accessor pins ─────────────────────
1687
1688    #[test]
1689    fn behavior_on_state_change_returns_option_path_verbatim_across_permutations() {
1690        // Canonical per-`:behavior` `:on-state-change` OTP-`code_change/3`-
1691        // shaped callback-path scalar pin: [`BehaviorSpec::on_state_change`]
1692        // must return the `:behavior :on-state-change` typed `PathBuf`
1693        // verbatim as an `Option<&Path>`, borrowed from the raw
1694        // `Option<PathBuf>` field access across the three canonical
1695        // shape-arms — `None` (no callback declared — the caixa exposes
1696        // no hot-upgrade state-fold path), `Some("lib/migrations.lisp")`
1697        // (the canonical single-file shape the module-doc example uses),
1698        // `Some("lib/migrations/v01-to-v02.lisp")` (the per-version
1699        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1700        // wasm-engine callback-dispatch wire acknowledges).
1701        //
1702        // Peer of the sibling per-`:placement` [`crate::Placement::shard_key`]
1703        // (7cd2a28) / [`crate::Placement::affinity`] (74ec2d3)
1704        // `Option<&str>` accessor pin on the sibling `Option<Str>`-return
1705        // axis, extended to the peer per-`:behavior` typed-`PathBuf`
1706        // optional-scalar shape — first `Option<&Path>`-return accessor
1707        // on the M2 `:behavior` slot family. Pins against a future silent
1708        // detour that re-derived the callback path from a peer axis (an
1709        // accidental `.on_call`-collapse that assumed the two
1710        // `Option<PathBuf>` axes carry the same value), a `None` →
1711        // `Some(empty)` collapse (the canonical
1712        // `Option<PathBuf>` → `PathBuf::new()` footgun the
1713        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
1714        // path-shape axis), or a per-arm variant swap that landed on one
1715        // consumer without the other.
1716        for path in [
1717            None,
1718            Some(PathBuf::from("lib/migrations.lisp")),
1719            Some(PathBuf::from("lib/migrations/v01-to-v02.lisp")),
1720        ] {
1721            let b = BehaviorSpec {
1722                on_state_change: path.clone(),
1723                ..BehaviorSpec::default()
1724            };
1725            assert_eq!(
1726                b.on_state_change(),
1727                path.as_deref(),
1728                "BehaviorSpec::on_state_change must return the \
1729                 :behavior :on-state-change PathBuf verbatim as \
1730                 Option<&Path> (got {:?}, expected {:?})",
1731                b.on_state_change(),
1732                path.as_deref(),
1733            );
1734            assert_eq!(
1735                b.on_state_change(),
1736                b.on_state_change.as_deref(),
1737                "BehaviorSpec::on_state_change must byte-equal the \
1738                 raw .on_state_change.as_deref() field access across \
1739                 every value in the accept-set",
1740            );
1741        }
1742    }
1743
1744    #[test]
1745    fn behavior_on_state_change_is_independent_of_peer_on_star_axes() {
1746        // Cross-axis independence pin: flipping only the
1747        // `:on-state-change` axis flips [`BehaviorSpec::on_state_change`]
1748        // independently of every peer `:on-*` axis
1749        // (`:on-init` / `:on-call` / `:on-cast` / `:on-info` /
1750        // `:on-terminate`). A future silent detour that re-derived the
1751        // callback path from a peer axis (an accidental `.on_call`-
1752        // collapse, a "state-change falls back to on-info" default that
1753        // would silently rebind the callback dispatch to the wrong
1754        // slot) surfaces here as a build-time test failure.
1755        //
1756        // Mirrors the sibling `limits_is_empty_memory_arm_routes_through_accessor`
1757        // (620c067) cross-axis pin on the peer M2 `:limits` slot
1758        // family — each accessor-lift closes exactly one axis and
1759        // leaves every peer axis unshifted.
1760        let base = BehaviorSpec {
1761            on_init: Some(PathBuf::from("lib/init.lisp")),
1762            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1763            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1764            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1765            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1766            ..BehaviorSpec::default()
1767        };
1768        assert_eq!(base.on_state_change(), None);
1769        let with = BehaviorSpec {
1770            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1771            ..base.clone()
1772        };
1773        assert_eq!(
1774            with.on_state_change(),
1775            Some(PathBuf::from("lib/migrations.lisp").as_path()),
1776            "BehaviorSpec::on_state_change must project the \
1777             :on-state-change axis independently of every peer :on-* \
1778             axis (got {:?})",
1779            with.on_state_change(),
1780        );
1781    }
1782
1783    #[test]
1784    fn validate_upgrade_from_against_behavior_routes_through_on_state_change_accessor() {
1785        // Production-through-const pin: the sole caixa-core consumer of
1786        // the accessor's `Option<&Path>` presence — the
1787        // [`crate::validate_upgrade_from_against_behavior`] cross-slot
1788        // composition gate — must route through
1789        // [`BehaviorSpec::on_state_change`] rather than the raw
1790        // `.on_state_change` field, so the gate's short-circuit and
1791        // every future accessor-side extension (a per-prior-`:versao`
1792        // callback override, a per-tenant migration alias table) land
1793        // as one edit at the accessor rather than as a coordinated
1794        // two-site rewrite of the gate + accessor.
1795        //
1796        // Peer of the sibling `declared_slots_labels_route_through_
1797        // lifted_author_key_consts` (production-through-const pin on
1798        // the label surface) and the sibling
1799        // `limits_is_empty_memory_arm_routes_through_accessor`
1800        // (620c067) pin on the peer M2 `:limits` slot family.
1801        //
1802        // Positive control: a `:behavior :on-state-change` callback
1803        // declared + a `:upgrade-from` entry carrying a
1804        // `(:state-change …)` instruction admits, because the accessor
1805        // returns `Some(&Path)` and the gate's short-circuit fires.
1806        let entries = vec![crate::UpgradeFromEntry {
1807            from: "0.1.0".to_string(),
1808            instructions: vec![
1809                crate::UpgradeInstruction::LoadModule {
1810                    module: "codec".to_string(),
1811                },
1812                crate::UpgradeInstruction::StateChange {
1813                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1814                },
1815            ],
1816        }];
1817        let b = BehaviorSpec {
1818            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1819            ..BehaviorSpec::default()
1820        };
1821        assert!(b.on_state_change().is_some());
1822        crate::validate_upgrade_from_against_behavior(&entries, Some(&b))
1823            .expect("callback declared → gate admits");
1824
1825        // Negative control: dropping only the accessor's slot to `None`
1826        // (with the same `:upgrade-from` entry) flips the gate to
1827        // refusal — the accessor's `None` return is the sole predicate
1828        // the short-circuit reads.
1829        let b_no_cb = BehaviorSpec::default();
1830        assert_eq!(b_no_cb.on_state_change(), None);
1831        let err = crate::validate_upgrade_from_against_behavior(&entries, Some(&b_no_cb))
1832            .expect_err(":state-change instruction without callback → refuse");
1833        assert!(matches!(
1834            err,
1835            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1836        ));
1837
1838        // `behavior: None` is the same refusal shape — the accessor
1839        // isn't reached, but `Option::and_then` on `None` short-circuits
1840        // to `None`, so the diagnostic is identical.
1841        let err = crate::validate_upgrade_from_against_behavior(&entries, None)
1842            .expect_err("behavior absent + :state-change instruction → refuse");
1843        assert!(matches!(
1844            err,
1845            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1846        ));
1847    }
1848
1849    // ── per-`:behavior :on-init` accessor pins ─────────────────────
1850
1851    #[test]
1852    fn behavior_on_init_returns_option_path_verbatim_across_permutations() {
1853        // Canonical per-`:behavior` `:on-init` OTP-`init/1`-shaped
1854        // callback-path scalar pin: [`BehaviorSpec::on_init`] must
1855        // return the `:behavior :on-init` typed `PathBuf` verbatim as
1856        // an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
1857        // field access across the three canonical shape-arms — `None`
1858        // (no callback declared — the runtime falls back to the
1859        // wasm-engine's no-op instance-start default), `Some("lib/init.lisp")`
1860        // (the canonical single-file shape the module-doc example
1861        // uses), `Some("lib/lifecycle/init.lisp")` (the per-lifecycle
1862        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1863        // wasm-engine callback-dispatch wire acknowledges).
1864        //
1865        // Peer of the sibling per-`:behavior` [`BehaviorSpec::on_state_change`]
1866        // (9b4ecde) `Option<&Path>` accessor pin on the sibling
1867        // `Option<PathBuf>`-return axis — second `Option<&Path>`-return
1868        // accessor on the M2 `:behavior` slot family. Pins against a
1869        // future silent detour that re-derived the callback path from a
1870        // peer axis (an accidental `.on_call`-collapse that assumed the
1871        // two `Option<PathBuf>` axes carry the same value), a `None` →
1872        // `Some(empty)` collapse (the canonical
1873        // `Option<PathBuf>` → `PathBuf::new()` footgun the
1874        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
1875        // path-shape axis), or a per-arm variant swap that landed on
1876        // one consumer without the other.
1877        for path in [
1878            None,
1879            Some(PathBuf::from("lib/init.lisp")),
1880            Some(PathBuf::from("lib/lifecycle/init.lisp")),
1881        ] {
1882            let b = BehaviorSpec {
1883                on_init: path.clone(),
1884                ..BehaviorSpec::default()
1885            };
1886            assert_eq!(
1887                b.on_init(),
1888                path.as_deref(),
1889                "BehaviorSpec::on_init must return the \
1890                 :behavior :on-init PathBuf verbatim as \
1891                 Option<&Path> (got {:?}, expected {:?})",
1892                b.on_init(),
1893                path.as_deref(),
1894            );
1895            assert_eq!(
1896                b.on_init(),
1897                b.on_init.as_deref(),
1898                "BehaviorSpec::on_init must byte-equal the \
1899                 raw .on_init.as_deref() field access across \
1900                 every value in the accept-set",
1901            );
1902        }
1903    }
1904
1905    #[test]
1906    fn behavior_on_init_is_independent_of_peer_on_star_axes() {
1907        // Cross-axis independence pin: flipping only the `:on-init`
1908        // axis flips [`BehaviorSpec::on_init`] independently of every
1909        // peer `:on-*` axis (`:on-call` / `:on-cast` / `:on-info` /
1910        // `:on-state-change` / `:on-terminate`). A future silent detour
1911        // that re-derived the callback path from a peer axis (an
1912        // accidental `.on_call`-collapse, a "init falls back to
1913        // state-change" default that would silently rebind the callback
1914        // dispatch to the wrong slot) surfaces here as a build-time
1915        // test failure.
1916        //
1917        // Peer of the sibling
1918        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
1919        // (9b4ecde) cross-axis pin on the sibling
1920        // `:on-state-change` axis — each accessor-lift closes exactly
1921        // one axis and leaves every peer axis unshifted.
1922        let base = BehaviorSpec {
1923            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1924            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1925            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1926            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1927            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1928            ..BehaviorSpec::default()
1929        };
1930        assert_eq!(base.on_init(), None);
1931        let with = BehaviorSpec {
1932            on_init: Some(PathBuf::from("lib/init.lisp")),
1933            ..base.clone()
1934        };
1935        assert_eq!(
1936            with.on_init(),
1937            Some(PathBuf::from("lib/init.lisp").as_path()),
1938            "BehaviorSpec::on_init must project the \
1939             :on-init axis independently of every peer :on-* \
1940             axis (got {:?})",
1941            with.on_init(),
1942        );
1943    }
1944
1945    // ── per-`:behavior :on-call` accessor pins ─────────────────────
1946
1947    #[test]
1948    fn behavior_on_call_returns_option_path_verbatim_across_permutations() {
1949        // Canonical per-`:behavior` `:on-call`
1950        // OTP-`gen_server:handle_call/3`-shaped synchronous
1951        // request/response callback-path scalar pin:
1952        // [`BehaviorSpec::on_call`] must return the `:behavior :on-call`
1953        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
1954        // the raw `Option<PathBuf>` field access across the three
1955        // canonical shape-arms — `None` (no callback declared — the
1956        // runtime falls back to the wasm-engine's raw
1957        // `wasi:http/incoming-handler` default), `Some("lib/handlers.lisp")`
1958        // (the canonical single-file shape the module-doc example
1959        // uses), `Some("lib/rpc/call.lisp")` (the per-dispatch
1960        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1961        // wasm-engine callback-dispatch wire acknowledges).
1962        //
1963        // Peer of the sibling per-`:behavior`
1964        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
1965        // [`BehaviorSpec::on_init`] (d66c702) `Option<&Path>` accessor
1966        // pins on the sibling `Option<PathBuf>`-return axes — third
1967        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
1968        // family. Pins against a future silent detour that re-derived
1969        // the callback path from a peer axis (an accidental
1970        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
1971        // axes carry the same value — a plausible slip because the
1972        // module-doc example shares one `lib/handlers.lisp` file
1973        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
1974        // that the tatara-lisp dispatch inside the file discriminates
1975        // on the callback-kind atom), a `None` → `Some(empty)` collapse
1976        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
1977        // the [`BehaviorError::EmptyPath`] validate arm guards on the
1978        // peer path-shape axis), or a per-arm variant swap that landed
1979        // on one consumer without the other.
1980        for path in [
1981            None,
1982            Some(PathBuf::from("lib/handlers.lisp")),
1983            Some(PathBuf::from("lib/rpc/call.lisp")),
1984        ] {
1985            let b = BehaviorSpec {
1986                on_call: path.clone(),
1987                ..BehaviorSpec::default()
1988            };
1989            assert_eq!(
1990                b.on_call(),
1991                path.as_deref(),
1992                "BehaviorSpec::on_call must return the \
1993                 :behavior :on-call PathBuf verbatim as \
1994                 Option<&Path> (got {:?}, expected {:?})",
1995                b.on_call(),
1996                path.as_deref(),
1997            );
1998            assert_eq!(
1999                b.on_call(),
2000                b.on_call.as_deref(),
2001                "BehaviorSpec::on_call must byte-equal the \
2002                 raw .on_call.as_deref() field access across \
2003                 every value in the accept-set",
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn behavior_on_call_is_independent_of_peer_on_star_axes() {
2010        // Cross-axis independence pin: flipping only the `:on-call`
2011        // axis flips [`BehaviorSpec::on_call`] independently of every
2012        // peer `:on-*` axis (`:on-init` / `:on-cast` / `:on-info` /
2013        // `:on-state-change` / `:on-terminate`). A future silent detour
2014        // that re-derived the callback path from a peer axis (an
2015        // accidental `.on_cast`-collapse — the sibling asynchronous
2016        // fire-and-forget arm on the peer OTP dispatch triad, a
2017        // plausible confusion because both callbacks share the
2018        // `handle_*/2|3` OTP shape — that would silently rebind the
2019        // synchronous request/response dispatch to the sibling
2020        // fire-and-forget slot's callback) surfaces here as a
2021        // build-time test failure.
2022        //
2023        // Peer of the sibling
2024        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2025        // (9b4ecde) /
2026        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2027        // (d66c702) cross-axis pins on the sibling `:on-state-change` /
2028        // `:on-init` axes — each accessor-lift closes exactly one axis
2029        // and leaves every peer axis unshifted.
2030        let base = BehaviorSpec {
2031            on_init: Some(PathBuf::from("lib/init.lisp")),
2032            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2033            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2034            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2035            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2036            ..BehaviorSpec::default()
2037        };
2038        assert_eq!(base.on_call(), None);
2039        let with = BehaviorSpec {
2040            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2041            ..base.clone()
2042        };
2043        assert_eq!(
2044            with.on_call(),
2045            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2046            "BehaviorSpec::on_call must project the \
2047             :on-call axis independently of every peer :on-* \
2048             axis (got {:?})",
2049            with.on_call(),
2050        );
2051    }
2052
2053    // ── per-`:behavior :on-cast` accessor pins ─────────────────────
2054
2055    #[test]
2056    fn behavior_on_cast_returns_option_path_verbatim_across_permutations() {
2057        // Canonical per-`:behavior` `:on-cast`
2058        // OTP-`gen_server:handle_cast/2`-shaped asynchronous
2059        // fire-and-forget callback-path scalar pin:
2060        // [`BehaviorSpec::on_cast`] must return the `:behavior :on-cast`
2061        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2062        // the raw `Option<PathBuf>` field access across the three
2063        // canonical shape-arms — `None` (no callback declared — the
2064        // runtime falls back to the wasm-engine's default `Accepted:
2065        // 202` fire-and-forget shape), `Some("lib/handlers.lisp")` (the
2066        // canonical single-file shape the module-doc example uses,
2067        // shared with `:on-call` / `:on-info` on the pattern that the
2068        // tatara-lisp dispatch inside the file discriminates on the
2069        // callback-kind atom), `Some("lib/rpc/cast.lisp")` (the
2070        // per-dispatch sub-directory shape the
2071        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2072        // callback-dispatch wire acknowledges).
2073        //
2074        // Peer of the sibling per-`:behavior`
2075        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2076        // [`BehaviorSpec::on_init`] (d66c702) /
2077        // [`BehaviorSpec::on_call`] (156ddbe) `Option<&Path>` accessor
2078        // pins on the sibling `Option<PathBuf>`-return axes — fourth
2079        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2080        // family. Pins against a future silent detour that re-derived
2081        // the callback path from a peer axis (an accidental
2082        // `.on_call`-collapse that assumed the two `Option<PathBuf>`
2083        // axes carry the same value — a plausible slip because both
2084        // callbacks share the `handle_*/2|3` OTP shape and the
2085        // module-doc example shares one `lib/handlers.lisp` file
2086        // between `:on-call` / `:on-cast` / `:on-info`), a `None` →
2087        // `Some(empty)` collapse (the canonical `Option<PathBuf>` →
2088        // `PathBuf::new()` footgun the [`BehaviorError::EmptyPath`]
2089        // validate arm guards on the peer path-shape axis), or a
2090        // per-arm variant swap that landed on one consumer without the
2091        // other.
2092        for path in [
2093            None,
2094            Some(PathBuf::from("lib/handlers.lisp")),
2095            Some(PathBuf::from("lib/rpc/cast.lisp")),
2096        ] {
2097            let b = BehaviorSpec {
2098                on_cast: path.clone(),
2099                ..BehaviorSpec::default()
2100            };
2101            assert_eq!(
2102                b.on_cast(),
2103                path.as_deref(),
2104                "BehaviorSpec::on_cast must return the \
2105                 :behavior :on-cast PathBuf verbatim as \
2106                 Option<&Path> (got {:?}, expected {:?})",
2107                b.on_cast(),
2108                path.as_deref(),
2109            );
2110            assert_eq!(
2111                b.on_cast(),
2112                b.on_cast.as_deref(),
2113                "BehaviorSpec::on_cast must byte-equal the \
2114                 raw .on_cast.as_deref() field access across \
2115                 every value in the accept-set",
2116            );
2117        }
2118    }
2119
2120    #[test]
2121    fn behavior_on_cast_is_independent_of_peer_on_star_axes() {
2122        // Cross-axis independence pin: flipping only the `:on-cast`
2123        // axis flips [`BehaviorSpec::on_cast`] independently of every
2124        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-info` /
2125        // `:on-state-change` / `:on-terminate`). A future silent detour
2126        // that re-derived the callback path from a peer axis (an
2127        // accidental `.on_call`-collapse — the sibling synchronous
2128        // request/response arm on the peer OTP dispatch triad, a
2129        // plausible confusion because both callbacks share the
2130        // `handle_*/2|3` OTP shape and the module-doc example shares
2131        // one `lib/handlers.lisp` file between the two — that would
2132        // silently rebind the asynchronous fire-and-forget dispatch to
2133        // the sibling synchronous request/response slot's callback)
2134        // surfaces here as a build-time test failure.
2135        //
2136        // Peer of the sibling
2137        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2138        // (9b4ecde) /
2139        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2140        // (d66c702) /
2141        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2142        // (156ddbe) cross-axis pins on the sibling `:on-state-change` /
2143        // `:on-init` / `:on-call` axes — each accessor-lift closes
2144        // exactly one axis and leaves every peer axis unshifted.
2145        let base = BehaviorSpec {
2146            on_init: Some(PathBuf::from("lib/init.lisp")),
2147            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2148            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2149            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2150            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2151            ..BehaviorSpec::default()
2152        };
2153        assert_eq!(base.on_cast(), None);
2154        let with = BehaviorSpec {
2155            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2156            ..base.clone()
2157        };
2158        assert_eq!(
2159            with.on_cast(),
2160            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2161            "BehaviorSpec::on_cast must project the \
2162             :on-cast axis independently of every peer :on-* \
2163             axis (got {:?})",
2164            with.on_cast(),
2165        );
2166    }
2167
2168    // ── per-`:behavior :on-info` accessor pins ─────────────────────
2169
2170    #[test]
2171    fn behavior_on_info_returns_option_path_verbatim_across_permutations() {
2172        // Canonical per-`:behavior` `:on-info`
2173        // OTP-`gen_server:handle_info/2`-shaped system / out-of-band
2174        // message-handler callback-path scalar pin:
2175        // [`BehaviorSpec::on_info`] must return the `:behavior :on-info`
2176        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2177        // the raw `Option<PathBuf>` field access across the three
2178        // canonical shape-arms — `None` (no callback declared — the
2179        // runtime silently drops every out-of-band mailbox message),
2180        // `Some("lib/handlers.lisp")` (the canonical single-file shape
2181        // the module-doc example uses, shared with `:on-call` /
2182        // `:on-cast` on the pattern that the tatara-lisp dispatch
2183        // inside the file discriminates on the callback-kind atom),
2184        // `Some("lib/rpc/info.lisp")` (the per-dispatch sub-directory
2185        // shape the `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2186        // callback-dispatch wire acknowledges).
2187        //
2188        // Peer of the sibling per-`:behavior`
2189        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2190        // [`BehaviorSpec::on_init`] (d66c702) /
2191        // [`BehaviorSpec::on_call`] (156ddbe) /
2192        // [`BehaviorSpec::on_cast`] (99616ac) `Option<&Path>` accessor
2193        // pins on the sibling `Option<PathBuf>`-return axes — fifth
2194        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2195        // family. Pins against a future silent detour that re-derived
2196        // the callback path from a peer axis (an accidental
2197        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
2198        // axes carry the same value — a plausible slip because the
2199        // module-doc example shares one `lib/handlers.lisp` file
2200        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
2201        // that the tatara-lisp dispatch inside the file discriminates
2202        // on the callback-kind atom), a `None` → `Some(empty)` collapse
2203        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
2204        // the [`BehaviorError::EmptyPath`] validate arm guards on the
2205        // peer path-shape axis), or a per-arm variant swap that landed
2206        // on one consumer without the other.
2207        for path in [
2208            None,
2209            Some(PathBuf::from("lib/handlers.lisp")),
2210            Some(PathBuf::from("lib/rpc/info.lisp")),
2211        ] {
2212            let b = BehaviorSpec {
2213                on_info: path.clone(),
2214                ..BehaviorSpec::default()
2215            };
2216            assert_eq!(
2217                b.on_info(),
2218                path.as_deref(),
2219                "BehaviorSpec::on_info must return the \
2220                 :behavior :on-info PathBuf verbatim as \
2221                 Option<&Path> (got {:?}, expected {:?})",
2222                b.on_info(),
2223                path.as_deref(),
2224            );
2225            assert_eq!(
2226                b.on_info(),
2227                b.on_info.as_deref(),
2228                "BehaviorSpec::on_info must byte-equal the \
2229                 raw .on_info.as_deref() field access across \
2230                 every value in the accept-set",
2231            );
2232        }
2233    }
2234
2235    #[test]
2236    fn behavior_on_info_is_independent_of_peer_on_star_axes() {
2237        // Cross-axis independence pin: flipping only the `:on-info`
2238        // axis flips [`BehaviorSpec::on_info`] independently of every
2239        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast` /
2240        // `:on-state-change` / `:on-terminate`). A future silent detour
2241        // that re-derived the callback path from a peer axis (an
2242        // accidental `.on_cast`-collapse — the sibling asynchronous
2243        // fire-and-forget arm on the peer OTP dispatch triad, a
2244        // plausible confusion because the module-doc example shares one
2245        // `lib/handlers.lisp` file between `:on-call` / `:on-cast` /
2246        // `:on-info` — that would silently rebind the out-of-band-info
2247        // dispatch to the sibling asynchronous fire-and-forget slot's
2248        // callback) surfaces here as a build-time test failure.
2249        //
2250        // Peer of the sibling
2251        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2252        // (9b4ecde) /
2253        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2254        // (d66c702) /
2255        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2256        // (156ddbe) /
2257        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2258        // (99616ac) cross-axis pins on the sibling `:on-state-change` /
2259        // `:on-init` / `:on-call` / `:on-cast` axes — each
2260        // accessor-lift closes exactly one axis and leaves every peer
2261        // axis unshifted.
2262        let base = BehaviorSpec {
2263            on_init: Some(PathBuf::from("lib/init.lisp")),
2264            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2265            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2266            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2267            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2268            ..BehaviorSpec::default()
2269        };
2270        assert_eq!(base.on_info(), None);
2271        let with = BehaviorSpec {
2272            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2273            ..base.clone()
2274        };
2275        assert_eq!(
2276            with.on_info(),
2277            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2278            "BehaviorSpec::on_info must project the \
2279             :on-info axis independently of every peer :on-* \
2280             axis (got {:?})",
2281            with.on_info(),
2282        );
2283    }
2284
2285    // ── per-`:behavior :on-terminate` accessor pins ────────────────
2286
2287    #[test]
2288    fn behavior_on_terminate_returns_option_path_verbatim_across_permutations() {
2289        // Canonical per-`:behavior` `:on-terminate`
2290        // OTP-`gen_server:terminate/2`-shaped graceful-shutdown cleanup
2291        // callback-path scalar pin: [`BehaviorSpec::on_terminate`] must
2292        // return the `:behavior :on-terminate` typed `PathBuf` verbatim
2293        // as an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
2294        // field access across the three canonical shape-arms — `None`
2295        // (no callback declared — the runtime tears down the wasm
2296        // instance without dispatching any author-supplied cleanup
2297        // side-effect), `Some("lib/cleanup.lisp")` (the canonical
2298        // single-file shape the module-doc example uses, the
2299        // author-surface pins `lib/cleanup.lisp` as the reference
2300        // `:on-terminate` value), `Some("lib/lifecycle/terminate.lisp")`
2301        // (the per-lifecycle-arm sub-directory shape the
2302        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2303        // callback-dispatch wire acknowledges).
2304        //
2305        // Peer of the sibling per-`:behavior`
2306        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2307        // [`BehaviorSpec::on_init`] (d66c702) /
2308        // [`BehaviorSpec::on_call`] (156ddbe) /
2309        // [`BehaviorSpec::on_cast`] (99616ac) /
2310        // [`BehaviorSpec::on_info`] (4846cef) `Option<&Path>` accessor
2311        // pins on the sibling `Option<PathBuf>`-return axes — sixth and
2312        // final `Option<&Path>`-return accessor on the M2 `:behavior`
2313        // slot family, closes the last unlifted per-`:behavior`
2314        // `Option<&Path>` scalar-value axis. Pins against a future
2315        // silent detour that re-derived the callback path from a peer
2316        // axis (an accidental `.on_init`-collapse that assumed the two
2317        // lifecycle-arm `Option<PathBuf>` axes carry the same value —
2318        // a plausible slip because both are the lifecycle-head /
2319        // lifecycle-tail bookends of the OTP `gen_server` lifecycle, so
2320        // the "run once per instance" semantics rhyme across the two
2321        // arms), a `None` → `Some(empty)` collapse (the canonical
2322        // `Option<PathBuf>` → `PathBuf::new()` footgun the
2323        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
2324        // path-shape axis), or a per-arm variant swap that landed on
2325        // one consumer without the other.
2326        for path in [
2327            None,
2328            Some(PathBuf::from("lib/cleanup.lisp")),
2329            Some(PathBuf::from("lib/lifecycle/terminate.lisp")),
2330        ] {
2331            let b = BehaviorSpec {
2332                on_terminate: path.clone(),
2333                ..BehaviorSpec::default()
2334            };
2335            assert_eq!(
2336                b.on_terminate(),
2337                path.as_deref(),
2338                "BehaviorSpec::on_terminate must return the \
2339                 :behavior :on-terminate PathBuf verbatim as \
2340                 Option<&Path> (got {:?}, expected {:?})",
2341                b.on_terminate(),
2342                path.as_deref(),
2343            );
2344            assert_eq!(
2345                b.on_terminate(),
2346                b.on_terminate.as_deref(),
2347                "BehaviorSpec::on_terminate must byte-equal the \
2348                 raw .on_terminate.as_deref() field access across \
2349                 every value in the accept-set",
2350            );
2351        }
2352    }
2353
2354    #[test]
2355    fn behavior_on_terminate_is_independent_of_peer_on_star_axes() {
2356        // Cross-axis independence pin: flipping only the `:on-terminate`
2357        // axis flips [`BehaviorSpec::on_terminate`] independently of
2358        // every peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast`
2359        // / `:on-info` / `:on-state-change`). A future silent detour
2360        // that re-derived the callback path from a peer axis (an
2361        // accidental `.on_init`-collapse — the sibling lifecycle-head
2362        // arm on the peer OTP dispatch lifecycle, a plausible confusion
2363        // because both are the lifecycle-bookend arms that run
2364        // once-per-instance rather than per-mailbox-turn — that would
2365        // silently rebind the graceful-tear-down dispatch to the
2366        // sibling instance-start slot's callback) surfaces here as a
2367        // build-time test failure.
2368        //
2369        // Peer of the sibling
2370        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2371        // (9b4ecde) /
2372        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2373        // (d66c702) /
2374        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2375        // (156ddbe) /
2376        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2377        // (99616ac) /
2378        // `behavior_on_info_is_independent_of_peer_on_star_axes`
2379        // (4846cef) cross-axis pins on the sibling `:on-state-change` /
2380        // `:on-init` / `:on-call` / `:on-cast` / `:on-info` axes —
2381        // each accessor-lift closes exactly one axis and leaves every
2382        // peer axis unshifted, so the six-callback OTP `gen_server`
2383        // lifecycle the slot family models routes through one typed
2384        // dispatch per arm on the substrate primitive.
2385        let base = BehaviorSpec {
2386            on_init: Some(PathBuf::from("lib/init.lisp")),
2387            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2388            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2389            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2390            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2391            ..BehaviorSpec::default()
2392        };
2393        assert_eq!(base.on_terminate(), None);
2394        let with = BehaviorSpec {
2395            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2396            ..base.clone()
2397        };
2398        assert_eq!(
2399            with.on_terminate(),
2400            Some(PathBuf::from("lib/cleanup.lisp").as_path()),
2401            "BehaviorSpec::on_terminate must project the \
2402             :on-terminate axis independently of every peer :on-* \
2403             axis (got {:?})",
2404            with.on_terminate(),
2405        );
2406    }
2407
2408    // Per-variant equivalence pins for the [`behavior_slot_path_ctors!`]
2409    // macro definition (see the paired doc-block above the macro
2410    // definition) — every generated `<ctor>(slot: &'static str,
2411    // path: &Path) -> Self` constructor folds the uniform `Self::<Variant>
2412    // { slot, path: path.to_path_buf() }` two-field struct-literal onto
2413    // one substrate primitive. The three per-variant equivalence pins
2414    // below (fail-before-pass-after by construction — a byte-mismatched
2415    // macro arm would trip its equivalence pin first) lock each generated
2416    // constructor to its struct-literal peer under `PartialEq`, so every
2417    // closure passed to [`crate::render::require_sandboxed_lisp_path`] at
2418    // [`validate_callback_path`] on that variant produces a byte-equal
2419    // `BehaviorError` to the pre-lift open-coded struct-literal. The
2420    // cross-axis pin that follows (non-default `(slot, path)` pair over
2421    // every `M2_BEHAVIOR_AUTHOR_KEY_ON_*` label, and both `&Path` and
2422    // `&PathBuf` shapes) routes both constructor input axes verbatim
2423    // (`slot` as `&'static str` without conversion, `path` via
2424    // `.to_path_buf()`), so the fold does not silently collapse onto a
2425    // fixed `slot` or `path` value or drop the Deref-coercion arm the
2426    // wire-up sites depend on.
2427    //
2428    // Peer of the sibling `absolute_script_ctor_matches_struct_literal_wrap`
2429    // / `parent_escape_script_ctor_matches_struct_literal_wrap` /
2430    // `non_lisp_extension_script_ctor_matches_struct_literal_wrap` /
2431    // `upgrade_script_only_ctors_route_script_through_to_path_buf`
2432    // equivalence + cross-axis pins the peer
2433    // [`crate::upgrade::upgrade_script_only_ctors!`] family (7468ca9)
2434    // established on the peer `{ script: PathBuf }` one-slot envelope of
2435    // the sibling `UpgradeError`, and of the peer
2436    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap` /
2437    // `duplicate_state_change_ctor_matches_struct_literal_wrap` /
2438    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
2439    // / `upgrade_from_script_ctors_route_from_and_script_verbatim` pins
2440    // the peer [`crate::upgrade::upgrade_from_script_ctors!`] family
2441    // (8e67041) established on the peer `{ from: String, script: PathBuf }`
2442    // two-slot envelope of that same sibling; extended here onto the
2443    // `BehaviorError` `{ slot: &'static str, path: PathBuf }` two-slot
2444    // envelope so every substrate-primitive ctor family in caixa-core
2445    // guarantees the same-shape fold every wire-up on the family reads
2446    // through one dispatch.
2447
2448    #[test]
2449    fn absolute_path_ctor_matches_struct_literal_wrap() {
2450        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_INIT;
2451        let path = Path::new("/etc/nope.lisp");
2452        assert_eq!(
2453            BehaviorError::absolute_path(slot, path),
2454            BehaviorError::AbsolutePath {
2455                slot,
2456                path: path.to_path_buf(),
2457            },
2458            "generated absolute_path ctor must produce byte-equal \
2459             BehaviorError to the open-coded struct-literal wrap on the \
2460             same (&'static str, &Path) fixture",
2461        );
2462    }
2463
2464    #[test]
2465    fn parent_escape_ctor_matches_struct_literal_wrap() {
2466        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE;
2467        let path = Path::new("../oops.lisp");
2468        assert_eq!(
2469            BehaviorError::parent_escape(slot, path),
2470            BehaviorError::ParentEscape {
2471                slot,
2472                path: path.to_path_buf(),
2473            },
2474            "generated parent_escape ctor must produce byte-equal \
2475             BehaviorError to the open-coded struct-literal wrap on the \
2476             same (&'static str, &Path) fixture",
2477        );
2478    }
2479
2480    #[test]
2481    fn non_lisp_extension_ctor_matches_struct_literal_wrap() {
2482        let slot = M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE;
2483        let path = Path::new("lib/cleanup.rs");
2484        assert_eq!(
2485            BehaviorError::non_lisp_extension(slot, path),
2486            BehaviorError::NonLispExtension {
2487                slot,
2488                path: path.to_path_buf(),
2489            },
2490            "generated non_lisp_extension ctor must produce byte-equal \
2491             BehaviorError to the open-coded struct-literal wrap on the \
2492             same (&'static str, &Path) fixture",
2493        );
2494    }
2495
2496    #[test]
2497    fn behavior_slot_path_ctors_route_slot_and_path_verbatim() {
2498        // Cross-axis pin: sweep both constructor input axes (`slot:
2499        // &'static str`, `path: &Path`) through non-default fixtures
2500        // over every M2 `:behavior :on-*` author-key label and both
2501        // `&Path` (direct `Path::new`) / `&PathBuf` (via Deref coercion)
2502        // shapes against every generated arm in the
2503        // [`behavior_slot_path_ctors!`] macro, so any wrapper-side
2504        // lowercase / trim / truncate / re-order / fixed-slot-or-path
2505        // substitution on the two-field construction surfaces here
2506        // rather than at a downstream diagnostic-shape mismatch. Also
2507        // exercises the `&Path` parameter under both `&Path` (direct
2508        // `Path::new`) and `&PathBuf` (via Deref coercion), matching the
2509        // shape the three closures at [`validate_callback_path`] thread
2510        // through — the wire-ups hand a `&Path` from
2511        // [`BehaviorSpec::declared_slots`]' iterator into each closure,
2512        // so the Deref-coercion arm the ctor advertises must actually
2513        // route through `.to_path_buf()` and not silently swap in a
2514        // fixed path. Peer of the sibling
2515        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
2516        // (8e67041) cross-axis pin on the sibling `UpgradeError`
2517        // `{ from, script }` two-slot envelope.
2518        let path_owned = PathBuf::from("lib/handlers.lisp");
2519        let path_ref: &Path = path_owned.as_path();
2520        for slot in [
2521            M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
2522            M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
2523            M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
2524            M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
2525            M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
2526            M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
2527        ] {
2528            for path in [path_ref, &path_owned as &Path] {
2529                assert_eq!(
2530                    BehaviorError::absolute_path(slot, path),
2531                    BehaviorError::AbsolutePath {
2532                        slot,
2533                        path: path.to_path_buf(),
2534                    },
2535                );
2536                assert_eq!(
2537                    BehaviorError::parent_escape(slot, path),
2538                    BehaviorError::ParentEscape {
2539                        slot,
2540                        path: path.to_path_buf(),
2541                    },
2542                );
2543                assert_eq!(
2544                    BehaviorError::non_lisp_extension(slot, path),
2545                    BehaviorError::NonLispExtension {
2546                        slot,
2547                        path: path.to_path_buf(),
2548                    },
2549                );
2550            }
2551        }
2552    }
2553}