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