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