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