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