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::AbsolutePath {
771            slot,
772            path: path.to_path_buf(),
773        },
774        || BehaviorError::ParentEscape {
775            slot,
776            path: path.to_path_buf(),
777        },
778        || BehaviorError::NonLispExtension {
779            slot,
780            path: path.to_path_buf(),
781        },
782    )
783}
784
785#[derive(Debug, Error, PartialEq, Eq)]
786pub enum BehaviorError {
787    #[error(
788        ":behavior {slot} path is empty (omit the slot to fall back to the runtime default \
789         callback; do not declare an empty path)"
790    )]
791    EmptyPath { slot: &'static str },
792    #[error(
793        ":behavior {slot} path {} is absolute — callbacks must be relative to the caixa root, \
794         since the layout checker's `root.join(p)` would otherwise escape the project sandbox \
795         (Path::join replaces the base with an absolute right-hand side)",
796        path.display()
797    )]
798    AbsolutePath { slot: &'static str, path: PathBuf },
799    #[error(
800        ":behavior {slot} path {} contains a `..` component — callbacks must not traverse \
801         above the caixa root",
802        path.display()
803    )]
804    ParentEscape { slot: &'static str, path: PathBuf },
805    #[error(
806        ":behavior {slot} path {} does not terminate in the `.lisp` extension — the M2.5 \
807         wasm-engine instantiator reads every callback path as tatara-lisp source through \
808         `tatara_lisp::read` at instance-start time, so any other extension (`.txt`, `.rs`, \
809         `.lisp.bak`) or no-extension shape is structurally a parser error far from the \
810         source caixa.lisp, with no field naming the offending `:on-*` slot. Pin a \
811         relative path under the caixa root whose terminating extension is \
812         lowercase-`.lisp` (e.g. `\"lib/init.lisp\"`, `\"lib/handlers.lisp\"`, \
813         `\"lib/migrations/v01-to-v02.lisp\"`) or omit the slot to fall back to the \
814         runtime default callback",
815        path.display()
816    )]
817    NonLispExtension { slot: &'static str, path: PathBuf },
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use crate::render::{
824        M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
825        M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
826        M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
827    };
828
829    #[test]
830    fn empty_behavior_round_trip() {
831        let b = BehaviorSpec::default();
832        assert!(b.is_empty());
833        let json = serde_json::to_string(&b).unwrap();
834        assert_eq!(json, "{}");
835        let back: BehaviorSpec = serde_json::from_str("{}").unwrap();
836        assert_eq!(back, b);
837    }
838
839    #[test]
840    fn full_behavior_round_trip_through_json() {
841        let b = BehaviorSpec {
842            on_init: Some(PathBuf::from("lib/init.lisp")),
843            on_call: Some(PathBuf::from("lib/handlers.lisp")),
844            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
845            on_info: Some(PathBuf::from("lib/handlers.lisp")),
846            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
847            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
848        };
849        let json = serde_json::to_string(&b).unwrap();
850        let back: BehaviorSpec = serde_json::from_str(&json).unwrap();
851        assert_eq!(b, back);
852    }
853
854    #[test]
855    fn partial_behavior_keeps_explicit_fields() {
856        let b = BehaviorSpec {
857            on_init: Some(PathBuf::from("lib/init.lisp")),
858            on_call: Some(PathBuf::from("lib/handlers.lisp")),
859            ..Default::default()
860        };
861        assert!(!b.is_empty());
862        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
863        assert_eq!(paths.len(), 2);
864        assert!(paths.contains(&PathBuf::from("lib/init.lisp")));
865        assert!(paths.contains(&PathBuf::from("lib/handlers.lisp")));
866    }
867
868    #[test]
869    fn declared_paths_skips_none() {
870        let b = BehaviorSpec {
871            on_init: Some(PathBuf::from("a.lisp")),
872            on_terminate: Some(PathBuf::from("b.lisp")),
873            ..Default::default()
874        };
875        let paths: Vec<PathBuf> = b.declared_paths().map(Path::to_path_buf).collect();
876        assert_eq!(
877            paths,
878            vec![PathBuf::from("a.lisp"), PathBuf::from("b.lisp")]
879        );
880    }
881
882    #[test]
883    fn json_keys_are_camelcase() {
884        let b = BehaviorSpec {
885            on_init: Some(PathBuf::from("init.lisp")),
886            on_state_change: Some(PathBuf::from("mig.lisp")),
887            ..Default::default()
888        };
889        let json = serde_json::to_string(&b).unwrap();
890        assert!(json.contains("\"onInit\""));
891        assert!(json.contains("\"onStateChange\""));
892        assert!(!json.contains("\"on_init\""));
893    }
894
895    #[test]
896    fn deserialize_accepts_camelcase() {
897        let json = r#"{"onInit":"a.lisp","onTerminate":"b.lisp"}"#;
898        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
899        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
900        assert_eq!(b.on_terminate, Some(PathBuf::from("b.lisp")));
901    }
902
903    // ── drift-detection: serde-derive-to-M2_BEHAVIOR_KEY_ON_* identity ────
904
905    #[test]
906    fn behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts() {
907        // Load-bearing invariant: the six `M2_BEHAVIOR_KEY_ON_*` consts
908        // (`M2_BEHAVIOR_KEY_ON_INIT` / `M2_BEHAVIOR_KEY_ON_CALL` /
909        // `M2_BEHAVIOR_KEY_ON_CAST` / `M2_BEHAVIOR_KEY_ON_INFO` /
910        // `M2_BEHAVIOR_KEY_ON_STATE_CHANGE` /
911        // `M2_BEHAVIOR_KEY_ON_TERMINATE`) name the exact camelCase JSON
912        // keys the `#[serde(rename_all = "camelCase")]` attribute on
913        // `BehaviorSpec` emits, and every test-side probe across the
914        // caixa-core / caixa-flux / caixa-helm renderer test fixtures
915        // navigates into the rendered `:behavior` overlay sub-block by
916        // consulting one of these six `&'static str`s. Serialize a
917        // fully-populated BehaviorSpec and pin that each canonical
918        // byte-sequence appears verbatim in the JSON — a future
919        // accidental `rename_all = "snake_case"` / `"kebab-case"` /
920        // verbatim-field-name flip at the derive attribute (any of
921        // which would silently break every test-side probe that reaches
922        // for one of the six consts) surfaces here as a build-time test
923        // failure at `behavior.rs`, not as an apply-time
924        // `.get(<stale-canonical-const>)` returning `None` far from the
925        // derive-attr drift's commit. Same discipline the sibling
926        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
927        // pin (d8b8b4f) established on the peer `:limits` sub-slot
928        // axis: one canonical byte-string per typed sub-key axis,
929        // pinned to the load-bearing serde derivation at the type
930        // itself.
931        let b = BehaviorSpec {
932            on_init: Some(PathBuf::from("lib/init.lisp")),
933            on_call: Some(PathBuf::from("lib/handlers.lisp")),
934            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
935            on_info: Some(PathBuf::from("lib/handlers.lisp")),
936            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
937            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
938        };
939        let json = serde_json::to_string(&b).unwrap();
940        for key in [
941            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
942            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
943            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
944            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
945            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
946            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
947        ] {
948            let quoted = format!("\"{key}\"");
949            assert!(
950                json.contains(&quoted),
951                "serialized BehaviorSpec must carry the lifted \
952                 M2_BEHAVIOR_KEY_ON_* byte-sequence {quoted} verbatim \
953                 in the JSON emission (got: {json})",
954            );
955        }
956    }
957
958    #[test]
959    fn m2_behavior_key_consts_are_pairwise_distinct() {
960        // Cross-axis drift-detection pin: a future collapse of two
961        // canonical sub-key byte-strings onto the same value (e.g. an
962        // accidental copy-paste flip of `M2_BEHAVIOR_KEY_ON_CAST` to
963        // also read `"onCall"`) would silently reroute every test-side
964        // probe on one axis onto the sibling axis's overlay entry and
965        // pass every propagation-probe test that expected only the
966        // stale axis's value. Peer of `m2_limits_key_consts_are_
967        // pairwise_distinct` (d8b8b4f) on the sibling `:limits`
968        // sub-slot axis.
969        let all = [
970            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
971            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
972            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
973            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
974            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
975            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
976        ];
977        for (i, a) in all.iter().enumerate() {
978            for b in all.iter().skip(i + 1) {
979                assert_ne!(
980                    a, b,
981                    "M2_BEHAVIOR_KEY_ON_* consts must be pairwise-distinct \
982                     canonical byte-sequences — got `{a}` == `{b}`",
983                );
984            }
985        }
986    }
987
988    #[test]
989    fn m2_behavior_key_consts_are_lower_camel_case_shape() {
990        // Shape-pin: every `M2_BEHAVIOR_KEY_ON_*` const must be a
991        // lowerCamelCase byte-sequence (no `snake_case` underscores,
992        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
993        // whitespace / colons / dots) — the canonical shape the
994        // `#[serde(rename_all = "camelCase")]` derive produces on
995        // `BehaviorSpec`. A future flip to a non-camelCase attribute
996        // at the derive surfaces both here (this test fails on the
997        // stale-constant shape) and at
998        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
999        // (that test fails on the mismatch between const and derive).
1000        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
1001        // (d8b8b4f) on the sibling `:limits` sub-slot axis.
1002        for key in [
1003            crate::render::M2_BEHAVIOR_KEY_ON_INIT,
1004            crate::render::M2_BEHAVIOR_KEY_ON_CALL,
1005            crate::render::M2_BEHAVIOR_KEY_ON_CAST,
1006            crate::render::M2_BEHAVIOR_KEY_ON_INFO,
1007            crate::render::M2_BEHAVIOR_KEY_ON_STATE_CHANGE,
1008            crate::render::M2_BEHAVIOR_KEY_ON_TERMINATE,
1009        ] {
1010            assert!(
1011                !key.is_empty(),
1012                "M2_BEHAVIOR_KEY_ON_* must be non-empty (got {key:?})"
1013            );
1014            let first = key.chars().next().unwrap();
1015            assert!(
1016                first.is_ascii_lowercase(),
1017                "M2_BEHAVIOR_KEY_ON_* must lead with an ASCII-lowercase \
1018                 byte (got {key:?}, leads with {first:?})",
1019            );
1020            assert!(
1021                key.chars().all(|c| c.is_ascii_alphanumeric()),
1022                "M2_BEHAVIOR_KEY_ON_* must be ASCII-alphanumeric only \
1023                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn deserialize_omits_unknown_fields_via_default() {
1030        // Forward-compatible: a future caixa.lisp with extra fields
1031        // round-trips without losing the known ones.
1032        let json = r#"{"onInit":"a.lisp"}"#;
1033        let b: BehaviorSpec = serde_json::from_str(json).unwrap();
1034        assert_eq!(b.on_init, Some(PathBuf::from("a.lisp")));
1035        assert!(b.on_call.is_none());
1036    }
1037
1038    // ── value-shape invariants on declared callback paths ──────────
1039
1040    #[test]
1041    fn validate_default_is_ok() {
1042        BehaviorSpec::default().validate().unwrap();
1043    }
1044
1045    #[test]
1046    fn validate_every_slot_relative_is_ok() {
1047        let b = BehaviorSpec {
1048            on_init: Some(PathBuf::from("lib/init.lisp")),
1049            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1050            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1051            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1052            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1053            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1054        };
1055        b.validate().unwrap();
1056    }
1057
1058    #[test]
1059    fn validate_rejects_empty_path_per_slot() {
1060        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1061            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1062                on_init: Some(p),
1063                ..Default::default()
1064            }),
1065            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1066                on_call: Some(p),
1067                ..Default::default()
1068            }),
1069            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1070                on_cast: Some(p),
1071                ..Default::default()
1072            }),
1073            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1074                on_info: Some(p),
1075                ..Default::default()
1076            }),
1077            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1078                on_state_change: Some(p),
1079                ..Default::default()
1080            }),
1081            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1082                on_terminate: Some(p),
1083                ..Default::default()
1084            }),
1085        ];
1086        for (expected_slot, build) in cases {
1087            let err = build(PathBuf::new()).validate().unwrap_err();
1088            assert!(
1089                matches!(err, BehaviorError::EmptyPath { slot } if slot == expected_slot),
1090                "slot {expected_slot}: got {err:?}",
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn validate_rejects_absolute_path() {
1097        let b = BehaviorSpec {
1098            on_init: Some(PathBuf::from("/etc/passwd")),
1099            ..Default::default()
1100        };
1101        let err = b.validate().unwrap_err();
1102        assert!(matches!(
1103            err,
1104            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1105        ));
1106    }
1107
1108    #[test]
1109    fn validate_rejects_parent_escape() {
1110        let b = BehaviorSpec {
1111            on_state_change: Some(PathBuf::from("../sibling/migrations.lisp")),
1112            ..Default::default()
1113        };
1114        let err = b.validate().unwrap_err();
1115        assert!(matches!(
1116            err,
1117            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE
1118        ));
1119    }
1120
1121    #[test]
1122    fn validate_rejects_parent_escape_mid_path() {
1123        // `lib/../../escaped.lisp` is still a parent-traversal — must
1124        // be caught regardless of where the `..` component sits.
1125        let b = BehaviorSpec {
1126            on_terminate: Some(PathBuf::from("lib/../../escaped.lisp")),
1127            ..Default::default()
1128        };
1129        let err = b.validate().unwrap_err();
1130        assert!(matches!(
1131            err,
1132            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE
1133        ));
1134    }
1135
1136    #[test]
1137    fn validate_diagnostic_order_is_deterministic() {
1138        // Multiple bad slots — the first declared (`:on-init`) wins
1139        // so authors see a stable, single-slot diagnostic.
1140        let b = BehaviorSpec {
1141            on_init: Some(PathBuf::new()),
1142            on_call: Some(PathBuf::from("/etc/passwd")),
1143            on_terminate: Some(PathBuf::from("../escape.lisp")),
1144            ..Default::default()
1145        };
1146        let err = b.validate().unwrap_err();
1147        assert!(matches!(
1148            err,
1149            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1150        ));
1151    }
1152
1153    // ── `.lisp`-extension gate on every `:behavior :on-*` axis ─────
1154
1155    #[test]
1156    fn validate_rejects_non_lisp_extension_per_slot() {
1157        // Loop the same offending non-`.lisp` path through every M2
1158        // typed `:behavior` slot — the diagnostic must name the
1159        // offending slot, not collapse to a generic "bad extension"
1160        // shape. Same per-slot diagnostic posture every peer
1161        // `BehaviorError` arm carries (`EmptyPath`, `AbsolutePath`,
1162        // `ParentEscape`).
1163        let cases: [(&'static str, fn(PathBuf) -> BehaviorSpec); 6] = [
1164            (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, |p| BehaviorSpec {
1165                on_init: Some(p),
1166                ..Default::default()
1167            }),
1168            (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, |p| BehaviorSpec {
1169                on_call: Some(p),
1170                ..Default::default()
1171            }),
1172            (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, |p| BehaviorSpec {
1173                on_cast: Some(p),
1174                ..Default::default()
1175            }),
1176            (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, |p| BehaviorSpec {
1177                on_info: Some(p),
1178                ..Default::default()
1179            }),
1180            (M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, |p| BehaviorSpec {
1181                on_state_change: Some(p),
1182                ..Default::default()
1183            }),
1184            (M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, |p| BehaviorSpec {
1185                on_terminate: Some(p),
1186                ..Default::default()
1187            }),
1188        ];
1189        let path = PathBuf::from("lib/init.txt");
1190        for (expected_slot, build) in cases {
1191            let err = build(path.clone()).validate().unwrap_err();
1192            assert!(
1193                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1194                    if *slot == expected_slot && p == &path),
1195                "slot {expected_slot}: got {err:?}",
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn validate_rejects_no_extension() {
1202        // The no-extension shape — author dropped the suffix entirely
1203        // (`lib/init` instead of `lib/init.lisp`). `Path::extension`
1204        // returns None, so the typed check distinguishes this from
1205        // the wrong-extension shape and from a leading-dot file like
1206        // `.lisp` (which also has no `Path::extension`).
1207        let cases = [
1208            PathBuf::from("lib/init"),
1209            PathBuf::from("lib/handlers"),
1210            PathBuf::from("init"),
1211        ];
1212        for path in cases {
1213            let b = BehaviorSpec {
1214                on_init: Some(path.clone()),
1215                ..Default::default()
1216            };
1217            let err = b.validate().unwrap_err();
1218            assert!(
1219                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1220                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1221                "no-extension path {path:?}: got {err:?}",
1222            );
1223        }
1224    }
1225
1226    #[test]
1227    fn validate_rejects_wrong_extension() {
1228        // Common authoring footguns — files that pass every prior
1229        // path-shape gate but that the wasm-engine's tatara-lisp
1230        // reader cannot consume as source.
1231        let cases = [
1232            PathBuf::from("lib/init.rs"),
1233            PathBuf::from("lib/init.txt"),
1234            PathBuf::from("lib/init.md"),
1235            PathBuf::from("lib/init.json"),
1236            PathBuf::from("lib/init.yaml"),
1237            PathBuf::from("lib/init.lisp.bak"),
1238            PathBuf::from("lib/init.lispx"),
1239        ];
1240        for path in cases {
1241            let b = BehaviorSpec {
1242                on_call: Some(path.clone()),
1243                ..Default::default()
1244            };
1245            let err = b.validate().unwrap_err();
1246            assert!(
1247                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1248                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_CALL && p == &path),
1249                "wrong-extension path {path:?}: got {err:?}",
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn validate_rejects_uppercase_lisp_extension() {
1256        // The strict-lowercase posture matches every other shape
1257        // predicate in `render.rs` — the byte-size codec is
1258        // case-sensitive on `MiB`, the duration codec on `ms` / `s` /
1259        // `m` / `h`, every DNS-1123 label is lowercase-only — so the
1260        // accepted set for `.lisp` does not silently fold to `.LISP` /
1261        // `.Lisp` / `.LiSp` even on case-insensitive volumes. A path
1262        // whose existence check would match the on-disk file via
1263        // case-insensitive lookup would still mismatch the canonical
1264        // form the codec emits, breaking the round-trip-stability
1265        // contract.
1266        let cases = [
1267            PathBuf::from("lib/init.LISP"),
1268            PathBuf::from("lib/init.Lisp"),
1269            PathBuf::from("lib/init.LiSp"),
1270        ];
1271        for path in cases {
1272            let b = BehaviorSpec {
1273                on_init: Some(path.clone()),
1274                ..Default::default()
1275            };
1276            let err = b.validate().unwrap_err();
1277            assert!(
1278                matches!(&err, BehaviorError::NonLispExtension { slot, path: p }
1279                    if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT && p == &path),
1280                "uppercase `.lisp` {path:?}: got {err:?}",
1281            );
1282        }
1283    }
1284
1285    #[test]
1286    fn validate_accepts_canonical_lisp_paths() {
1287        // Positive control: every shape the in-tree fixtures and
1288        // module-doc examples use must pass the gate. Pins the
1289        // accepted set so a future tightening doesn't accidentally
1290        // reject the canonical authoring shape.
1291        let cases = [
1292            PathBuf::from("lib/init.lisp"),
1293            PathBuf::from("lib/handlers.lisp"),
1294            PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1295            PathBuf::from("init.lisp"),
1296            PathBuf::from("a.lisp"),
1297            PathBuf::from("./lib/init.lisp"),
1298            PathBuf::from("lib/./handlers.lisp"),
1299            PathBuf::from("lib/migrations/v.0.1.lisp"),
1300        ];
1301        for path in cases {
1302            let b = BehaviorSpec {
1303                on_init: Some(path.clone()),
1304                ..Default::default()
1305            };
1306            b.validate()
1307                .unwrap_or_else(|e| panic!("canonical `.lisp` path {path:?} must pass: {e:?}"));
1308        }
1309    }
1310
1311    #[test]
1312    fn validate_path_shape_precedes_extension_arm() {
1313        // Cross-arm ordering: a path that is *both* path-shape invalid
1314        // (empty / absolute / parent-escape) and non-`.lisp` surfaces
1315        // the more fundamental sandbox-shape diagnostic first — the
1316        // `.lisp` remediation is misleading when the offending path
1317        // can never resolve under the caixa root anyway. Mirrors the
1318        // `MemoryZero` → `MemoryBelowWasm32Page` → `MemoryExceedsWasm32Cap`
1319        // → `MemoryNotPageMultiple` smallest-scope-last cascade on the
1320        // peer `:limits :memory` axis.
1321
1322        // Empty + non-`.lisp` → empty wins (the empty case has no
1323        // extension to begin with).
1324        let b = BehaviorSpec {
1325            on_init: Some(PathBuf::new()),
1326            ..Default::default()
1327        };
1328        assert!(matches!(
1329            b.validate().unwrap_err(),
1330            BehaviorError::EmptyPath { slot } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1331        ));
1332
1333        // Absolute + non-`.lisp` → absolute wins.
1334        let b = BehaviorSpec {
1335            on_init: Some(PathBuf::from("/etc/init.txt")),
1336            ..Default::default()
1337        };
1338        assert!(matches!(
1339            b.validate().unwrap_err(),
1340            BehaviorError::AbsolutePath { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1341        ));
1342
1343        // Parent-escape + non-`.lisp` → parent-escape wins.
1344        let b = BehaviorSpec {
1345            on_init: Some(PathBuf::from("../sibling/init.txt")),
1346            ..Default::default()
1347        };
1348        assert!(matches!(
1349            b.validate().unwrap_err(),
1350            BehaviorError::ParentEscape { slot, .. } if slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1351        ));
1352    }
1353
1354    #[test]
1355    fn validate_extension_diagnostic_names_offending_slot_and_path() {
1356        // Self-locating diagnostic pin: the surfaced error names the
1357        // exact `:on-*` slot the author wrote and the exact offending
1358        // path verbatim, so the author can grep their caixa.lisp for
1359        // the named slot / path and fix it in one edit. Same shape
1360        // every peer per-slot `:behavior` arm exposes.
1361        let path = PathBuf::from("lib/handlers.rs");
1362        let b = BehaviorSpec {
1363            on_cast: Some(path.clone()),
1364            ..Default::default()
1365        };
1366        let err = b.validate().unwrap_err();
1367        let rendered = err.to_string();
1368        assert!(
1369            rendered.contains(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST),
1370            "diagnostic must name the `:on-cast` slot: {rendered}"
1371        );
1372        assert!(
1373            rendered.contains("lib/handlers.rs"),
1374            "diagnostic must carry the offending path verbatim: {rendered}"
1375        );
1376        assert!(
1377            rendered.contains(".lisp"),
1378            "diagnostic must name the expected `.lisp` extension: {rendered}"
1379        );
1380    }
1381
1382    #[test]
1383    fn validate_extension_arm_fires_across_multi_malformed_manifest_in_slot_order() {
1384        // Multi-malformed manifest, all four slots carrying a
1385        // non-`.lisp` extension — the first declared slot
1386        // (`:on-init`) wins, mirroring the prior
1387        // `validate_diagnostic_order_is_deterministic` pin on the
1388        // path-shape arms.
1389        let b = BehaviorSpec {
1390            on_init: Some(PathBuf::from("lib/init.rs")),
1391            on_call: Some(PathBuf::from("lib/handlers.txt")),
1392            on_state_change: Some(PathBuf::from("lib/migrations.md")),
1393            ..Default::default()
1394        };
1395        let err = b.validate().unwrap_err();
1396        assert!(matches!(
1397            &err,
1398            BehaviorError::NonLispExtension { slot, path }
1399                if *slot == M2_BEHAVIOR_AUTHOR_KEY_ON_INIT
1400                    && path == &PathBuf::from("lib/init.rs")
1401        ));
1402    }
1403
1404    #[test]
1405    fn m2_behavior_author_key_consts_pin_canonical_kebab_case_labels() {
1406        // Scalar-value pin: the six author-facing kebab-case labels the
1407        // `(defcaixa … :behavior (:on-* …))` surface admits, one arm per
1408        // sub-slot. Mirrors the peer scalar-value pin the sibling
1409        // renderer-side [`crate::M2_BEHAVIOR_KEY_ON_*`] camelCase consts
1410        // carry (21fe462), so both halves of the M2 `:behavior` sub-slot
1411        // dual axis (author-facing kebab-case label + renderer-side
1412        // camelCase wire key) route through one canonical per-arm
1413        // declaration. A future rebrand (`:on-init` → `:on-start`
1414        // matching Akka's per-actor preStart naming, `:on-state-change`
1415        // → `:on-code-change` matching Erlang's verbatim `code_change/3`
1416        // name) lands as an edit to exactly one const, and every
1417        // consumer that reaches for the label picks it up at build time
1418        // rather than at runtime as a downstream mismatch.
1419        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, ":on-init");
1420        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, ":on-call");
1421        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, ":on-cast");
1422        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, ":on-info");
1423        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE, ":on-state-change");
1424        assert_eq!(M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE, ":on-terminate");
1425    }
1426
1427    #[test]
1428    fn declared_slots_labels_route_through_lifted_author_key_consts() {
1429        // Production-through-const pin: the six per-arm labels
1430        // [`BehaviorSpec::declared_slots`] threads through as the
1431        // `(slot, path)` iterator's first component route through the
1432        // lifted [`crate::M2_BEHAVIOR_AUTHOR_KEY_ON_*`] consts, in
1433        // declaration order. A future re-order or drift at the tagger
1434        // (a rename that reaches the tagger but not the const, or vice
1435        // versa) surfaces here at build time rather than at runtime as
1436        // a diagnostic naming a stale kebab-case slot far from the
1437        // rename's commit.
1438        let b = BehaviorSpec {
1439            on_init: Some(PathBuf::from("a.lisp")),
1440            on_call: Some(PathBuf::from("b.lisp")),
1441            on_cast: Some(PathBuf::from("c.lisp")),
1442            on_info: Some(PathBuf::from("d.lisp")),
1443            on_state_change: Some(PathBuf::from("e.lisp")),
1444            on_terminate: Some(PathBuf::from("f.lisp")),
1445        };
1446        let labels: Vec<&'static str> = b.declared_slots().map(|(s, _)| s).collect();
1447        assert_eq!(
1448            labels,
1449            vec![
1450                M2_BEHAVIOR_AUTHOR_KEY_ON_INIT,
1451                M2_BEHAVIOR_AUTHOR_KEY_ON_CALL,
1452                M2_BEHAVIOR_AUTHOR_KEY_ON_CAST,
1453                M2_BEHAVIOR_AUTHOR_KEY_ON_INFO,
1454                M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1455                M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1456            ]
1457        );
1458    }
1459
1460    #[test]
1461    fn declared_slots_paths_route_through_lifted_on_star_accessors() {
1462        // Production-through-accessor pin: the six per-arm
1463        // `Option<&Path>` path-values [`BehaviorSpec::declared_slots`]
1464        // threads through as the `(slot, path)` iterator's second
1465        // component route through the lifted per-slot
1466        // [`BehaviorSpec::on_init`] / [`BehaviorSpec::on_call`] /
1467        // [`BehaviorSpec::on_cast`] / [`BehaviorSpec::on_info`] /
1468        // [`BehaviorSpec::on_state_change`] / [`BehaviorSpec::on_terminate`]
1469        // accessors, so every future accessor-side extension of an
1470        // `:on-*` slot (a per-prior-`:versao` state-migration callback
1471        // the operator pins through a future `:behavior
1472        // :on-state-change-overrides` slot the
1473        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
1474        // callback-dispatch wire acknowledges, a per-tenant callback
1475        // alias table the M4 CR materializer resolves per-CR, a
1476        // per-cluster callback overlay the operator pins through a
1477        // future placement-scoped slot) reaches both production
1478        // consumers of the iterator (the layout checker's existence
1479        // sweep in `layout.rs` + the sibling [`BehaviorSpec::validate`]
1480        // value-shape gate) by construction, without a coordinated
1481        // rewrite of the iterator's six raw-field-access sites and the
1482        // six accessor bodies in lockstep. Peer of the sibling
1483        // `declared_slots_labels_route_through_lifted_author_key_consts`
1484        // pin on the tag-surface axis — same "one typed dispatch on the
1485        // substrate primitive, thin projections at each consumer"
1486        // discipline extended onto the peer per-arm `Option<&Path>`
1487        // path-value axis.
1488        //
1489        // Byte-equal today (each `on_*()` accessor is a thin
1490        // `.as_deref()` on the raw `Option<PathBuf>` field); the pin
1491        // catches any future accessor-side extension whose iterator
1492        // read regresses to the raw field.
1493        let b = BehaviorSpec {
1494            on_init: Some(PathBuf::from("lib/init.lisp")),
1495            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1496            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1497            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1498            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1499            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1500        };
1501        let entries: Vec<(&'static str, &Path)> = b.declared_slots().collect();
1502        assert_eq!(
1503            entries,
1504            vec![
1505                (M2_BEHAVIOR_AUTHOR_KEY_ON_INIT, b.on_init().unwrap()),
1506                (M2_BEHAVIOR_AUTHOR_KEY_ON_CALL, b.on_call().unwrap()),
1507                (M2_BEHAVIOR_AUTHOR_KEY_ON_CAST, b.on_cast().unwrap()),
1508                (M2_BEHAVIOR_AUTHOR_KEY_ON_INFO, b.on_info().unwrap()),
1509                (
1510                    M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE,
1511                    b.on_state_change().unwrap(),
1512                ),
1513                (
1514                    M2_BEHAVIOR_AUTHOR_KEY_ON_TERMINATE,
1515                    b.on_terminate().unwrap(),
1516                ),
1517            ],
1518            "declared_slots must route each of its six per-arm \
1519             Option<&Path> path-values through the sibling lifted \
1520             BehaviorSpec::on_* accessor for its slot, so future \
1521             accessor-side extensions reach both the layout checker \
1522             + validate gate by construction (got {entries:?})",
1523        );
1524    }
1525
1526    #[test]
1527    fn declared_paths_routes_through_lifted_on_star_accessors() {
1528        // Sibling of `declared_slots_paths_route_through_lifted_
1529        // on_star_accessors` on the peer path-only projection axis:
1530        // [`BehaviorSpec::declared_paths`] must project each declared
1531        // callback path through the lifted per-slot
1532        // [`BehaviorSpec::on_*`] accessor, so the layout checker's
1533        // `for p in b.declared_paths() { root.join(p) }` on-disk
1534        // existence sweep at `layout.rs:901` reaches every future
1535        // accessor-side extension without a coordinated rewrite of the
1536        // sibling `declared_slots` internal iterator + the accessor
1537        // bodies in lockstep.
1538        let b = BehaviorSpec {
1539            on_init: Some(PathBuf::from("lib/init.lisp")),
1540            on_call: Some(PathBuf::from("lib/rpc/call.lisp")),
1541            on_cast: Some(PathBuf::from("lib/rpc/cast.lisp")),
1542            on_info: Some(PathBuf::from("lib/rpc/info.lisp")),
1543            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1544            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1545        };
1546        let paths: Vec<&Path> = b.declared_paths().collect();
1547        assert_eq!(
1548            paths,
1549            vec![
1550                b.on_init().unwrap(),
1551                b.on_call().unwrap(),
1552                b.on_cast().unwrap(),
1553                b.on_info().unwrap(),
1554                b.on_state_change().unwrap(),
1555                b.on_terminate().unwrap(),
1556            ],
1557            "declared_paths must project each callback path through \
1558             the sibling lifted BehaviorSpec::on_* accessor for its \
1559             slot (got {paths:?})",
1560        );
1561    }
1562
1563    // ── per-`:behavior :on-state-change` accessor pins ─────────────────────
1564
1565    #[test]
1566    fn behavior_on_state_change_returns_option_path_verbatim_across_permutations() {
1567        // Canonical per-`:behavior` `:on-state-change` OTP-`code_change/3`-
1568        // shaped callback-path scalar pin: [`BehaviorSpec::on_state_change`]
1569        // must return the `:behavior :on-state-change` typed `PathBuf`
1570        // verbatim as an `Option<&Path>`, borrowed from the raw
1571        // `Option<PathBuf>` field access across the three canonical
1572        // shape-arms — `None` (no callback declared — the caixa exposes
1573        // no hot-upgrade state-fold path), `Some("lib/migrations.lisp")`
1574        // (the canonical single-file shape the module-doc example uses),
1575        // `Some("lib/migrations/v01-to-v02.lisp")` (the per-version
1576        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1577        // wasm-engine callback-dispatch wire acknowledges).
1578        //
1579        // Peer of the sibling per-`:placement` [`crate::Placement::shard_key`]
1580        // (7cd2a28) / [`crate::Placement::affinity`] (74ec2d3)
1581        // `Option<&str>` accessor pin on the sibling `Option<Str>`-return
1582        // axis, extended to the peer per-`:behavior` typed-`PathBuf`
1583        // optional-scalar shape — first `Option<&Path>`-return accessor
1584        // on the M2 `:behavior` slot family. Pins against a future silent
1585        // detour that re-derived the callback path from a peer axis (an
1586        // accidental `.on_call`-collapse that assumed the two
1587        // `Option<PathBuf>` axes carry the same value), a `None` →
1588        // `Some(empty)` collapse (the canonical
1589        // `Option<PathBuf>` → `PathBuf::new()` footgun the
1590        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
1591        // path-shape axis), or a per-arm variant swap that landed on one
1592        // consumer without the other.
1593        for path in [
1594            None,
1595            Some(PathBuf::from("lib/migrations.lisp")),
1596            Some(PathBuf::from("lib/migrations/v01-to-v02.lisp")),
1597        ] {
1598            let b = BehaviorSpec {
1599                on_state_change: path.clone(),
1600                ..BehaviorSpec::default()
1601            };
1602            assert_eq!(
1603                b.on_state_change(),
1604                path.as_deref(),
1605                "BehaviorSpec::on_state_change must return the \
1606                 :behavior :on-state-change PathBuf verbatim as \
1607                 Option<&Path> (got {:?}, expected {:?})",
1608                b.on_state_change(),
1609                path.as_deref(),
1610            );
1611            assert_eq!(
1612                b.on_state_change(),
1613                b.on_state_change.as_deref(),
1614                "BehaviorSpec::on_state_change must byte-equal the \
1615                 raw .on_state_change.as_deref() field access across \
1616                 every value in the accept-set",
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn behavior_on_state_change_is_independent_of_peer_on_star_axes() {
1623        // Cross-axis independence pin: flipping only the
1624        // `:on-state-change` axis flips [`BehaviorSpec::on_state_change`]
1625        // independently of every peer `:on-*` axis
1626        // (`:on-init` / `:on-call` / `:on-cast` / `:on-info` /
1627        // `:on-terminate`). A future silent detour that re-derived the
1628        // callback path from a peer axis (an accidental `.on_call`-
1629        // collapse, a "state-change falls back to on-info" default that
1630        // would silently rebind the callback dispatch to the wrong
1631        // slot) surfaces here as a build-time test failure.
1632        //
1633        // Mirrors the sibling `limits_is_empty_memory_arm_routes_through_accessor`
1634        // (620c067) cross-axis pin on the peer M2 `:limits` slot
1635        // family — each accessor-lift closes exactly one axis and
1636        // leaves every peer axis unshifted.
1637        let base = BehaviorSpec {
1638            on_init: Some(PathBuf::from("lib/init.lisp")),
1639            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1640            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1641            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1642            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1643            ..BehaviorSpec::default()
1644        };
1645        assert_eq!(base.on_state_change(), None);
1646        let with = BehaviorSpec {
1647            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1648            ..base.clone()
1649        };
1650        assert_eq!(
1651            with.on_state_change(),
1652            Some(PathBuf::from("lib/migrations.lisp").as_path()),
1653            "BehaviorSpec::on_state_change must project the \
1654             :on-state-change axis independently of every peer :on-* \
1655             axis (got {:?})",
1656            with.on_state_change(),
1657        );
1658    }
1659
1660    #[test]
1661    fn validate_upgrade_from_against_behavior_routes_through_on_state_change_accessor() {
1662        // Production-through-const pin: the sole caixa-core consumer of
1663        // the accessor's `Option<&Path>` presence — the
1664        // [`crate::validate_upgrade_from_against_behavior`] cross-slot
1665        // composition gate — must route through
1666        // [`BehaviorSpec::on_state_change`] rather than the raw
1667        // `.on_state_change` field, so the gate's short-circuit and
1668        // every future accessor-side extension (a per-prior-`:versao`
1669        // callback override, a per-tenant migration alias table) land
1670        // as one edit at the accessor rather than as a coordinated
1671        // two-site rewrite of the gate + accessor.
1672        //
1673        // Peer of the sibling `declared_slots_labels_route_through_
1674        // lifted_author_key_consts` (production-through-const pin on
1675        // the label surface) and the sibling
1676        // `limits_is_empty_memory_arm_routes_through_accessor`
1677        // (620c067) pin on the peer M2 `:limits` slot family.
1678        //
1679        // Positive control: a `:behavior :on-state-change` callback
1680        // declared + a `:upgrade-from` entry carrying a
1681        // `(:state-change …)` instruction admits, because the accessor
1682        // returns `Some(&Path)` and the gate's short-circuit fires.
1683        let entries = vec![crate::UpgradeFromEntry {
1684            from: "0.1.0".to_string(),
1685            instructions: vec![
1686                crate::UpgradeInstruction::LoadModule {
1687                    module: "codec".to_string(),
1688                },
1689                crate::UpgradeInstruction::StateChange {
1690                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
1691                },
1692            ],
1693        }];
1694        let b = BehaviorSpec {
1695            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1696            ..BehaviorSpec::default()
1697        };
1698        assert!(b.on_state_change().is_some());
1699        crate::validate_upgrade_from_against_behavior(&entries, Some(&b))
1700            .expect("callback declared → gate admits");
1701
1702        // Negative control: dropping only the accessor's slot to `None`
1703        // (with the same `:upgrade-from` entry) flips the gate to
1704        // refusal — the accessor's `None` return is the sole predicate
1705        // the short-circuit reads.
1706        let b_no_cb = BehaviorSpec::default();
1707        assert_eq!(b_no_cb.on_state_change(), None);
1708        let err = crate::validate_upgrade_from_against_behavior(&entries, Some(&b_no_cb))
1709            .expect_err(":state-change instruction without callback → refuse");
1710        assert!(matches!(
1711            err,
1712            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1713        ));
1714
1715        // `behavior: None` is the same refusal shape — the accessor
1716        // isn't reached, but `Option::and_then` on `None` short-circuits
1717        // to `None`, so the diagnostic is identical.
1718        let err = crate::validate_upgrade_from_against_behavior(&entries, None)
1719            .expect_err("behavior absent + :state-change instruction → refuse");
1720        assert!(matches!(
1721            err,
1722            crate::UpgradeError::StateChangeWithoutOnStateChangeCallback { .. }
1723        ));
1724    }
1725
1726    // ── per-`:behavior :on-init` accessor pins ─────────────────────
1727
1728    #[test]
1729    fn behavior_on_init_returns_option_path_verbatim_across_permutations() {
1730        // Canonical per-`:behavior` `:on-init` OTP-`init/1`-shaped
1731        // callback-path scalar pin: [`BehaviorSpec::on_init`] must
1732        // return the `:behavior :on-init` typed `PathBuf` verbatim as
1733        // an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
1734        // field access across the three canonical shape-arms — `None`
1735        // (no callback declared — the runtime falls back to the
1736        // wasm-engine's no-op instance-start default), `Some("lib/init.lisp")`
1737        // (the canonical single-file shape the module-doc example
1738        // uses), `Some("lib/lifecycle/init.lisp")` (the per-lifecycle
1739        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1740        // wasm-engine callback-dispatch wire acknowledges).
1741        //
1742        // Peer of the sibling per-`:behavior` [`BehaviorSpec::on_state_change`]
1743        // (9b4ecde) `Option<&Path>` accessor pin on the sibling
1744        // `Option<PathBuf>`-return axis — second `Option<&Path>`-return
1745        // accessor on the M2 `:behavior` slot family. Pins against a
1746        // future silent detour that re-derived the callback path from a
1747        // peer axis (an accidental `.on_call`-collapse that assumed the
1748        // two `Option<PathBuf>` axes carry the same value), a `None` →
1749        // `Some(empty)` collapse (the canonical
1750        // `Option<PathBuf>` → `PathBuf::new()` footgun the
1751        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
1752        // path-shape axis), or a per-arm variant swap that landed on
1753        // one consumer without the other.
1754        for path in [
1755            None,
1756            Some(PathBuf::from("lib/init.lisp")),
1757            Some(PathBuf::from("lib/lifecycle/init.lisp")),
1758        ] {
1759            let b = BehaviorSpec {
1760                on_init: path.clone(),
1761                ..BehaviorSpec::default()
1762            };
1763            assert_eq!(
1764                b.on_init(),
1765                path.as_deref(),
1766                "BehaviorSpec::on_init must return the \
1767                 :behavior :on-init PathBuf verbatim as \
1768                 Option<&Path> (got {:?}, expected {:?})",
1769                b.on_init(),
1770                path.as_deref(),
1771            );
1772            assert_eq!(
1773                b.on_init(),
1774                b.on_init.as_deref(),
1775                "BehaviorSpec::on_init must byte-equal the \
1776                 raw .on_init.as_deref() field access across \
1777                 every value in the accept-set",
1778            );
1779        }
1780    }
1781
1782    #[test]
1783    fn behavior_on_init_is_independent_of_peer_on_star_axes() {
1784        // Cross-axis independence pin: flipping only the `:on-init`
1785        // axis flips [`BehaviorSpec::on_init`] independently of every
1786        // peer `:on-*` axis (`:on-call` / `:on-cast` / `:on-info` /
1787        // `:on-state-change` / `:on-terminate`). A future silent detour
1788        // that re-derived the callback path from a peer axis (an
1789        // accidental `.on_call`-collapse, a "init falls back to
1790        // state-change" default that would silently rebind the callback
1791        // dispatch to the wrong slot) surfaces here as a build-time
1792        // test failure.
1793        //
1794        // Peer of the sibling
1795        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
1796        // (9b4ecde) cross-axis pin on the sibling
1797        // `:on-state-change` axis — each accessor-lift closes exactly
1798        // one axis and leaves every peer axis unshifted.
1799        let base = BehaviorSpec {
1800            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1801            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1802            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1803            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1804            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1805            ..BehaviorSpec::default()
1806        };
1807        assert_eq!(base.on_init(), None);
1808        let with = BehaviorSpec {
1809            on_init: Some(PathBuf::from("lib/init.lisp")),
1810            ..base.clone()
1811        };
1812        assert_eq!(
1813            with.on_init(),
1814            Some(PathBuf::from("lib/init.lisp").as_path()),
1815            "BehaviorSpec::on_init must project the \
1816             :on-init axis independently of every peer :on-* \
1817             axis (got {:?})",
1818            with.on_init(),
1819        );
1820    }
1821
1822    // ── per-`:behavior :on-call` accessor pins ─────────────────────
1823
1824    #[test]
1825    fn behavior_on_call_returns_option_path_verbatim_across_permutations() {
1826        // Canonical per-`:behavior` `:on-call`
1827        // OTP-`gen_server:handle_call/3`-shaped synchronous
1828        // request/response callback-path scalar pin:
1829        // [`BehaviorSpec::on_call`] must return the `:behavior :on-call`
1830        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
1831        // the raw `Option<PathBuf>` field access across the three
1832        // canonical shape-arms — `None` (no callback declared — the
1833        // runtime falls back to the wasm-engine's raw
1834        // `wasi:http/incoming-handler` default), `Some("lib/handlers.lisp")`
1835        // (the canonical single-file shape the module-doc example
1836        // uses), `Some("lib/rpc/call.lisp")` (the per-dispatch
1837        // sub-directory shape the `theory/ABSORPTION-ROADMAP.md` M2.5
1838        // wasm-engine callback-dispatch wire acknowledges).
1839        //
1840        // Peer of the sibling per-`:behavior`
1841        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
1842        // [`BehaviorSpec::on_init`] (d66c702) `Option<&Path>` accessor
1843        // pins on the sibling `Option<PathBuf>`-return axes — third
1844        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
1845        // family. Pins against a future silent detour that re-derived
1846        // the callback path from a peer axis (an accidental
1847        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
1848        // axes carry the same value — a plausible slip because the
1849        // module-doc example shares one `lib/handlers.lisp` file
1850        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
1851        // that the tatara-lisp dispatch inside the file discriminates
1852        // on the callback-kind atom), a `None` → `Some(empty)` collapse
1853        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
1854        // the [`BehaviorError::EmptyPath`] validate arm guards on the
1855        // peer path-shape axis), or a per-arm variant swap that landed
1856        // on one consumer without the other.
1857        for path in [
1858            None,
1859            Some(PathBuf::from("lib/handlers.lisp")),
1860            Some(PathBuf::from("lib/rpc/call.lisp")),
1861        ] {
1862            let b = BehaviorSpec {
1863                on_call: path.clone(),
1864                ..BehaviorSpec::default()
1865            };
1866            assert_eq!(
1867                b.on_call(),
1868                path.as_deref(),
1869                "BehaviorSpec::on_call must return the \
1870                 :behavior :on-call PathBuf verbatim as \
1871                 Option<&Path> (got {:?}, expected {:?})",
1872                b.on_call(),
1873                path.as_deref(),
1874            );
1875            assert_eq!(
1876                b.on_call(),
1877                b.on_call.as_deref(),
1878                "BehaviorSpec::on_call must byte-equal the \
1879                 raw .on_call.as_deref() field access across \
1880                 every value in the accept-set",
1881            );
1882        }
1883    }
1884
1885    #[test]
1886    fn behavior_on_call_is_independent_of_peer_on_star_axes() {
1887        // Cross-axis independence pin: flipping only the `:on-call`
1888        // axis flips [`BehaviorSpec::on_call`] independently of every
1889        // peer `:on-*` axis (`:on-init` / `:on-cast` / `:on-info` /
1890        // `:on-state-change` / `:on-terminate`). A future silent detour
1891        // that re-derived the callback path from a peer axis (an
1892        // accidental `.on_cast`-collapse — the sibling asynchronous
1893        // fire-and-forget arm on the peer OTP dispatch triad, a
1894        // plausible confusion because both callbacks share the
1895        // `handle_*/2|3` OTP shape — that would silently rebind the
1896        // synchronous request/response dispatch to the sibling
1897        // fire-and-forget slot's callback) surfaces here as a
1898        // build-time test failure.
1899        //
1900        // Peer of the sibling
1901        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
1902        // (9b4ecde) /
1903        // `behavior_on_init_is_independent_of_peer_on_star_axes`
1904        // (d66c702) cross-axis pins on the sibling `:on-state-change` /
1905        // `:on-init` axes — each accessor-lift closes exactly one axis
1906        // and leaves every peer axis unshifted.
1907        let base = BehaviorSpec {
1908            on_init: Some(PathBuf::from("lib/init.lisp")),
1909            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
1910            on_info: Some(PathBuf::from("lib/handlers.lisp")),
1911            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
1912            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
1913            ..BehaviorSpec::default()
1914        };
1915        assert_eq!(base.on_call(), None);
1916        let with = BehaviorSpec {
1917            on_call: Some(PathBuf::from("lib/handlers.lisp")),
1918            ..base.clone()
1919        };
1920        assert_eq!(
1921            with.on_call(),
1922            Some(PathBuf::from("lib/handlers.lisp").as_path()),
1923            "BehaviorSpec::on_call must project the \
1924             :on-call axis independently of every peer :on-* \
1925             axis (got {:?})",
1926            with.on_call(),
1927        );
1928    }
1929
1930    // ── per-`:behavior :on-cast` accessor pins ─────────────────────
1931
1932    #[test]
1933    fn behavior_on_cast_returns_option_path_verbatim_across_permutations() {
1934        // Canonical per-`:behavior` `:on-cast`
1935        // OTP-`gen_server:handle_cast/2`-shaped asynchronous
1936        // fire-and-forget callback-path scalar pin:
1937        // [`BehaviorSpec::on_cast`] must return the `:behavior :on-cast`
1938        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
1939        // the raw `Option<PathBuf>` field access across the three
1940        // canonical shape-arms — `None` (no callback declared — the
1941        // runtime falls back to the wasm-engine's default `Accepted:
1942        // 202` fire-and-forget shape), `Some("lib/handlers.lisp")` (the
1943        // canonical single-file shape the module-doc example uses,
1944        // shared with `:on-call` / `:on-info` on the pattern that the
1945        // tatara-lisp dispatch inside the file discriminates on the
1946        // callback-kind atom), `Some("lib/rpc/cast.lisp")` (the
1947        // per-dispatch sub-directory shape the
1948        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
1949        // callback-dispatch wire acknowledges).
1950        //
1951        // Peer of the sibling per-`:behavior`
1952        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
1953        // [`BehaviorSpec::on_init`] (d66c702) /
1954        // [`BehaviorSpec::on_call`] (156ddbe) `Option<&Path>` accessor
1955        // pins on the sibling `Option<PathBuf>`-return axes — fourth
1956        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
1957        // family. Pins against a future silent detour that re-derived
1958        // the callback path from a peer axis (an accidental
1959        // `.on_call`-collapse that assumed the two `Option<PathBuf>`
1960        // axes carry the same value — a plausible slip because both
1961        // callbacks share the `handle_*/2|3` OTP shape and the
1962        // module-doc example shares one `lib/handlers.lisp` file
1963        // between `:on-call` / `:on-cast` / `:on-info`), a `None` →
1964        // `Some(empty)` collapse (the canonical `Option<PathBuf>` →
1965        // `PathBuf::new()` footgun the [`BehaviorError::EmptyPath`]
1966        // validate arm guards on the peer path-shape axis), or a
1967        // per-arm variant swap that landed on one consumer without the
1968        // other.
1969        for path in [
1970            None,
1971            Some(PathBuf::from("lib/handlers.lisp")),
1972            Some(PathBuf::from("lib/rpc/cast.lisp")),
1973        ] {
1974            let b = BehaviorSpec {
1975                on_cast: path.clone(),
1976                ..BehaviorSpec::default()
1977            };
1978            assert_eq!(
1979                b.on_cast(),
1980                path.as_deref(),
1981                "BehaviorSpec::on_cast must return the \
1982                 :behavior :on-cast PathBuf verbatim as \
1983                 Option<&Path> (got {:?}, expected {:?})",
1984                b.on_cast(),
1985                path.as_deref(),
1986            );
1987            assert_eq!(
1988                b.on_cast(),
1989                b.on_cast.as_deref(),
1990                "BehaviorSpec::on_cast must byte-equal the \
1991                 raw .on_cast.as_deref() field access across \
1992                 every value in the accept-set",
1993            );
1994        }
1995    }
1996
1997    #[test]
1998    fn behavior_on_cast_is_independent_of_peer_on_star_axes() {
1999        // Cross-axis independence pin: flipping only the `:on-cast`
2000        // axis flips [`BehaviorSpec::on_cast`] independently of every
2001        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-info` /
2002        // `:on-state-change` / `:on-terminate`). A future silent detour
2003        // that re-derived the callback path from a peer axis (an
2004        // accidental `.on_call`-collapse — the sibling synchronous
2005        // request/response arm on the peer OTP dispatch triad, a
2006        // plausible confusion because both callbacks share the
2007        // `handle_*/2|3` OTP shape and the module-doc example shares
2008        // one `lib/handlers.lisp` file between the two — that would
2009        // silently rebind the asynchronous fire-and-forget dispatch to
2010        // the sibling synchronous request/response slot's callback)
2011        // surfaces here as a build-time test failure.
2012        //
2013        // Peer of the sibling
2014        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2015        // (9b4ecde) /
2016        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2017        // (d66c702) /
2018        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2019        // (156ddbe) cross-axis pins on the sibling `:on-state-change` /
2020        // `:on-init` / `:on-call` axes — each accessor-lift closes
2021        // exactly one axis and leaves every peer axis unshifted.
2022        let base = BehaviorSpec {
2023            on_init: Some(PathBuf::from("lib/init.lisp")),
2024            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2025            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2026            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2027            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2028            ..BehaviorSpec::default()
2029        };
2030        assert_eq!(base.on_cast(), None);
2031        let with = BehaviorSpec {
2032            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2033            ..base.clone()
2034        };
2035        assert_eq!(
2036            with.on_cast(),
2037            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2038            "BehaviorSpec::on_cast must project the \
2039             :on-cast axis independently of every peer :on-* \
2040             axis (got {:?})",
2041            with.on_cast(),
2042        );
2043    }
2044
2045    // ── per-`:behavior :on-info` accessor pins ─────────────────────
2046
2047    #[test]
2048    fn behavior_on_info_returns_option_path_verbatim_across_permutations() {
2049        // Canonical per-`:behavior` `:on-info`
2050        // OTP-`gen_server:handle_info/2`-shaped system / out-of-band
2051        // message-handler callback-path scalar pin:
2052        // [`BehaviorSpec::on_info`] must return the `:behavior :on-info`
2053        // typed `PathBuf` verbatim as an `Option<&Path>`, borrowed from
2054        // the raw `Option<PathBuf>` field access across the three
2055        // canonical shape-arms — `None` (no callback declared — the
2056        // runtime silently drops every out-of-band mailbox message),
2057        // `Some("lib/handlers.lisp")` (the canonical single-file shape
2058        // the module-doc example uses, shared with `:on-call` /
2059        // `:on-cast` on the pattern that the tatara-lisp dispatch
2060        // inside the file discriminates on the callback-kind atom),
2061        // `Some("lib/rpc/info.lisp")` (the per-dispatch sub-directory
2062        // shape the `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2063        // callback-dispatch wire acknowledges).
2064        //
2065        // Peer of the sibling per-`:behavior`
2066        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2067        // [`BehaviorSpec::on_init`] (d66c702) /
2068        // [`BehaviorSpec::on_call`] (156ddbe) /
2069        // [`BehaviorSpec::on_cast`] (99616ac) `Option<&Path>` accessor
2070        // pins on the sibling `Option<PathBuf>`-return axes — fifth
2071        // `Option<&Path>`-return accessor on the M2 `:behavior` slot
2072        // family. Pins against a future silent detour that re-derived
2073        // the callback path from a peer axis (an accidental
2074        // `.on_cast`-collapse that assumed the two `Option<PathBuf>`
2075        // axes carry the same value — a plausible slip because the
2076        // module-doc example shares one `lib/handlers.lisp` file
2077        // between `:on-call` / `:on-cast` / `:on-info` on the pattern
2078        // that the tatara-lisp dispatch inside the file discriminates
2079        // on the callback-kind atom), a `None` → `Some(empty)` collapse
2080        // (the canonical `Option<PathBuf>` → `PathBuf::new()` footgun
2081        // the [`BehaviorError::EmptyPath`] validate arm guards on the
2082        // peer path-shape axis), or a per-arm variant swap that landed
2083        // on one consumer without the other.
2084        for path in [
2085            None,
2086            Some(PathBuf::from("lib/handlers.lisp")),
2087            Some(PathBuf::from("lib/rpc/info.lisp")),
2088        ] {
2089            let b = BehaviorSpec {
2090                on_info: path.clone(),
2091                ..BehaviorSpec::default()
2092            };
2093            assert_eq!(
2094                b.on_info(),
2095                path.as_deref(),
2096                "BehaviorSpec::on_info must return the \
2097                 :behavior :on-info PathBuf verbatim as \
2098                 Option<&Path> (got {:?}, expected {:?})",
2099                b.on_info(),
2100                path.as_deref(),
2101            );
2102            assert_eq!(
2103                b.on_info(),
2104                b.on_info.as_deref(),
2105                "BehaviorSpec::on_info must byte-equal the \
2106                 raw .on_info.as_deref() field access across \
2107                 every value in the accept-set",
2108            );
2109        }
2110    }
2111
2112    #[test]
2113    fn behavior_on_info_is_independent_of_peer_on_star_axes() {
2114        // Cross-axis independence pin: flipping only the `:on-info`
2115        // axis flips [`BehaviorSpec::on_info`] independently of every
2116        // peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast` /
2117        // `:on-state-change` / `:on-terminate`). A future silent detour
2118        // that re-derived the callback path from a peer axis (an
2119        // accidental `.on_cast`-collapse — the sibling asynchronous
2120        // fire-and-forget arm on the peer OTP dispatch triad, a
2121        // plausible confusion because the module-doc example shares one
2122        // `lib/handlers.lisp` file between `:on-call` / `:on-cast` /
2123        // `:on-info` — that would silently rebind the out-of-band-info
2124        // dispatch to the sibling asynchronous fire-and-forget slot's
2125        // callback) surfaces here as a build-time test failure.
2126        //
2127        // Peer of the sibling
2128        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2129        // (9b4ecde) /
2130        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2131        // (d66c702) /
2132        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2133        // (156ddbe) /
2134        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2135        // (99616ac) cross-axis pins on the sibling `:on-state-change` /
2136        // `:on-init` / `:on-call` / `:on-cast` axes — each
2137        // accessor-lift closes exactly one axis and leaves every peer
2138        // axis unshifted.
2139        let base = BehaviorSpec {
2140            on_init: Some(PathBuf::from("lib/init.lisp")),
2141            on_call: Some(PathBuf::from("lib/handlers.lisp")),
2142            on_cast: Some(PathBuf::from("lib/handlers.lisp")),
2143            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2144            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2145            ..BehaviorSpec::default()
2146        };
2147        assert_eq!(base.on_info(), None);
2148        let with = BehaviorSpec {
2149            on_info: Some(PathBuf::from("lib/handlers.lisp")),
2150            ..base.clone()
2151        };
2152        assert_eq!(
2153            with.on_info(),
2154            Some(PathBuf::from("lib/handlers.lisp").as_path()),
2155            "BehaviorSpec::on_info must project the \
2156             :on-info axis independently of every peer :on-* \
2157             axis (got {:?})",
2158            with.on_info(),
2159        );
2160    }
2161
2162    // ── per-`:behavior :on-terminate` accessor pins ────────────────
2163
2164    #[test]
2165    fn behavior_on_terminate_returns_option_path_verbatim_across_permutations() {
2166        // Canonical per-`:behavior` `:on-terminate`
2167        // OTP-`gen_server:terminate/2`-shaped graceful-shutdown cleanup
2168        // callback-path scalar pin: [`BehaviorSpec::on_terminate`] must
2169        // return the `:behavior :on-terminate` typed `PathBuf` verbatim
2170        // as an `Option<&Path>`, borrowed from the raw `Option<PathBuf>`
2171        // field access across the three canonical shape-arms — `None`
2172        // (no callback declared — the runtime tears down the wasm
2173        // instance without dispatching any author-supplied cleanup
2174        // side-effect), `Some("lib/cleanup.lisp")` (the canonical
2175        // single-file shape the module-doc example uses, the
2176        // author-surface pins `lib/cleanup.lisp` as the reference
2177        // `:on-terminate` value), `Some("lib/lifecycle/terminate.lisp")`
2178        // (the per-lifecycle-arm sub-directory shape the
2179        // `theory/ABSORPTION-ROADMAP.md` M2.5 wasm-engine
2180        // callback-dispatch wire acknowledges).
2181        //
2182        // Peer of the sibling per-`:behavior`
2183        // [`BehaviorSpec::on_state_change`] (9b4ecde) /
2184        // [`BehaviorSpec::on_init`] (d66c702) /
2185        // [`BehaviorSpec::on_call`] (156ddbe) /
2186        // [`BehaviorSpec::on_cast`] (99616ac) /
2187        // [`BehaviorSpec::on_info`] (4846cef) `Option<&Path>` accessor
2188        // pins on the sibling `Option<PathBuf>`-return axes — sixth and
2189        // final `Option<&Path>`-return accessor on the M2 `:behavior`
2190        // slot family, closes the last unlifted per-`:behavior`
2191        // `Option<&Path>` scalar-value axis. Pins against a future
2192        // silent detour that re-derived the callback path from a peer
2193        // axis (an accidental `.on_init`-collapse that assumed the two
2194        // lifecycle-arm `Option<PathBuf>` axes carry the same value —
2195        // a plausible slip because both are the lifecycle-head /
2196        // lifecycle-tail bookends of the OTP `gen_server` lifecycle, so
2197        // the "run once per instance" semantics rhyme across the two
2198        // arms), a `None` → `Some(empty)` collapse (the canonical
2199        // `Option<PathBuf>` → `PathBuf::new()` footgun the
2200        // [`BehaviorError::EmptyPath`] validate arm guards on the peer
2201        // path-shape axis), or a per-arm variant swap that landed on
2202        // one consumer without the other.
2203        for path in [
2204            None,
2205            Some(PathBuf::from("lib/cleanup.lisp")),
2206            Some(PathBuf::from("lib/lifecycle/terminate.lisp")),
2207        ] {
2208            let b = BehaviorSpec {
2209                on_terminate: path.clone(),
2210                ..BehaviorSpec::default()
2211            };
2212            assert_eq!(
2213                b.on_terminate(),
2214                path.as_deref(),
2215                "BehaviorSpec::on_terminate must return the \
2216                 :behavior :on-terminate PathBuf verbatim as \
2217                 Option<&Path> (got {:?}, expected {:?})",
2218                b.on_terminate(),
2219                path.as_deref(),
2220            );
2221            assert_eq!(
2222                b.on_terminate(),
2223                b.on_terminate.as_deref(),
2224                "BehaviorSpec::on_terminate must byte-equal the \
2225                 raw .on_terminate.as_deref() field access across \
2226                 every value in the accept-set",
2227            );
2228        }
2229    }
2230
2231    #[test]
2232    fn behavior_on_terminate_is_independent_of_peer_on_star_axes() {
2233        // Cross-axis independence pin: flipping only the `:on-terminate`
2234        // axis flips [`BehaviorSpec::on_terminate`] independently of
2235        // every peer `:on-*` axis (`:on-init` / `:on-call` / `:on-cast`
2236        // / `:on-info` / `:on-state-change`). A future silent detour
2237        // that re-derived the callback path from a peer axis (an
2238        // accidental `.on_init`-collapse — the sibling lifecycle-head
2239        // arm on the peer OTP dispatch lifecycle, a plausible confusion
2240        // because both are the lifecycle-bookend arms that run
2241        // once-per-instance rather than per-mailbox-turn — that would
2242        // silently rebind the graceful-tear-down dispatch to the
2243        // sibling instance-start slot's callback) surfaces here as a
2244        // build-time test failure.
2245        //
2246        // Peer of the sibling
2247        // `behavior_on_state_change_is_independent_of_peer_on_star_axes`
2248        // (9b4ecde) /
2249        // `behavior_on_init_is_independent_of_peer_on_star_axes`
2250        // (d66c702) /
2251        // `behavior_on_call_is_independent_of_peer_on_star_axes`
2252        // (156ddbe) /
2253        // `behavior_on_cast_is_independent_of_peer_on_star_axes`
2254        // (99616ac) /
2255        // `behavior_on_info_is_independent_of_peer_on_star_axes`
2256        // (4846cef) cross-axis pins on the sibling `:on-state-change` /
2257        // `:on-init` / `:on-call` / `:on-cast` / `:on-info` axes —
2258        // each accessor-lift closes exactly one axis and leaves every
2259        // peer axis unshifted, so the six-callback OTP `gen_server`
2260        // lifecycle the slot family models routes through one typed
2261        // dispatch per arm on the substrate primitive.
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_info: Some(PathBuf::from("lib/handlers.lisp")),
2267            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
2268            ..BehaviorSpec::default()
2269        };
2270        assert_eq!(base.on_terminate(), None);
2271        let with = BehaviorSpec {
2272            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
2273            ..base.clone()
2274        };
2275        assert_eq!(
2276            with.on_terminate(),
2277            Some(PathBuf::from("lib/cleanup.lisp").as_path()),
2278            "BehaviorSpec::on_terminate must project the \
2279             :on-terminate axis independently of every peer :on-* \
2280             axis (got {:?})",
2281            with.on_terminate(),
2282        );
2283    }
2284}