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