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