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