caixa_core/upgrade.rs
1//! Erlang/OTP-style appup — declarative upgrade instructions per
2//! prior caixa version. Composes with the `:behavior :on-state-change`
3//! callback to deliver state migration during hot upgrades.
4//!
5//! See `theory/INSPIRATIONS.md` §II.4 for the prior-art frame.
6//!
7//! ```lisp
8//! (defcaixa
9//! :nome "hello-rio"
10//! :versao "0.2.0"
11//! :upgrade-from
12//! ((:from "0.1.0"
13//! :instructions ((:load-module "hello-rio")
14//! (:state-change "lib/migrations/v01-to-v02.lisp")
15//! (:soft-purge "hello-rio-old")))
16//! (:from "0.1.5"
17//! :instructions ((:load-module "hello-rio")
18//! (:soft-purge "hello-rio-old")))))
19//! ```
20//!
21//! Each `(:from <prior>)` block declares the upgrade path *from* that
22//! version *to* the current `:versao`. wasm-operator picks the
23//! matching block at upgrade time, runs the instructions in order,
24//! and only swaps traffic to the new instance after all instructions
25//! succeed (transactional upgrade). On any failure, the current
26//! version stays load-bearing — a typed atomic upgrade.
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// One upgrade instruction. The set mirrors OTP's appup low-level
34/// instructions: enough to express every common upgrade pattern,
35/// few enough that the wasm-operator can implement each
36/// deterministically.
37#[derive(
38 Serialize,
39 Deserialize,
40 Debug,
41 Clone,
42 PartialEq,
43 Eq,
44 gen_platform::TypedDispatcher,
45 gen_platform::Discriminant,
46 gen_platform::IsVariant,
47)]
48#[serde(tag = "kind", rename_all = "kebab-case")]
49pub enum UpgradeInstruction {
50 /// Load a new wasm module alongside the current one — the analog
51 /// of OTP's `code:load_module/1`. Both versions remain in memory
52 /// after this instruction; in-flight requests stay on the old
53 /// version, new requests route to the new version.
54 LoadModule { module: String },
55
56 /// Run a state-migration tatara-lisp file. Receives the old state
57 /// + the prior version string; returns the new state. Analog of
58 /// `gen_server:code_change/3`.
59 StateChange { script: PathBuf },
60
61 /// Wait for in-flight requests on a named module to drain, then
62 /// GC it — the analog of `code:soft_purge/1`. Default cooldown is
63 /// 60s; longer-running requests block the upgrade.
64 SoftPurge { module: String },
65
66 /// Discard a named module immediately, without waiting for
67 /// drain — the analog of `code:purge/1`. Used when we don't
68 /// care about in-flight callers (cron, oneShot).
69 Purge { module: String },
70
71 /// Fall back to a full restart for this entry. Used when a typed
72 /// upgrade is impossible (e.g. wasm component world incompatible).
73 Restart,
74}
75
76// Fleet-wide dispatcher-catalog registration. UpgradeInstruction is
77// the OTP-style hot-upgrade primitive (load_module/code_change/
78// soft_purge/purge/restart) — the first NON-ADAPTER consumer of
79// gen-platform's typed-dispatcher catamorphism, satisfying the ★★
80// "two classes of consumer" promotion criterion from
81// theory/QUIRK-APPLIER.md §V.1.
82//
83// Operators query via:
84// gen dispatchers --from-catalog | jq '.[] | select(.label=="caixa.upgrade-instruction")'
85//
86// The substrate's lib/build/shared/fleet-catalog-coverage-test.nix
87// adds an assertion row for this label on the next snapshot refresh.
88gen_platform::register_dispatcher!("caixa.upgrade-instruction", UpgradeInstruction);
89
90/// One upgrade entry: the *prior* version we're upgrading from, plus
91/// the instruction sequence to execute.
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct UpgradeFromEntry {
95 /// Semver of the *prior* version. Authored as a literal string;
96 /// validated lazily by [`UpgradeFromEntry::validate`].
97 pub from: String,
98
99 /// Ordered list of instructions to execute. Empty list = "no-op
100 /// upgrade" (rare; usually means only documentation changed).
101 #[serde(default)]
102 pub instructions: Vec<UpgradeInstruction>,
103}
104
105impl UpgradeFromEntry {
106 /// Prior-versao semver-2 literal this entry declares an upgrade
107 /// path *from* — the string the OTP-shape `release_handler:install_release/1`
108 /// analog matches the running caixa's `:versao` against at hot-
109 /// upgrade dispatch time to pick this entry's `:instructions`
110 /// sequence. Returned byte-for-byte from the typed slot's own
111 /// `String` storage; no cloning, no re-parsing.
112 ///
113 /// The M2 companion of the closed M3 mesh-slot scalar-accessor
114 /// family — sibling in shape to [`crate::Membro::versao_requirement`]
115 /// (a40b0e3), [`crate::Membro::nome`] (4a32abf), and the
116 /// [`crate::WitContract::{source, destination, world_ref}`]
117 /// (7f0fd43 / 0804823) / [`crate::Entrada::{hostname, destination}`]
118 /// (11f3dfe / 6db982c) `&str` accessors already routing every
119 /// per-mesh-slot-atom scalar-value axis through one typed dispatch
120 /// on the substrate primitive — extended here onto the first per-
121 /// M2-slot scalar-value axis. Every downstream consumer of the
122 /// M2 `:upgrade-from :from` axis (the [`UpgradeFromEntry::validate`]
123 /// SemVer-2 parse gate, the [`validate_upgrade_from`] cross-entry
124 /// duplicate-detection re-parse assertion, the
125 /// [`validate_upgrade_from_against_versao`] precedence gate,
126 /// the [`validate_upgrade_from_against_behavior`] state-change-
127 /// callback coherence gate, every per-arm error variant carrying
128 /// the offending `:from` verbatim for `feira lint` rendering)
129 /// now reads through this one accessor rather than open-coding
130 /// `&self.from` / `&entry.from` / `self.from.clone()` /
131 /// `entry.from.clone()`.
132 ///
133 /// A future extension of the axis (an M4 typed `:from`-range slot
134 /// composing multiple prior versions into one entry, an operator-
135 /// side pre-parsed [`semver::Version`] cache the accessor could
136 /// materialize behind the same `&str` return contract, a per-
137 /// cluster `:placement`-scoped prior-versao overlay the
138 /// `caixa-operator` reconciles ahead of dispatch) migrates as a
139 /// single caixa-core edit rather than a coordinated rewrite of
140 /// the four validate-side call sites + every downstream error-
141 /// variant carrying `:from`.
142 #[must_use]
143 pub const fn prior_versao(&self) -> &str {
144 self.from.as_str()
145 }
146
147 /// Substrate-canonical per-`:upgrade-from :instructions`
148 /// OTP-appup migration-instruction-list slice-return accessor
149 /// every per-entry instructions-list reader keys off — returns
150 /// the author-declared `:instructions` list verbatim as a
151 /// `&[UpgradeInstruction]` slice-view over the same backing
152 /// buffer the raw `self.instructions.as_slice()` field access
153 /// borrows from. Non-optional: an empty slice is the load-bearing
154 /// "author declared `:instructions ()`" sentinel — the
155 /// `Vec<UpgradeInstruction>::default()`-produced empty tail the
156 /// [`UpgradeFromEntry::instructions`] field's own docstring already
157 /// names as the "no-op upgrade" shape (a metadata-only upgrade
158 /// entry — the operator's `:from`-match dispatch matches the entry
159 /// but runs no instructions, advancing straight to the "traffic
160 /// swap" step) and every peer within-entry cross-instruction gate
161 /// no-ops against without allocating a new `Vec` per gate.
162 ///
163 /// The `:upgrade-from :instructions` slot carries the per-`:from`
164 /// OTP-appup ordered instruction list the wasm-operator's hot-
165 /// upgrade dispatch materializes one per-instruction runtime
166 /// primitive from — the Erlang/OTP appup's per-`{from, to,
167 /// UpgradeInstructions, DowngradeInstructions}` entry's
168 /// `UpgradeInstructions` list (`code:load_module/1` /
169 /// `gen_server:code_change/3` / `code:soft_purge/1` /
170 /// `code:purge/1` / `restart_new_emulator` — see INSPIRATIONS
171 /// §II.4), projected through the tatara-lisp
172 /// `:upgrade-from ((:from … :instructions …))` author surface
173 /// onto a typed `Vec<UpgradeInstruction>` whose per-element
174 /// variant is [`UpgradeInstruction::LoadModule`] /
175 /// [`UpgradeInstruction::StateChange`] /
176 /// [`UpgradeInstruction::SoftPurge`] / [`UpgradeInstruction::Purge`]
177 /// / [`UpgradeInstruction::Restart`]. Every downstream consumer
178 /// that fans on the per-entry instruction list keys off this
179 /// slice (the [`UpgradeFromEntry::validate`] per-instruction
180 /// shape-check fan-out, the seven paired within-entry cross-
181 /// instruction gates [`Self::validate_restart_exclusive`] /
182 /// [`Self::validate_state_change_ordering`] /
183 /// [`Self::validate_purge_ordering`] /
184 /// [`Self::validate_state_change_before_cleanup`] /
185 /// [`Self::validate_load_singularity`] /
186 /// [`Self::validate_state_change_singularity`] /
187 /// [`Self::validate_cleanup_singularity`], the layout-side
188 /// [`crate::layout::StandardLayout`]'s per-`:state-change`
189 /// script-existence fan-out
190 /// ([`crate::layout::LayoutError::MissingEntry`]'s
191 /// `LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT` arm), the cross-slot
192 /// [`validate_upgrade_from_against_behavior`] gate's per-entry
193 /// `:state-change`-instruction detection loop, every future
194 /// wasm-operator (M2.5) per-`:from`-match hot-upgrade dispatch's
195 /// per-instruction runtime-primitive fan-out, every future M4
196 /// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-entry
197 /// upgrade-plan admission-webhook fan-out).
198 ///
199 /// Prior to this lift the `.instructions` `Vec<UpgradeInstruction>`
200 /// was accessed inline at nine production sites across
201 /// `caixa-core/src/upgrade.rs` and `caixa-core/src/layout.rs` —
202 /// the [`UpgradeFromEntry::validate`] per-instruction shape-check
203 /// fan-out (`for instr in &self.instructions`), the paired
204 /// [`Self::validate_restart_exclusive`] restart-count / other-kind
205 /// projections + `.len()` probe (three raw-access sites in one
206 /// gate), the [`Self::validate_state_change_ordering`] /
207 /// [`Self::validate_purge_ordering`] /
208 /// [`Self::validate_state_change_before_cleanup`] /
209 /// [`Self::validate_load_singularity`] /
210 /// [`Self::validate_state_change_singularity`] /
211 /// [`Self::validate_cleanup_singularity`] within-entry cross-
212 /// instruction gate traversal heads, the peer
213 /// [`validate_upgrade_from_against_behavior`] cross-slot
214 /// composition gate's `for instr in &entry.instructions`
215 /// per-entry `:state-change` detection loop, and the
216 /// [`crate::layout::StandardLayout`]-side
217 /// `for instr in &entry.instructions` per-`:state-change`
218 /// script-existence fan-out — nine open-coded field-accesses
219 /// that expressed no compile-time link back to the typed slot.
220 /// A future extension of the `:instructions` axis to a richer
221 /// author surface (a per-cluster overlay the operator pins
222 /// through a future `:upgrade-from :instructions-overrides` slot
223 /// so a canary cluster runs a `(:state-change …)` before the
224 /// production fleet does, a per-tenant instruction-list overlay
225 /// the M4 CR materializer resolves per-CR to inject cluster-
226 /// specific `(:soft-purge …)` cooldown adjustments, a promotion
227 /// of the plain `Vec<UpgradeInstruction>` to a richer
228 /// `{static, dynamic}` partition once virtual-actor-style
229 /// dynamic-instruction composition (an operator-derived
230 /// `(:load-module …)` sequence computed from the running
231 /// module set at upgrade time) comes into typed scope, a
232 /// per-instruction pre-condition scalar the future adaptive-
233 /// upgrade engine reads to bias per-instruction retry
234 /// strategy) would have had to be threaded through all nine
235 /// open-coded copies in lockstep or one consumer would silently
236 /// disagree with the peers on which instruction sequence a
237 /// given `:upgrade-from` entry resolves to — the per-
238 /// instruction shape-check reading the raw slot while the
239 /// paired within-entry ordering gates read an operator-resolved
240 /// slot would silently split the build-time per-entry gate
241 /// cohort from the layout-side script-existence gate + the
242 /// cross-slot behavior-composition gate + the runtime hot-
243 /// upgrade dispatch, a nine-consumer split across the seven
244 /// within-entry cross-instruction gates + the layout invariant +
245 /// the cross-slot composition gate far from the source
246 /// `caixa.lisp` with no field naming the instruction-sequence-
247 /// drift root cause. Lifting the resolution rule to a typed
248 /// method on the substrate primitive means every downstream
249 /// consumer of the per-entry OTP-appup instruction-list surface
250 /// reaches for exactly one typed dispatch — the resolver's
251 /// accept-set migrates as a unit on any future axis addition.
252 ///
253 /// Fifth slice-return (`&[T]`) accessor on any M2 or M3 typed
254 /// slot — sibling to the seed M2
255 /// [`crate::SupervisorSpec::children`] (bc92bce) `&[ChildSpec]`
256 /// accessor on the peer per-`:supervisor` static-child-list
257 /// `Vec`-carry axis, the M3 [`crate::Placement::clusters`]
258 /// (a6e18d7) `&[String]` accessor on the peer per-`:placement`
259 /// distribution-target-list `Vec`-carry axis, the M3
260 /// [`crate::AplicacaoSpec::membros`] (6c77e36) `&[Membro]`
261 /// accessor on the peer per-`:membros` node-list `Vec`-carry
262 /// axis, and the M3 [`crate::AplicacaoSpec::contratos`]
263 /// (0dcc926) `&[WitContract]` accessor on the peer per-
264 /// `:contratos` edge-list `Vec`-carry axis. This lift closes the
265 /// last unlifted `Vec`-carry axis on any M2 or M3 typed slot in
266 /// the substrate — the four peer axes named in the
267 /// [`crate::SupervisorSpec::children`] seed docstring
268 /// (`Placement::clusters`, `AplicacaoSpec::membros`,
269 /// `AplicacaoSpec::contratos`, `UpgradeFromEntry::instructions`)
270 /// are now all closed. The per-`UpgradeFromEntry` type carried
271 /// two axes: the scalar `Copy`-return
272 /// [`UpgradeFromEntry::prior_versao`] (75d27a8) on the
273 /// `:from` axis, and now the slice-return
274 /// [`UpgradeFromEntry::instructions`] on the peer
275 /// `:instructions` axis. Named `instructions()` to match the
276 /// storage field's name verbatim and the tatara-lisp
277 /// author-surface term (`:instructions`) the field's own
278 /// docstring already carries; the accessor's identity maps
279 /// onto the canonical OTP-appup vocabulary the
280 /// [`crate::upgrade`] module doc already reaches for ("runs
281 /// the instructions in order"). Returns `&[UpgradeInstruction]`
282 /// (not `&Vec<UpgradeInstruction>`) because every downstream
283 /// consumer of the instruction list treats it as a read-only
284 /// sequence — the slice-view is the narrowest borrow that
285 /// supports every present + roadmapped consumer (`.iter()`,
286 /// `.len()`, `.filter(...).count()`) without leaking the
287 /// backing `Vec`'s grow/push/reserve surface that no consumer
288 /// of the typed view reaches for (the storage-side `Vec`
289 /// remains reachable through the `pub instructions` field for
290 /// the mutation-carrying `Serialize`/`Deserialize` derive
291 /// round-trip and per-test fixture-mutation paths).
292 #[must_use]
293 pub fn instructions(&self) -> &[UpgradeInstruction] {
294 self.instructions.as_slice()
295 }
296
297 /// Verify the `:from` field is a valid semver, every instruction's
298 /// typed shape, the within-entry `(:restart)`-exclusivity invariant
299 /// (an entry containing `(:restart)` must contain exactly one
300 /// `(:restart)` and nothing else — see
301 /// [`Self::validate_restart_exclusive`]), the within-entry
302 /// state-change-ordering invariant (every `(:state-change …)` must
303 /// be preceded by a `(:load-module …)` — see
304 /// [`Self::validate_state_change_ordering`]), the within-entry
305 /// purge-ordering invariant (every `(:soft-purge …)` / `(:purge …)`
306 /// must be preceded by a `(:load-module …)` — see
307 /// [`Self::validate_purge_ordering`]), the within-entry
308 /// state-change-before-cleanup ordering invariant (no
309 /// `(:state-change …)` may appear after any `(:soft-purge …)` /
310 /// `(:purge …)` — see
311 /// [`Self::validate_state_change_before_cleanup`]), the within-
312 /// entry load-singularity invariant (no module appears as the
313 /// target of `(:load-module …)` more than once — see
314 /// [`Self::validate_load_singularity`]), the within-entry
315 /// state-change-singularity invariant (no script appears as the
316 /// target of `(:state-change …)` more than once — see
317 /// [`Self::validate_state_change_singularity`]), and the within-
318 /// entry cleanup-singularity invariant (no module appears as the
319 /// target of `(:soft-purge …)` or `(:purge …)` more than once
320 /// total — see [`Self::validate_cleanup_singularity`]).
321 pub fn validate(&self) -> Result<(), UpgradeError> {
322 use semver::Version;
323 Version::parse(self.prior_versao()).map_err(|e| UpgradeError::FromInvalid {
324 from: self.prior_versao().to_string(),
325 reason: e.to_string(),
326 })?;
327 // Per-instruction typed shape: kind-tagged `:module` /
328 // `:script` value-shape gates fire here, *before* the
329 // within-entry restart-exclusivity gate below — so a
330 // malformed-shape diagnostic on a Module/Script-bearing
331 // instruction surfaces with its narrower self-locating
332 // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
333 // `AbsoluteScript`, `ParentEscapeScript`) rather than
334 // collapsing two unrelated authoring errors into a single
335 // exclusivity diagnostic. Same empty-first cascade discipline
336 // every peer DNS-1123 / path-shape gate inside this module
337 // uses (`validate_module`'s ModuleEmpty arm precedes the
338 // DNS-1123 predicate; `validate` on `StateChange` consults
339 // the lifted `is_sandboxed_relative_path` shape gate first).
340 // Route the per-instruction shape-check fan-out through the
341 // lifted [`Self::instructions`] slice-return accessor rather
342 // than the raw `self.instructions` field access — first of
343 // nine paired production consumers of the per-`:upgrade-from
344 // :instructions` OTP-appup migration-instruction-list surface
345 // that now key off exactly one typed dispatch on the substrate
346 // primitive.
347 for instr in self.instructions() {
348 instr.validate()?;
349 }
350 self.validate_restart_exclusive()?;
351 self.validate_state_change_ordering()?;
352 self.validate_purge_ordering()?;
353 self.validate_state_change_before_cleanup()?;
354 self.validate_load_singularity()?;
355 self.validate_state_change_singularity()?;
356 self.validate_cleanup_singularity()?;
357 Ok(())
358 }
359
360 /// Reject `:upgrade-from :instructions` lists that carry
361 /// `(:restart)` alongside any other instruction, or that carry
362 /// more than one `(:restart)`. The valid Restart-bearing shape is
363 /// exactly `((:restart))` — a single `Restart` as the entry's
364 /// whole instructions list.
365 ///
366 /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
367 /// is the *fallback* for an entry whose typed upgrade is
368 /// impossible (wasm component-model world incompatibility,
369 /// irreversible state shape change). The fallback is terminal by
370 /// construction: the operator restarts the pod and the new version
371 /// comes up fresh, so any other instructions in the same entry
372 /// are dead code in both directions — either the typed sequence
373 /// would have succeeded and `(:restart)` is unreached, or it
374 /// wouldn't and the typed instructions are dead because the
375 /// operator restarts anyway. Two canonical authoring footguns
376 /// close here:
377 ///
378 /// - `((:load-module …) (:state-change …) (:restart))` — the
379 /// "I'll try the typed path *then* restart anyway" footgun.
380 /// There is no coherent OTP-shaped semantic for this: if the
381 /// typed sequence succeeds, the trailing restart discards the
382 /// work that just succeeded (defeating the whole point of
383 /// declaring it); if it fails, the restart is never reached
384 /// because the entry already failed.
385 /// - `((:restart) (:restart))` — multiple `Restart` variants in
386 /// one entry. The fallback is a single semantic; repeating it
387 /// is at best redundant, at worst suggests the author thought
388 /// the second one would re-trigger after the first.
389 ///
390 /// Same within-entry exclusivity discipline OTP's `relup` enforces
391 /// at the `restart_new_emulator | restart_emulator` instruction
392 /// boundary — those instructions are terminal in the upgrade
393 /// script (`systools(3)` rejects sequences that continue past
394 /// them); pleme-io lifts the same shape to a build-time gate,
395 /// matching the CAIXA-SDLC §III "build errors, not runtime
396 /// surprises" frame.
397 ///
398 /// Same within-entry cross-instruction discipline the
399 /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
400 /// shard-key partition (934bc58) and
401 /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
402 /// precedence partition (de7ab1a) apply on cross-slot axes — now
403 /// extended onto the first within-list cross-instruction axis on
404 /// the `:upgrade-from` typed slot.
405 fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
406 // Route the paired restart-count / instructions-len / other-
407 // kind projections through the lifted [`Self::instructions`]
408 // slice-return accessor rather than the raw `self.instructions`
409 // field access — three raw-access sites in one gate collapse
410 // onto exactly one typed dispatch on the substrate primitive.
411 //
412 // The paired positive / negated `Self::Restart` arm-discriminator
413 // predicates route through the `gen_platform::IsVariant`
414 // derive-generated [`UpgradeInstruction::is_restart`] rather than
415 // the raw `matches!(i, UpgradeInstruction::Restart)` /
416 // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
417 // matches — same closed-set-typed-enum arm-discriminator dispatch
418 // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
419 // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
420 // / `!= CaixaKind::X` production sites in the substrate's own
421 // layout invariant verifier + typed-view projection gates,
422 // extended here onto the last unlifted `matches!`-based
423 // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
424 // typed enum. A future sixth `UpgradeInstruction` arm (an
425 // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
426 // wasm-operator's hot-upgrade runtime could adopt to bracket the
427 // typed instruction sequence against a per-cluster readiness
428 // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
429 // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
430 // materializer could resolve per-CR) migrates as a single
431 // enum-declaration edit — the derive auto-generates the paired
432 // `.is_<new_arm>()` predicate; every consumer inherits the new
433 // arm on the next re-derive, rather than the two `matches!` sites
434 // here having to be threaded through in lockstep.
435 let instructions = self.instructions();
436 let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
437 if restart_count == 0 {
438 return Ok(());
439 }
440 if restart_count == 1 && instructions.len() == 1 {
441 return Ok(());
442 }
443 let other_kinds: Vec<&'static str> = instructions
444 .iter()
445 .filter(|i| !i.is_restart())
446 .map(UpgradeInstruction::lisp_form)
447 .collect();
448 Err(UpgradeError::RestartNotExclusive {
449 from: self.prior_versao().to_string(),
450 restart_count,
451 other_kinds,
452 })
453 }
454
455 /// Reject an entry whose `(:state-change …)` is not preceded by a
456 /// `(:load-module …)` in the same `:instructions` list.
457 ///
458 /// `StateChange` is the `gen_server:code_change/3` analog
459 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
460 /// it runs the migration script that folds the *old* state into the
461 /// shape the *new* code expects. In OTP, `code_change/3` is invoked
462 /// in the context of the newly-loaded code — `release_handler`
463 /// always loads the new module before running the advanced update
464 /// that triggers the callback. caixa decomposes that into two
465 /// explicit instructions (`LoadModule` brings the new version up
466 /// "alongside the current one"; `StateChange` migrates the state),
467 /// and the module doc pins that the operator "runs the instructions
468 /// in order" and only swaps traffic after all succeed. So a
469 /// `:state-change` with no preceding `:load-module` migrates state
470 /// into code that was never loaded — the migration script runs while
471 /// the only resident version is still the *old* one, which expects
472 /// the *old* state. Two authoring footguns close here:
473 ///
474 /// - `((:state-change "…"))` — the "I wrote the migration but
475 /// forgot to load the new module" footgun. The new code that
476 /// defines the new state representation (and that the migration
477 /// output is destined for) never comes up; the operator runs
478 /// the script against the old code and either no-ops or corrupts
479 /// live state.
480 /// - `((:state-change "…") (:load-module "…"))` — the
481 /// right-instructions-wrong-order footgun. Because the operator
482 /// executes in declared order, the migration runs *before* the
483 /// new code is resident, then the load brings up code expecting
484 /// already-migrated state that the just-run script produced
485 /// against the old version's shape. The canonical order is
486 /// `(:load-module …) (:state-change …) (:soft-purge …)`
487 /// (module doc example).
488 ///
489 /// Same within-entry cross-instruction discipline as
490 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
491 /// exclusivity gate it runs beside): both reject an
492 /// `:instructions` list whose instructions are individually
493 /// well-shaped but jointly incoherent, at the typed build surface
494 /// rather than as a runtime surprise. Runs *after*
495 /// `validate_restart_exclusive` so a `((:state-change …)
496 /// (:restart))` shape still surfaces the more-fundamental
497 /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
498 /// alone, so no Restart-bearing entry reaches this gate carrying a
499 /// `StateChange`).
500 fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
501 // Route the per-instruction load-family arm-discriminator through
502 // the `gen_platform::IsVariant`-derive-generated
503 // [`UpgradeInstruction::is_load_module`] predicate and the
504 // per-instruction migration-family `:script` scalar projection
505 // through the sibling lifted [`UpgradeInstruction::declared_path`]
506 // `Option<&PathBuf>` accessor rather than the raw two-arm
507 // `match instr { UpgradeInstruction::LoadModule { .. } =>
508 // loaded = true, UpgradeInstruction::StateChange { script } if
509 // !loaded => …, _ => {} }` open-coded pattern-match — closes the
510 // last unlifted `match`-shaped per-arm-hand-rolled load-family
511 // arm-discriminator + migration-family script-projection pair
512 // inside `impl UpgradeFromEntry`. Sibling of the peer
513 // [`Self::validate_purge_ordering`] (580d0f1) routing already
514 // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
515 // load → cleanup ordering axis, the peer
516 // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
517 // onto the [`UpgradeInstruction::is_load_module`] +
518 // [`UpgradeInstruction::declared_module`] pair on the singularity
519 // axis, and the peer [`Self::validate_state_change_singularity`]
520 // routing already lifted onto the sibling
521 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
522 // accessor on the migration-family script-projection axis — both
523 // ordering-gate load-family sticky-latch dispatches now key off
524 // exactly one typed dispatch on the substrate primitive for
525 // their load-family arm-discriminator, and both migration-family
526 // projection sites (this ordering gate + the peer singularity
527 // gate) now key off exactly one typed dispatch on the substrate
528 // primitive for the `:script`-carrying axis. A future sixth arm
529 // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
530 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
531 // `CanaryTraffic` split-traffic variant the M4 CR materializer
532 // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
533 // enum-declaration edit through the derive rather than a
534 // coordinated rewrite of every ordering / singularity gate's
535 // per-arm hand-rolled pattern-match. Byte-identity of this
536 // dispatch against the pre-lift `match` shape is pinned by
537 // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
538 let mut loaded = false;
539 for instr in self.instructions() {
540 if instr.is_load_module() {
541 loaded = true;
542 } else if !loaded && let Some(script) = instr.declared_path() {
543 return Err(UpgradeError::StateChangeWithoutPriorLoad {
544 from: self.prior_versao().to_string(),
545 script: script.clone(),
546 });
547 }
548 }
549 Ok(())
550 }
551
552 /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
553 /// preceded by a `(:load-module …)` in the same `:instructions` list.
554 ///
555 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
556 /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
557 /// *old* module from memory after the new one is resident. OTP's
558 /// two-phase code load is `code:load_module/1` *then*
559 /// `code:soft_purge/1` — load the new version alongside the old
560 /// (both in memory, new requests route to new), then purge the old
561 /// after in-flight callers drain. caixa decomposes that into two
562 /// explicit instructions (`LoadModule` brings the new version up
563 /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
564 /// doc; `SoftPurge` "waits for in-flight requests on a named module
565 /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
566 /// and the module doc pins that the operator "runs the instructions
567 /// in order". So a `:soft-purge` / `:purge` with no preceding
568 /// `:load-module` purges old code while the only resident version is
569 /// still the *same* old code, leaving the upgrade entry asking the
570 /// operator to drain or discard the live module with no replacement
571 /// resident. Two authoring footguns close here:
572 ///
573 /// - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
574 /// cleanup but forgot to load the new module" footgun. The new
575 /// code never comes up alongside; the operator either drains the
576 /// old version to nothing (`SoftPurge`) or discards it outright
577 /// mid-request (`Purge`), with no replacement to route in-flight
578 /// or future requests to.
579 /// - `((:soft-purge "…") (:load-module "…"))` /
580 /// `((:purge "…") (:load-module "…"))` — the right-instructions-
581 /// wrong-order footgun. Because the operator executes in declared
582 /// order, the cleanup runs *before* the new code is resident,
583 /// leaving a window during which neither version is available;
584 /// the canonical order is `(:load-module …) (:state-change …)
585 /// (:soft-purge …)` (module doc example).
586 ///
587 /// Same within-entry cross-instruction discipline as
588 /// [`Self::validate_state_change_ordering`] (the `:state-change`-
589 /// ordering gate it runs beside): both close the same load-before-X
590 /// post-condition on the OTP appup ordering contract, now extending
591 /// the typed coverage from "new code resident before its state
592 /// migration runs" to "new code resident before the old code is
593 /// drained or discarded" — the second half of OTP's two-phase code
594 /// load. Runs *after* `validate_state_change_ordering` so an entry
595 /// like `((:state-change …) (:soft-purge …))` surfaces the more-
596 /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
597 /// are load-less, but state-change is the load-bearing semantic — the
598 /// purge is meaningless either way without a preceding load, so the
599 /// author should see the migration-side diagnostic first).
600 fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
601 let mut loaded = false;
602 for instr in self.instructions() {
603 // Route the per-instruction cleanup-family arm-discriminator
604 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
605 // predicate rather than the raw
606 // `UpgradeInstruction::SoftPurge { module } |
607 // UpgradeInstruction::Purge { module }` open-coded per-arm
608 // union pattern-match — the first of three within-entry cross-
609 // instruction cleanup-facing gates now keys off exactly one
610 // typed dispatch on the substrate primitive, so any future
611 // fifth cleanup-shaped variant (a `Discard` variant the
612 // `code:delete/1` peer inspires) added to
613 // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
614 // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
615 // through the accessor's one body. The paired cleanup-arm
616 // `:module` scalar is routed through the sibling
617 // [`UpgradeInstruction::declared_module`] accessor rather than
618 // the raw pattern-bound `module` binding — same substrate-
619 // primitive-owns-the-scalar discipline every peer
620 // per-`UpgradeInstruction` scalar-value axis already routes
621 // through, with the `is_cleanup`-implies-`declared_module`-is-
622 // `Some` composition pin at
623 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
624 // making the `.expect(…)` structurally infallible at build
625 // time. Peer of the sibling
626 // [`UpgradeFromEntry::validate_restart_exclusive`]
627 // paired positive / negated
628 // [`UpgradeInstruction::is_restart`] routing (915a934) on the
629 // per-arm terminal-fallback partition — same closed-set-typed-
630 // enum arm-discriminator dispatch discipline extended from
631 // the single-arm terminal-fallback family onto the two-arm
632 // cleanup family here.
633 //
634 // Route the paired load-family arm-discriminator through the
635 // `gen_platform::IsVariant`-derive-generated
636 // [`UpgradeInstruction::is_load_module`] predicate rather than
637 // the raw `matches!(instr, UpgradeInstruction::LoadModule
638 // { .. })` open-coded pattern-match — closes the last
639 // unlifted `matches!`-based per-variant arm-discriminator
640 // axis on the [`UpgradeInstruction`] closed-set typed enum,
641 // sibling of the [`UpgradeInstruction::is_restart`] terminal-
642 // fallback routing (915a934) and the
643 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
644 // routing (0bc469f) that already lifted the paired
645 // arm-discriminator sites in this method. Every arm-family
646 // partition the gate keys off — load-family (`LoadModule`),
647 // cleanup-family (`SoftPurge | Purge`), terminal-fallback
648 // (`Restart`) — now consults exactly one typed dispatch on
649 // the substrate primitive, so a future sixth arm added to
650 // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
651 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
652 // a `CanaryTraffic` split-traffic variant the M4 CR
653 // materializer could resolve per-CR — INSPIRATIONS §II.4)
654 // migrates as a single enum-declaration edit through the
655 // derive rather than a scattered per-consumer rewrite. The
656 // partition invariant is pinned by
657 // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
658 // and the byte-identity of this dispatch against the pre-lift
659 // `matches!` pattern by
660 // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
661 if instr.is_load_module() {
662 loaded = true;
663 } else if instr.is_cleanup() && !loaded {
664 return Err(UpgradeError::PurgeWithoutPriorLoad {
665 from: self.prior_versao().to_string(),
666 kind: instr.lisp_form(),
667 module: instr
668 .declared_module()
669 .expect("is_cleanup() implies declared_module() is Some")
670 .to_string(),
671 });
672 }
673 }
674 Ok(())
675 }
676
677 /// Reject an entry whose `(:state-change …)` appears after any
678 /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
679 /// list — completing the canonical OTP appup `code:load_module/1`
680 /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
681 /// chain on the typed `:upgrade-from` slot.
682 ///
683 /// `StateChange` is the `gen_server:code_change/3` analog
684 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
685 /// verbatim: "State migration uses `gen_server:code_change/3` …
686 /// migrate state from v0.1.0 shape to current shape"). The
687 /// callback's input is the *prior* version's state shape, which
688 /// only exists while the prior code is still resident — the running
689 /// `gen_server` processes hold the v0.1.0 state, and the operator's
690 /// dispatch invokes `code_change/3` to fold that state into the
691 /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
692 /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
693 /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
694 /// *old* module after the new one is resident. The operator runs
695 /// instructions in declared order (module doc), so a cleanup ahead
696 /// of a state-change discards the prior code before the migration
697 /// fold runs against the state it held — the canonical OTP error
698 /// mode "`code_change/3` invoked on a purged module" the
699 /// `release_handler` enforces by always emitting the migration
700 /// callback before the soft-purge step.
701 ///
702 /// `systools`-generated `.relup` files always emit `code_change`
703 /// before `soft_purge` for this reason; the appup cookbook's
704 /// canonical pattern (`[{load_module, m}, {update, m, soft},
705 /// {soft_purge, m}]`) places the migration-triggering `update`
706 /// strictly between the load and the cleanup. The caixa module
707 /// doc pins the same canonical order verbatim — `(:load-module
708 /// …) (:state-change …) (:soft-purge …)` — and this gate makes
709 /// that ordering a structural property at build time. Three
710 /// authoring footguns close here:
711 ///
712 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
713 /// "lib/m.lisp"))` — the right-instructions-wrong-order
714 /// footgun on the migrate ↔ cleanup axis. Because the operator
715 /// executes in declared order, the cleanup drains the v0.1.0
716 /// module to nothing before the migration callback runs, and
717 /// the script either no-ops (no v0.1.0 state left to fold) or
718 /// crashes (`code_change/3` invoked on an unloaded version).
719 /// The canonical order is `(:load-module …) (:state-change
720 /// …) (:soft-purge …)` (module doc example).
721 /// - `((:load-module "x") (:purge "x-old") (:state-change
722 /// "lib/m.lisp"))` — same shape on the more catastrophic
723 /// `:purge` variant. The immediate-discard semantic destroys
724 /// v0.1.0 state mid-request; the trailing migration script
725 /// has nothing to fold from and the `gen_server` processes that
726 /// held v0.1.0 state were killed by the `:purge`.
727 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
728 /// "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
729 /// sandwiched between two cleanups" footgun. The first
730 /// cleanup discards v0.1.0; the migration runs against
731 /// drained state; the second cleanup is irrelevant. The first
732 /// cleanup → state-change boundary is the load-bearing defect
733 /// surfaced.
734 ///
735 /// Same within-entry cross-instruction discipline as
736 /// [`Self::validate_state_change_ordering`] (the load → state-
737 /// change ordering gate it runs after) and
738 /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
739 /// gate it runs after): all three close one boundary of the OTP
740 /// canonical sequence `code:load_module/1` →
741 /// `gen_server:code_change/3` → `code:soft_purge/1`. The
742 /// state-change-ordering gate closes the load → migrate boundary;
743 /// the purge-ordering gate closes the load → cleanup boundary;
744 /// this gate closes the migrate → cleanup boundary, completing
745 /// the typed coverage of the canonical sequence. Runs *after*
746 /// [`Self::validate_purge_ordering`] (and therefore after
747 /// [`Self::validate_state_change_ordering`]) so an entry like
748 /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
749 /// which violates *both* the purge-without-load gate and this
750 /// state-change-after-cleanup gate — surfaces the more-
751 /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
752 /// defect is load-bearing; once a coherent `(:load-module …)`
753 /// precedes both, the migrate ↔ cleanup ordering becomes the
754 /// next live defect). Runs *before* the per-instruction-class
755 /// singularity gates ([`Self::validate_load_singularity`],
756 /// [`Self::validate_state_change_singularity`],
757 /// [`Self::validate_cleanup_singularity`]) so an entry like
758 /// `((:load-module "x") (:soft-purge "x-old") (:state-change
759 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
760 /// *both* this ordering gate and the state-change-singularity
761 /// gate — surfaces the ordering defect first; the canonical
762 /// "ordering before singularity" precedence the peer
763 /// `validate_state_change_ordering` / `validate_purge_ordering`
764 /// gates already establish.
765 ///
766 /// Detection: linear scan of the instructions list with a
767 /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
768 /// recording the first cleanup encountered; on any subsequent
769 /// `StateChange` the gate fires with the script + the prior
770 /// cleanup's kind/module. Diagnostic-order pin: the first
771 /// colliding state-change-after-cleanup pair surfaces, not the
772 /// last — mirrors every peer ordering gate's first-collision
773 /// posture ([`Self::validate_state_change_ordering`] returns on
774 /// the first `StateChange` without prior load,
775 /// [`Self::validate_purge_ordering`] on the first cleanup
776 /// without prior load).
777 fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
778 let mut prior_cleanup: Option<(&str, &'static str)> = None;
779 for instr in self.instructions() {
780 // Route the per-instruction cleanup-family arm-discriminator
781 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
782 // predicate rather than the raw
783 // `UpgradeInstruction::SoftPurge { module } |
784 // UpgradeInstruction::Purge { module }` open-coded per-arm
785 // union pattern-match — the second of three within-entry
786 // cross-instruction cleanup-facing gates the peer
787 // [`Self::validate_purge_ordering`] routing already lifted;
788 // both now key off exactly one typed dispatch on the substrate
789 // primitive so the "which arms belong to the cleanup family"
790 // question resolves at exactly one caixa-core edit. The
791 // sticky-once latch's `:module` scalar is routed through the
792 // sibling [`UpgradeInstruction::declared_module`] accessor
793 // rather than the raw pattern-bound `module.as_str()`
794 // projection, with the `is_cleanup`-implies-`declared_module`-
795 // is-`Some` composition pin at
796 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
797 // making the `.expect(…)` structurally infallible at build
798 // time.
799 if instr.is_cleanup() && prior_cleanup.is_none() {
800 prior_cleanup = Some((
801 instr
802 .declared_module()
803 .expect("is_cleanup() implies declared_module() is Some"),
804 instr.lisp_form(),
805 ));
806 } else if let Some(script) = instr.declared_path()
807 && let Some((prior_module, prior_kind)) = prior_cleanup
808 {
809 // Route the per-instruction `StateChange`-arm script-path
810 // projection through the sibling lifted
811 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
812 // accessor rather than the raw
813 // `if let UpgradeInstruction::StateChange { script } = instr`
814 // open-coded pattern-match — the last unlifted per-
815 // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
816 // inside `impl UpgradeFromEntry`, sibling to the four peer
817 // per-`UpgradeInstruction` consumers already routed through
818 // the accessor: [`UpgradeInstruction::validate`]'s per-
819 // `StateChange` sandbox-path fan-out, the layout-side per-
820 // `StateChange` script-existence fan-out at
821 // [`crate::layout::StandardLayout::verify`]
822 // (caixa-core/src/layout.rs:1058), the within-entry
823 // [`UpgradeFromEntry::validate_state_change_singularity`]
824 // per-`StateChange` script-projection fan-out, and the
825 // cross-slot
826 // [`validate_upgrade_from_against_behavior`]
827 // per-`StateChange` detection loop. Byte-equal today
828 // (`declared_path` returns `Some(script)` iff the
829 // instruction is [`UpgradeInstruction::StateChange`], per
830 // the sibling `declared_path_only_for_state_change` pin),
831 // so a state-change-after-cleanup surfaces
832 // `StateChangeAfterCleanup` byte-identical to the pattern-
833 // match shape. Any future accessor extension that promotes
834 // an additional variant onto the `PathBuf`-carrying axis
835 // reaches this gate through one caixa-core edit rather
836 // than a coordinated rewrite of five call sites — the
837 // migrate→cleanup ordering discipline extends to the
838 // promoted variant by construction. Same "one typed
839 // dispatch on the substrate primitive, thin projections at
840 // each consumer" trajectory the sibling
841 // [`UpgradeInstruction::declared_module`] `String`-axis
842 // per-variant unifier already established.
843 return Err(UpgradeError::StateChangeAfterCleanup {
844 from: self.prior_versao().to_string(),
845 script: script.clone(),
846 prior_cleanup_kind: prior_kind,
847 prior_cleanup_module: prior_module.to_string(),
848 });
849 }
850 }
851 Ok(())
852 }
853
854 /// Reject an entry whose `:instructions` list names the same module
855 /// as the target of more than one cleanup instruction (`:soft-purge`
856 /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
857 /// module) axis, narrowed to the cleanup class.
858 ///
859 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
860 /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
861 /// `code:load_module/1` — load v2 alongside v1 … 2.
862 /// `code:soft_purge/1` — wait until no process is running v1, then
863 /// discard. (`code:purge/1` kills v1 immediately if you don't
864 /// care.)"). The author picks *one* cleanup semantic per old
865 /// module — `:soft-purge` (preferred: waits for in-flight callers
866 /// to drain) or `:purge` (when the drain isn't possible) — and the
867 /// operator runs that one in declared order alongside any other
868 /// distinct-module cleanups. systools-generated `.relup` files
869 /// always emit at most one purge per module for this reason; any
870 /// retry / fallback decision is the operator's job on
871 /// instruction failure, not authored into the entry. Three
872 /// authoring footguns close here:
873 ///
874 /// - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
875 /// — the "I copy-pasted the cleanup line twice" footgun. The
876 /// second `:soft-purge` is a no-op (the module is already gone
877 /// after the first drain-and-discard) or undefined depending
878 /// on the operator's handling of a non-resident-module purge
879 /// request; either way the second instruction carries no
880 /// observable semantic, far from the source caixa.lisp.
881 /// - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
882 /// — the "soft-then-hard fallback" footgun. The author wrote
883 /// "drain, and if drain didn't clean it up, force-discard",
884 /// but the operator runs instructions unconditionally in
885 /// declared order — the `:purge` fires whether the
886 /// `:soft-purge` already discarded the module or not, so the
887 /// fallback semantic the author imagined is missing; the
888 /// pair is incoherent (drain *and* force-discard semantics
889 /// on one module is two contradictory dispositions). The
890 /// operator's failure-handling surface is its own
891 /// responsibility: if `:soft-purge` doesn't drain within its
892 /// cooldown the operator escalates, not the author's entry.
893 /// - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
894 /// — same shape on the reversed ordering. The `:purge`
895 /// discards immediately; the trailing `:soft-purge` has no
896 /// module to drain.
897 ///
898 /// Same within-entry exclusivity discipline as
899 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
900 /// exclusivity gate it joins on the per-module cleanup axis): both
901 /// reject an `:instructions` list whose instructions are
902 /// individually well-shaped but jointly incoherent on a chosen
903 /// semantic axis (restart-fallback for the whole entry there;
904 /// cleanup-semantic for one module here), at the typed build
905 /// surface rather than as a runtime surprise. Runs *after*
906 /// [`Self::validate_purge_ordering`] (the load-before-cleanup
907 /// ordering gate) so an entry like `((:soft-purge "x-old")
908 /// (:soft-purge "x-old"))` surfaces the more-fundamental
909 /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
910 /// the missing-load defect is the load-bearing one — the duplicate
911 /// is meaningless either way without the preceding load).
912 ///
913 /// Same set-not-multiset discipline applied to every peer
914 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
915 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
916 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
917 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
918 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
919 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
920 /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
921 /// Each closes the same authoring footgun: a Vec authoring surface
922 /// that silently accepts duplicate entries and renders the "second
923 /// wins" (or "operator processes both, second is a no-op or
924 /// errors") shape downstream, far from the source caixa.lisp.
925 /// This gate extends the discipline onto the within-entry
926 /// instruction-target axis — duplicate cleanup targets *within*
927 /// one `:upgrade-from` entry — the peer of the cross-entry
928 /// duplicate-`:from` axis at one level of nesting deeper.
929 ///
930 /// Detection: linear scan of the instructions list collecting
931 /// the (module, kind) pair from every `SoftPurge` / `Purge`
932 /// encountered; on the second occurrence of any module the gate
933 /// fires with the prior kind and the colliding kind in declaration
934 /// order. Diagnostic-order pin: the first colliding pair surfaces,
935 /// not the last — mirrors
936 /// [`validate_upgrade_from`]'s
937 /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
938 /// posture (the first detected collision wins) and every peer
939 /// duplicate gate's first-collision discipline.
940 fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
941 let mut seen: Vec<(&str, &'static str)> = Vec::new();
942 for instr in self.instructions() {
943 // Route the per-instruction cleanup-family arm-discriminator
944 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
945 // predicate rather than the raw two-arm
946 // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
947 // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
948 // `UpgradeInstruction::Purge { module } => (module.as_str(),
949 // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
950 // per-arm dispatch — the third of three within-entry cross-
951 // instruction cleanup-facing gates the peer
952 // [`Self::validate_purge_ordering`] +
953 // [`Self::validate_state_change_before_cleanup`] routing
954 // already lifted; all three now key off exactly one typed
955 // dispatch on the substrate primitive, structurally. The
956 // cleanup-target `(module, kind)` pair is projected through
957 // the peer [`UpgradeInstruction::declared_module`] /
958 // [`UpgradeInstruction::lisp_form`] accessors rather than
959 // the per-arm-hand-rolled scalar-value + kind-const pair,
960 // with the `is_cleanup`-implies-`declared_module`-is-`Some`
961 // composition pin at
962 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
963 // making the `.expect(…)` structurally infallible at build
964 // time. Any future fifth cleanup-shaped variant added under
965 // the `is_cleanup` predicate + registered through the peer
966 // `lisp_form` per-arm kebab-case-const dispatch reaches this
967 // dedup gate through the accessor's one body rather than a
968 // fourth per-arm-hand-rolled scalar/kind projection here.
969 if !instr.is_cleanup() {
970 continue;
971 }
972 let module = instr
973 .declared_module()
974 .expect("is_cleanup() implies declared_module() is Some");
975 let kind = instr.lisp_form();
976 if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
977 let prior_kind = seen[prior_idx].1;
978 return Err(UpgradeError::DuplicateCleanup {
979 from: self.prior_versao().to_string(),
980 module: module.to_string(),
981 kinds: vec![prior_kind, kind],
982 });
983 }
984 seen.push((module, kind));
985 }
986 Ok(())
987 }
988
989 /// Reject an entry whose `:instructions` list names the same module
990 /// as the target of more than one `(:load-module …)` instruction —
991 /// set-not-multiset on the `LoadModule` axis.
992 ///
993 /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
994 /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
995 /// new code is 'current', old code is 'old'."). The instruction
996 /// brings the new wasm component up resident alongside the old
997 /// one so the operator can route new traffic to the new code
998 /// while in-flight callers drain on the old — and the operator's
999 /// dispatch table reads the module *name* (a caixa name) to bind
1000 /// the component, so two `(:load-module "x")` instructions in one
1001 /// entry ask the operator to re-bind the same component twice.
1002 /// `systools`-generated `.relup` files emit at most one
1003 /// `load_module` per module per upgrade step for this reason; the
1004 /// second load has no observable semantic relative to the first
1005 /// (the component is already resident). Three authoring footguns
1006 /// close here:
1007 ///
1008 /// - `((:load-module "x") (:load-module "x"))` — the "I
1009 /// copy-pasted the load line twice" footgun. The second
1010 /// `:load-module` re-reads the same module name and re-binds
1011 /// the same wasm component — a no-op in both directions
1012 /// (no new code becomes resident; no old code is purged) —
1013 /// and any cleanup / migration the author intended for a
1014 /// *distinct* module is silently absent from the entry.
1015 /// - `((:load-module "x") (:load-module "x") (:state-change …))`
1016 /// — the "I meant to load two distinct modules" typo. The
1017 /// author intended `((:load-module "x") (:load-module "y"))`
1018 /// but renamed both to "x" (or copied the first line and
1019 /// forgot to change the module). The migration runs against
1020 /// code that's resident only on one module name, and the
1021 /// second module the author imagined was being loaded never
1022 /// comes up at all — far from the source caixa.lisp.
1023 /// - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1024 /// — same shape with a trailing cleanup. The duplicate load
1025 /// is dead code; the cleanup still fires correctly, masking
1026 /// the load-side duplication as a silently-passing entry.
1027 ///
1028 /// Same within-entry exclusivity discipline as
1029 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1030 /// singularity gate this runs beside) on the sibling
1031 /// `LoadModule` axis: both reject an `:instructions` list whose
1032 /// instructions are individually well-shaped but jointly
1033 /// incoherent on a per-module-per-class basis (load-once for the
1034 /// load axis here; cleanup-once for the cleanup axis there), at
1035 /// the typed build surface rather than as a runtime surprise.
1036 /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1037 /// before-cleanup ordering gate) so an entry like
1038 /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1039 /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1040 /// first (the missing-load defect is load-bearing — the migration
1041 /// runs against unloaded code; the duplicate is meaningless either
1042 /// way without the preceding load). Runs *before*
1043 /// [`Self::validate_cleanup_singularity`] so an entry like
1044 /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1045 /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1046 /// the load axis precedes the cleanup axis in the canonical OTP
1047 /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1048 /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1049 /// before `SoftPurge`/`Purge`), so the load-side singularity is
1050 /// the load-bearing diagnostic when both fire.
1051 ///
1052 /// Same set-not-multiset discipline applied to every peer
1053 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1054 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1055 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1056 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1057 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1058 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1059 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1060 /// the per-module cleanup-target axis (9cedd8b —
1061 /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1062 /// discipline onto the within-entry `LoadModule` instruction-target
1063 /// axis — the third within-entry per-module singularity completing
1064 /// the load+cleanup pair across the OTP two-phase code-load
1065 /// contract.
1066 ///
1067 /// Detection: linear scan of the instructions list collecting the
1068 /// module name from every `LoadModule` encountered; on the second
1069 /// occurrence of any module the gate fires. Diagnostic-order pin:
1070 /// the first colliding occurrence surfaces, not the last — mirrors
1071 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1072 /// and every peer duplicate gate's first-collision discipline.
1073 fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1074 let mut seen: Vec<&str> = Vec::new();
1075 for instr in self.instructions() {
1076 // Route the per-instruction load-family arm-discriminator
1077 // through the `gen_platform::IsVariant`-derive-generated
1078 // [`UpgradeInstruction::is_load_module`] predicate rather
1079 // than the raw single-arm `match instr {
1080 // UpgradeInstruction::LoadModule { module } =>
1081 // module.as_str(), _ => continue }` open-coded pattern-
1082 // match — closes the last unlifted `matches!`-shaped
1083 // per-arm-hand-rolled scalar-value + arm-discriminator
1084 // pair inside `impl UpgradeFromEntry`, sibling of the
1085 // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1086 // routing already lifted onto the two-arm cleanup-family
1087 // axis's per-arm arm-discriminator + `:module` projection
1088 // dispatch. The load-target `:module` scalar is projected
1089 // through the sibling [`UpgradeInstruction::declared_module`]
1090 // accessor rather than the per-arm-hand-rolled scalar-
1091 // value binding, with the
1092 // `is_load_module`-implies-`declared_module`-is-`Some`
1093 // composition pin at
1094 // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1095 // making the `.expect(…)` structurally infallible at
1096 // build time. Every arm-family partition the three
1097 // within-entry per-instruction-class singularity gates
1098 // key off — load-family
1099 // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1100 // ([`UpgradeInstruction::SoftPurge`] |
1101 // [`UpgradeInstruction::Purge`]), migration-family
1102 // ([`UpgradeInstruction::StateChange`]) — now consults
1103 // exactly one typed dispatch on the substrate primitive
1104 // (`is_load_module()` here, `is_cleanup()` at
1105 // [`Self::validate_cleanup_singularity`],
1106 // `declared_path()` at
1107 // [`Self::validate_state_change_singularity`]), so a
1108 // future sixth arm added to [`UpgradeInstruction`] (an
1109 // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1110 // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1111 // split-traffic variant the M4 CR materializer could
1112 // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1113 // single enum-declaration edit through the derive rather
1114 // than a scattered per-consumer rewrite. Byte-identity of
1115 // this dispatch against the pre-lift match-pattern is
1116 // pinned by
1117 // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1118 if !instr.is_load_module() {
1119 continue;
1120 }
1121 let module = instr
1122 .declared_module()
1123 .expect("is_load_module() implies declared_module() is Some");
1124 if seen.contains(&module) {
1125 return Err(UpgradeError::DuplicateLoadModule {
1126 from: self.prior_versao().to_string(),
1127 module: module.to_string(),
1128 });
1129 }
1130 seen.push(module);
1131 }
1132 Ok(())
1133 }
1134
1135 /// Reject an entry whose `:instructions` list names the same script
1136 /// as the target of more than one `(:state-change …)` instruction —
1137 /// set-not-multiset on the `StateChange` axis.
1138 ///
1139 /// `StateChange` is the `gen_server:code_change/3` analog
1140 /// (INSPIRATIONS §II.4: "State migration uses
1141 /// `gen_server:code_change/3`"). The instruction folds the *old*
1142 /// state into the shape the *new* code expects — a one-shot
1143 /// transition from one declared state representation to another.
1144 /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1145 /// exactly once per upgrade per `gen_server`; `systools`-generated
1146 /// `.relup` files emit at most one `code_change` per `gen_server` per
1147 /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1148 /// instruction targeting the same script in one entry re-runs the
1149 /// migration fold — at best a no-op (idempotent script masking a
1150 /// typo where the author intended two distinct scripts) and at
1151 /// worst silent state corruption (non-idempotent fold double-
1152 /// applied: an `add column` migration that runs twice, an
1153 /// `increment counter` that double-bumps, a `rename field` that
1154 /// renames-then-fails the second time). Three authoring footguns
1155 /// close here:
1156 ///
1157 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1158 /// (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1159 /// migration line twice" footgun. The second `:state-change`
1160 /// re-runs the same fold on the already-migrated state — a
1161 /// no-op if the script is idempotent (dead code masking the
1162 /// duplication) or state corruption if not (the migration's
1163 /// pre-condition no longer holds because the post-condition is
1164 /// already in place).
1165 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1166 /// (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1167 /// "duplicate migrate masked by trailing cleanup" footgun. The
1168 /// cleanup still fires correctly, masking the migration-side
1169 /// duplication as a silently-passing entry.
1170 /// - `((:load-module "x") (:state-change "lib/m1.lisp")
1171 /// (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1172 /// two distinct modules" typo. The author intended
1173 /// `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1174 /// but renamed both to `m1.lisp` (or copy-pasted the first line
1175 /// and forgot to change the script). The migration that should
1176 /// have folded the second module's state never runs, far from
1177 /// the source caixa.lisp.
1178 ///
1179 /// Same within-entry exclusivity discipline as
1180 /// [`Self::validate_load_singularity`] (the per-module load-
1181 /// singularity gate it runs after) and
1182 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1183 /// singularity gate it runs before) on the sibling `StateChange`
1184 /// axis: each rejects an `:instructions` list whose instructions
1185 /// are individually well-shaped but jointly incoherent on a per-
1186 /// instruction-class basis (load-once per module for the load
1187 /// axis; migrate-once per script for the migration axis here;
1188 /// cleanup-once per module for the cleanup axis), at the typed
1189 /// build surface rather than as a runtime surprise. Runs *after*
1190 /// [`Self::validate_load_singularity`] so an entry like
1191 /// `((:load-module "x") (:load-module "x") (:state-change
1192 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1193 /// `DuplicateLoadModule` first — the load axis precedes the
1194 /// migration axis in the canonical OTP sequence
1195 /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1196 /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1197 /// `StateChange`), so the load-side singularity is the load-
1198 /// bearing diagnostic when both fire. Runs *before*
1199 /// [`Self::validate_cleanup_singularity`] so an entry like
1200 /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1201 /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1202 /// surfaces `DuplicateStateChange` first — the migration axis
1203 /// precedes the cleanup axis in the canonical OTP sequence
1204 /// (`code:code_change/3` then `code:soft_purge/1`) and in
1205 /// [`UpgradeInstruction`] declaration order (`StateChange` before
1206 /// `SoftPurge`/`Purge`).
1207 ///
1208 /// Same set-not-multiset discipline applied to every peer
1209 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1210 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1211 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1212 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1213 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1214 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1215 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1216 /// per-module cleanup-target axis (9cedd8b —
1217 /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1218 /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1219 /// This gate extends the discipline onto the within-entry
1220 /// `StateChange` instruction-target axis — the third within-entry
1221 /// per-instruction-class singularity, completing the OTP two-phase
1222 /// code-load + state-migration coverage triad
1223 /// (`code:load_module/1` → `gen_server:code_change/3` →
1224 /// `code:soft_purge/1`).
1225 ///
1226 /// Detection: linear scan of the instructions list collecting the
1227 /// script path from every `StateChange` encountered; on the second
1228 /// occurrence of any script the gate fires. Diagnostic-order pin:
1229 /// the first colliding occurrence surfaces, not the last — mirrors
1230 /// [`Self::validate_load_singularity`]'s and
1231 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1232 /// and every peer duplicate gate's first-collision discipline.
1233 fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1234 // Route the per-instruction `StateChange`-arm script-path
1235 // projection through the sibling lifted
1236 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1237 // accessor rather than the raw
1238 // `match instr { UpgradeInstruction::StateChange { script } =>
1239 // script.as_path(), _ => continue }` open-coded pattern-match —
1240 // the third within-entry singularity gate's per-instruction
1241 // script-projection site now keys off exactly one typed
1242 // dispatch on the substrate primitive's `PathBuf`-carrying
1243 // axis, sibling to the four peer per-`UpgradeInstruction`
1244 // consumers ([`Self::validate`]'s per-`StateChange`
1245 // sandbox-path fan-out, the layout-side per-`StateChange`
1246 // script-existence fan-out at
1247 // `caixa-core/src/layout.rs:1017`, the cross-slot
1248 // [`validate_upgrade_from_against_behavior`] gate's
1249 // per-`StateChange` detection loop, the future wasm-operator's
1250 // per-`StateChange` runtime hook-dispatch) that already route
1251 // through `declared_path` / `declared_module`. Byte-equal
1252 // today (`declared_path` returns `Some(script)` iff the
1253 // instruction is [`UpgradeInstruction::StateChange`], per the
1254 // sibling `declared_path_only_for_state_change` pin), so a
1255 // duplicate `:state-change` script surfaces
1256 // `DuplicateStateChange` byte-identical to the pattern-match
1257 // shape. Same "one typed dispatch on the substrate primitive,
1258 // thin projections at each consumer" discipline the sibling
1259 // [`UpgradeInstruction::declared_module`] accessor established
1260 // (b13c4f9) on the peer `String`-carrying axis's per-variant
1261 // consumers, extended here onto the last unlifted
1262 // pattern-match on the `PathBuf`-carrying axis inside
1263 // `impl UpgradeFromEntry`.
1264 let mut seen: Vec<&std::path::Path> = Vec::new();
1265 for instr in self.instructions() {
1266 let Some(script) = instr.declared_path() else {
1267 continue;
1268 };
1269 let script = script.as_path();
1270 if seen.contains(&script) {
1271 return Err(UpgradeError::DuplicateStateChange {
1272 from: self.prior_versao().to_string(),
1273 script: script.to_path_buf(),
1274 });
1275 }
1276 seen.push(script);
1277 }
1278 Ok(())
1279 }
1280}
1281
1282/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1283/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1284/// invariant — at most one `(:from <prior>)` block per parsed semver.
1285///
1286/// OTP's appup picks at most one matching block to apply to the running
1287/// release (`release_handler:install_release/1` matches the loaded
1288/// `:from` against the currently-running version and executes the
1289/// associated instruction sequence; the wasm-operator picks the matching
1290/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1291/// the same parsed-semver `:from` are an ambiguous edge in the typed
1292/// upgrade graph — the operator can pick either set deterministically,
1293/// but each set may carry different `LoadModule | StateChange |
1294/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1295/// non-deterministic relative to the source caixa.lisp. The author's
1296/// intent is one path per prior version; the typed graph must enforce
1297/// that shape.
1298///
1299/// Same set-not-multiset discipline already applied to every peer
1300/// typed-graph axis: `:children :caixa` (dbf50a9 —
1301/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1302/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1303/// `AplicacaoError::MembroDuplicate`), `:contratos`
1304/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1305/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1306/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1307/// Each closes the same authoring footgun: a Vec authoring surface that
1308/// silently accepts duplicate entries and renders the "second wins"
1309/// (or "operator picks arbitrarily") shape downstream, far from the
1310/// source caixa.lisp.
1311///
1312/// Duplicates are detected by [`semver::Version`] equality (the
1313/// crate's `PartialEq` compares the full identity — major.minor.patch +
1314/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1315/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1316/// The conservative choice mirrors what the wasm-operator's
1317/// `:from`-match dispatch can see; collapsing build metadata to catch
1318/// a wider net of duplicates is a future tightening that requires
1319/// coordinating with the operator's match step.
1320///
1321/// Per-entry shape errors fire before the duplicate gate so the
1322/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1323/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1324/// errors into a single duplicate diagnostic. Mirrors the
1325/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1326/// axis ([`crate::SupervisorSpec::validate`],
1327/// [`crate::AplicacaoSpec::validate_membros`],
1328/// [`crate::AplicacaoSpec::validate_placement`]).
1329pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1330 use semver::Version;
1331 let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1332 for entry in entries {
1333 entry.validate()?;
1334 // `entry.validate()` accepted this `:from`, so parse cannot
1335 // fail here — the FromInvalid arm above is the only gate
1336 // and both call `Version::parse(entry.prior_versao())`.
1337 let parsed = Version::parse(entry.prior_versao()).expect(
1338 "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1339 two gates aligned",
1340 );
1341 if seen.contains(&parsed) {
1342 return Err(UpgradeError::DuplicateFrom {
1343 from: entry.prior_versao().to_string(),
1344 });
1345 }
1346 seen.push(parsed);
1347 }
1348 Ok(())
1349}
1350
1351/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1352/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1353/// same ordering [`semver::Version::cmp`] implements, with build
1354/// metadata ignored per [SemVer §11][semver-11]).
1355///
1356/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1357/// declarative answer to "given the wasm-operator is loading a node
1358/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1359/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1360/// semantic). The operator's `:from`-match dispatch loads the
1361/// current `:versao` and matches the *running* version against each
1362/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1363/// unreachable — the operator never runs a version greater than or
1364/// equal to the current `:versao` that it could then "upgrade *to*"
1365/// the current `:versao`. Two authoring footguns close here:
1366///
1367/// - `:from > :versao` (downgrade-shaped) — the canonical
1368/// "I copy-pasted from the next minor version and forgot to bump
1369/// `:versao`" / "I bumped `:versao` then reverted but left the
1370/// `:upgrade-from` entry behind" footgun. Until this gate landed
1371/// `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1372/// silently passed `feira build` and the wasm-operator's
1373/// `:from`-match dispatch would never fire on the entry — the
1374/// instructions sat dormant in the caixa.lisp forever, the
1375/// author's intent ("upgrade users coming from 0.2.0") permanently
1376/// unreached because they actually meant to bump `:versao`.
1377///
1378/// - `:from == :versao` (precedence-equal self-upgrade) — the
1379/// "I declared an upgrade from myself to myself" no-op the
1380/// operator's dispatch would either skip silently (no semantic
1381/// transition) or attempt and trivially "succeed" with no
1382/// observable state change. Includes the build-metadata-only
1383/// difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1384/// SemVer-2 precedence ignores build metadata so they compare
1385/// equal under [`semver::Version::cmp`] — the gate rejects this
1386/// even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1387/// gate uses derived `PartialEq` which keeps them distinct;
1388/// they're distinct dispatch keys but the same "from" version
1389/// for our purposes here).
1390///
1391/// Same cross-slot value-shape discipline as
1392/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1393/// partition (934bc58 — the typed partition between two declared
1394/// slots): one slot's value constrains the valid set of another's,
1395/// and the constraint is a structural property visible at validate
1396/// time. The validated set after this gate satisfies
1397/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1398/// for every entry, so the future operator-side hot-upgrade dispatch
1399/// step can reach for `entry.from` knowing the precedence relation
1400/// holds without re-deriving it from inline checks.
1401///
1402/// Silent-pass semantics on malformed inputs:
1403///
1404/// - When `versao` itself doesn't parse as semver, this gate
1405/// returns `Ok(())` silently — the narrower
1406/// [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1407/// diagnostics are the load-bearing surfaces for those failure
1408/// modes, and surfacing a `FromNotBeforeVersao` over an
1409/// unparseable `:versao` would mask the more actionable root
1410/// cause.
1411/// - Likewise, an entry whose `:from` itself doesn't parse falls
1412/// through to its narrower diagnostic surface
1413/// ([`UpgradeError::FromInvalid`]), which is expected to fire
1414/// via [`validate_upgrade_from`] *before* this gate runs at the
1415/// [`crate::LayoutInvariants`] call site.
1416///
1417/// [semver-11]: https://semver.org/#spec-item-11
1418pub fn validate_upgrade_from_against_versao(
1419 entries: &[UpgradeFromEntry],
1420 versao: &str,
1421) -> Result<(), UpgradeError> {
1422 use semver::Version;
1423 let Ok(current) = Version::parse(versao) else {
1424 // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1425 // surfacing a precedence-relation diagnostic over an unparseable
1426 // top-level version would mask the more actionable root cause.
1427 return Ok(());
1428 };
1429 for entry in entries {
1430 // Per-entry shape — including a malformed `:from` — is gated
1431 // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1432 // upstream at the LayoutInvariants call site; an unparseable
1433 // `:from` here falls through silently to keep the
1434 // FromInvalid diagnostic load-bearing. Same fall-through
1435 // posture as the `versao` arm above.
1436 let Ok(prior) = Version::parse(entry.prior_versao()) else {
1437 continue;
1438 };
1439 if prior >= current {
1440 return Err(UpgradeError::FromNotBeforeVersao {
1441 from: entry.prior_versao().to_string(),
1442 versao: versao.to_string(),
1443 });
1444 }
1445 }
1446 Ok(())
1447}
1448
1449/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1450/// `(:state-change <script>)` instruction unless the caixa also declares
1451/// `:behavior :on-state-change` — the runtime callback the per-version
1452/// migration script is delivered through during hot upgrade.
1453///
1454/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1455/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1456/// callback to deliver state migration during hot upgrades." The peer
1457/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1458/// promise from the callback side: the slot is the
1459/// `gen_server:code_change/3` analog — "receives old state + version,
1460/// returns new state. Composes with the `:upgrade-from` slot declared at
1461/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1462/// the composition by invoking the running `gen_server`'s
1463/// `code_change/3` callback during the appup's `code_change` /
1464/// `update, m, soft` step — the appup's instruction triggers the
1465/// callback, the callback folds the prior-version state shape into the
1466/// current-version shape, and the operator advances to the next
1467/// instruction only after the callback returns successfully. caixa
1468/// decomposes the same composition into two typed slots: the per-version
1469/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1470/// instruction's `:script` (the `:upgrade-from` author surface), and the
1471/// runtime hook the operator dispatches the migration through lives in
1472/// the `:behavior :on-state-change` callback (the `:behavior` author
1473/// surface). A `:state-change` instruction declared without the callback
1474/// is half the composition: the per-version script the author wrote has
1475/// no runtime delivery path, and the operator's hot-upgrade dispatch
1476/// reaches for `caixa.behavior.on_state_change` at the migration step,
1477/// finds `None`, and either fails the upgrade mid-flight (the
1478/// transactional rollback the module doc names — "On any failure, the
1479/// current version stays load-bearing — a typed atomic upgrade") or
1480/// silently skips the migration depending on the operator's handling of
1481/// a missing callback, both far from the source caixa.lisp.
1482///
1483/// Two authoring footguns close here:
1484///
1485/// - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1486/// :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1487/// (:soft-purge "x-old")))))` — the "I declared the migration script
1488/// but forgot the callback" footgun. The author wrote the per-version
1489/// fold against the prior state shape, the typed `:upgrade-from`
1490/// slot validated every per-instruction shape + ordering + singularity
1491/// gate, and the missing callback only surfaces at upgrade time as
1492/// either a transactional rollback to the prior version (no progress
1493/// across the upgrade) or as a silently-skipped migration that leaves
1494/// v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1495/// state shape).
1496/// - `:behavior` absent entirely + `:upgrade-from` carrying any
1497/// `:state-change` — the "I added the upgrade path but never declared
1498/// `:behavior`" footgun. `:behavior` is optional at the typed root
1499/// ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1500/// `:upgrade-from` slot validates on its own merits, but a `Caixa`
1501/// with `behavior: None` and a `:state-change` instruction is the
1502/// same missing-callback shape — the operator's dispatch can't reach
1503/// a callback that doesn't exist.
1504///
1505/// Same cross-slot composition discipline as
1506/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1507/// precedence gate at the peer wire-up site): one slot's value
1508/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1509/// the valid set of another's (the entry must be dispatchable there; the
1510/// callback must be declared here), and the constraint is a structural
1511/// property visible at validate time. The validated set after this gate
1512/// satisfies the documented composition: every `:state-change`
1513/// instruction the operator iterates at hot-upgrade time has a
1514/// corresponding `:on-state-change` callback declared on the same caixa,
1515/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1516/// `release_handler` canonical-sequence loop) can reach for
1517/// `behavior.on_state_change` at the migration step knowing the
1518/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1519/// from inline checks.
1520///
1521/// Diagnostic-precedence:
1522///
1523/// - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1524/// shape + the within-entry ordering / singularity gates) and
1525/// [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1526/// gate), so a malformed `:state-change` (`EmptyScript`,
1527/// `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1528/// (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1529/// duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1530/// self-locating diagnostic first — the canonical "per-instr-shape +
1531/// within-entry ordering + cross-entry uniqueness before
1532/// cross-slot composition" precedence the peer
1533/// `validate_upgrade_from_against_versao` gate establishes at the
1534/// same wire-up site. Without this precedence pin a malformed
1535/// `:state-change` instruction would surface this gate's
1536/// missing-callback diagnostic over the narrower
1537/// `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1538/// load-bearing per-instruction defect with a cross-slot composition
1539/// diagnostic.
1540/// - Within the entries, walks the list in declaration order and
1541/// surfaces the *first* `:state-change` instruction encountered —
1542/// mirrors every peer first-collision diagnostic posture on this
1543/// module (`validate_state_change_ordering` returns on the first
1544/// `StateChange` without prior load,
1545/// `validate_load_singularity` returns on the second matching
1546/// module, etc.). A future entry's later `:state-change` doesn't
1547/// surface a different diagnostic — the missing callback is the same
1548/// defect regardless of which entry's `:state-change` exposes it.
1549///
1550/// Silent-pass semantics:
1551///
1552/// - Entries carrying no `:state-change` instruction (load-only,
1553/// cleanup-only, restart-only, or empty `:instructions`) leave the
1554/// gate vacuous — no per-version migration means no callback to
1555/// dispatch through, so the absence of `:on-state-change` is
1556/// coherent. Pins the gate's identity element on the empty-set side
1557/// of the composition.
1558/// - `behavior: None` is *not* a free pass when a `:state-change`
1559/// instruction is present — the same missing-callback shape as
1560/// `behavior: Some(_)` with `on_state_change: None`. The gate reads
1561/// `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1562/// surface the same diagnostic.
1563pub fn validate_upgrade_from_against_behavior(
1564 entries: &[UpgradeFromEntry],
1565 behavior: Option<&crate::BehaviorSpec>,
1566) -> Result<(), UpgradeError> {
1567 if behavior
1568 .and_then(crate::BehaviorSpec::on_state_change)
1569 .is_some()
1570 {
1571 return Ok(());
1572 }
1573 for entry in entries {
1574 // Route the per-instruction `StateChange`-arm script-path
1575 // projection through the sibling lifted
1576 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1577 // accessor rather than the raw
1578 // `if let UpgradeInstruction::StateChange { script } = instr`
1579 // open-coded pattern-match — the cross-slot
1580 // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1581 // script-projection site now keys off exactly one typed dispatch
1582 // on the substrate primitive's `PathBuf`-carrying axis, sibling
1583 // to the four peer per-`UpgradeInstruction` consumers
1584 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1585 // sandbox-path fan-out, the layout-side per-`StateChange`
1586 // script-existence fan-out at
1587 // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1588 // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1589 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1590 // peer [`UpgradeInstruction::declared_module`] `String`-axis
1591 // per-variant unifier) that already route through
1592 // `declared_path` / `declared_module`. Byte-equal today
1593 // (`declared_path` returns `Some(script)` iff the instruction is
1594 // [`UpgradeInstruction::StateChange`], per the sibling
1595 // `declared_path_only_for_state_change` pin), so a
1596 // `:state-change`-without-`:on-state-change`-callback
1597 // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1598 // byte-identical to the pattern-match shape. Fourth (and last)
1599 // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1600 // axis now routed through the accessor — closes the last
1601 // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1602 // site outside `impl UpgradeFromEntry`, so the peer four
1603 // consumer set named in the sibling
1604 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1605 // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1606 // closed.
1607 for instr in entry.instructions() {
1608 if let Some(script) = instr.declared_path() {
1609 return Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
1610 from: entry.prior_versao().to_string(),
1611 script: script.clone(),
1612 });
1613 }
1614 }
1615 }
1616 Ok(())
1617}
1618
1619impl UpgradeInstruction {
1620 /// Kebab-case lisp form name for this instruction, used as the
1621 /// `:kind` tag in [`UpgradeError::ModuleEmpty`] /
1622 /// [`UpgradeError::ModuleInvalid`] diagnostics so the author can
1623 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)`
1624 /// / `(:purge …)` and fix it in one edit. Mirrors the kebab-case
1625 /// slot tags `BehaviorError::EmptyPath` (b0c8389) and
1626 /// `UpgradeFromEntry`'s `:from` field already carry.
1627 #[must_use]
1628 const fn lisp_form(&self) -> &'static str {
1629 match self {
1630 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1631 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1632 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1633 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1634 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1635 }
1636 }
1637
1638 /// Validate the instruction's typed shape. Path existence is
1639 /// checked separately by [`crate::layout::StandardLayout`].
1640 ///
1641 /// The per-variant scalar the value-shape gates fire against is
1642 /// read through this method's two sibling accessors — the
1643 /// `String`-carrying axis via [`Self::declared_module`] (the
1644 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1645 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1646 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1647 /// variant's tatara-lisp `:script`) — rather than the per-arm
1648 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1649 /// Self::Purge { module }` pattern the module-axis previously
1650 /// open-coded and the per-arm `Self::StateChange { script }` the
1651 /// script-axis previously open-coded. Every scalar this enum
1652 /// carries now flows through one of the two `Option<&…>`
1653 /// accessors, so a future extension of either axis (a fifth
1654 /// module-bearing variant, an operator-side pre-parsed scalar
1655 /// cache the accessors materialize behind the same return
1656 /// contract, an M4 typed sub-slot the accessors could route
1657 /// alongside the existing scalar) migrates as a single edit on
1658 /// the accessor rather than a coordinated rewrite of every
1659 /// downstream value-shape gate. `Restart` (the only variant that
1660 /// carries neither scalar) falls through both `Option` checks and
1661 /// returns `Ok(())` — the terminal-fallback shape the
1662 /// [`Self::Restart`] variant doc pins.
1663 pub fn validate(&self) -> Result<(), UpgradeError> {
1664 if let Some(module) = self.declared_module() {
1665 return validate_module(self.lisp_form(), module);
1666 }
1667 if let Some(script) = self.declared_path() {
1668 // Delegate the four-arm cascade (empty / absolute /
1669 // parent-escape / non-`.lisp`-extension) to the lifted
1670 // [`crate::render::require_sandboxed_lisp_path`] helper —
1671 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1672 // arm-ordering this method previously inlined verbatim,
1673 // now shared with [`crate::BehaviorSpec::validate`]'s
1674 // per-`:on-*`-callback gate so every author-supplied
1675 // tatara-lisp source path on every M2 typed slot consults
1676 // one gate, not two-and-counting verbatim copies of the
1677 // same four-arm cascade. Each closure wraps the tag in
1678 // the same `*Script` variant the original inline code
1679 // raised, so the diagnostic shape every caller depends
1680 // on (the `:state-change :script` self-locating error)
1681 // is preserved by construction. See
1682 // [`crate::render::require_sandboxed_lisp_path`] for the
1683 // smallest-scope-arm-fires-last ordering rationale.
1684 crate::render::require_sandboxed_lisp_path(
1685 script,
1686 || UpgradeError::EmptyScript,
1687 || UpgradeError::AbsoluteScript {
1688 script: script.clone(),
1689 },
1690 || UpgradeError::ParentEscapeScript {
1691 script: script.clone(),
1692 },
1693 || UpgradeError::NonLispExtensionScript {
1694 script: script.clone(),
1695 },
1696 )?;
1697 }
1698 // `Restart` (the only variant with no `Option<&…>`-carrying
1699 // scalar) falls through both accessor gates and returns
1700 // `Ok(())` — the terminal-fallback shape.
1701 Ok(())
1702 }
1703
1704 /// The `:module` scalar carried by this instruction — the
1705 /// K8s DNS-1123-label OTP-appup caixa-name reference every
1706 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1707 /// variant declares against, and every author expects `feira lint`
1708 /// to name verbatim in per-instruction diagnostics. Returns `None`
1709 /// on [`Self::StateChange`] (which carries a `:script` — closed by
1710 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1711 /// (which carries no data at all, the OTP terminal-fallback
1712 /// shape).
1713 ///
1714 /// Sibling in shape to [`Self::declared_path`] on the second and
1715 /// final scalar-carrying axis of [`UpgradeInstruction`]:
1716 /// `declared_path` closes the `PathBuf`-carrying arm
1717 /// (`StateChange`); `declared_module` closes the `String`-carrying
1718 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1719 /// enum carries now routes through one of the two `Option<&…>`
1720 /// accessors — a caller that doesn't care which variant declared
1721 /// the scalar reads through one `if let Some(…)` rather than a
1722 /// per-variant pattern match. The pair is the enum-variant-
1723 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1724 /// on the M3 side ([`crate::WitContract::source`] /
1725 /// [`crate::WitContract::destination`] /
1726 /// [`crate::WitContract::world_ref`] closing `:contratos`;
1727 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1728 /// closing `:entrada`; [`crate::Membro::nome`] /
1729 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1730 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1731 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1732 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1733 /// closed OTP-shape supervisor family) — those peer accessors
1734 /// return a struct field verbatim; this pair unifies enum-
1735 /// variant-carried scalars into one accessor per typed axis.
1736 ///
1737 /// Byte-for-byte from the typed variant's own `String` storage;
1738 /// no cloning, no re-parsing. A future extension of the axis (an
1739 /// M4 typed sub-slot the module string is derived from, an
1740 /// operator-side pre-parsed caixa-name cache the accessor could
1741 /// materialize behind the same `&str` return contract, a fifth
1742 /// module-bearing OTP-appup variant the enum grows) migrates as
1743 /// a single caixa-core edit rather than a coordinated rewrite
1744 /// of every downstream module-axis consumer (currently
1745 /// [`Self::validate`]'s DNS-1123-label gate through
1746 /// [`validate_module`]; extensible to future consumers on the
1747 /// same axis without further per-variant match sites).
1748 #[must_use]
1749 pub fn declared_module(&self) -> Option<&str> {
1750 match self {
1751 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1752 Some(module.as_str())
1753 }
1754 Self::StateChange { .. } | Self::Restart => None,
1755 }
1756 }
1757
1758 /// If the instruction references an on-disk path, return it —
1759 /// used by the layout checker to verify the path resolves.
1760 ///
1761 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1762 /// on the `String`-carrying axis: `declared_path` closes the
1763 /// `StateChange` arm's `:script`; `declared_module` closes the
1764 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1765 /// they route every scalar this enum carries through one of two
1766 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1767 /// gates dispatch on the accessor return rather than a per-variant
1768 /// pattern match on the enum shape itself.
1769 ///
1770 /// Four per-`UpgradeInstruction` consumers now key off this
1771 /// accessor's `PathBuf`-carrying axis:
1772 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1773 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1774 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1775 /// within-entry
1776 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1777 /// per-`StateChange` script-projection fan-out, and the cross-slot
1778 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1779 /// :behavior` composition gate's per-`StateChange` detection loop
1780 /// — every downstream consumer of the `PathBuf`-carrying axis
1781 /// reaches through this one dispatch, so a future accessor
1782 /// extension (an M4 typed sub-slot the script path is derived from,
1783 /// an operator-side pre-resolved-path cache the accessor
1784 /// materializes behind the same `Option<&PathBuf>` return contract,
1785 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1786 /// migrates as a single caixa-core edit rather than a coordinated
1787 /// rewrite of four call sites.
1788 #[must_use]
1789 pub fn declared_path(&self) -> Option<&PathBuf> {
1790 match self {
1791 Self::StateChange { script } => Some(script),
1792 _ => None,
1793 }
1794 }
1795
1796 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1797 /// family arm-discriminator predicate every within-entry cross-
1798 /// instruction cleanup-facing gate keys off — true iff `self` is
1799 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1800 /// named module until no process is running it, then GC) or
1801 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1802 /// module immediately, without waiting for drain), the two OTP
1803 /// two-phase-code-load cleanup arms the closed-set enum's
1804 /// non-terminal / non-migration / non-load variants exhaust.
1805 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1806 /// two-phase-load half, [`Self::StateChange`] on the
1807 /// `gen_server:code_change/3`-analog migration axis,
1808 /// [`Self::Restart`] on the OTP terminal-fallback shape)
1809 /// returns `false`.
1810 ///
1811 /// Prior to this lift the `Self::SoftPurge { module } |
1812 /// Self::Purge { module }` two-arm cleanup-family pattern-
1813 /// match sat inline at three within-entry cross-instruction
1814 /// gate sites, each hand-rolling its own copy of the union
1815 /// with no compile-time link back to the substrate primitive's
1816 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1817 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1818 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1819 /// arriving before a preceding [`Self::LoadModule`]),
1820 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1821 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1822 /// recording the first-encountered cleanup so a subsequent
1823 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1824 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1825 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1826 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1827 /// second cleanup targeting the same `:module`). Three open-
1828 /// coded per-arm-union pattern-matches that expressed no
1829 /// compile-time link back to the substrate primitive. A future
1830 /// fifth cleanup-shaped variant (a `Discard` variant the
1831 /// `code:delete/1` peer inspires that folds under the same
1832 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1833 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1834 /// cool-down policy grows a two-arm shape, an operator-side
1835 /// pre-resolved cleanup-decision cache the predicate could
1836 /// route through the same `bool` return contract) would have
1837 /// had to be threaded through every open-coded per-arm-union
1838 /// pattern-match in lockstep or one gate would silently
1839 /// classify the new arm outside the cleanup family while the
1840 /// peer gates classified it in (or vice versa) — a
1841 /// classification split across the three within-entry cross-
1842 /// instruction gates at build time that lands far from the
1843 /// source [`UpgradeInstruction`] declaration with no field
1844 /// naming which gate carries the drifted arm-set. Lifting the
1845 /// resolution to a typed predicate on the substrate primitive
1846 /// means every downstream cleanup-facing consumer of the
1847 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1848 /// one typed dispatch — the resolver's arm-set migrates as a
1849 /// unit on any future arm addition composing under this
1850 /// predicate's `||` chain.
1851 ///
1852 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1853 /// derive-generated [`Self::is_restart`] terminal-fallback
1854 /// arm-discriminator predicate on the same closed-set
1855 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1856 /// family partition as one typed dispatch on the substrate
1857 /// primitive; `is_restart` on the single-arm terminal-
1858 /// fallback family, `is_cleanup` on the two-arm cleanup
1859 /// family), extended here from the single-arm case onto the
1860 /// two-arm arm-family union case. Composes through the
1861 /// [`gen_platform::IsVariant`]-derive-generated
1862 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1863 /// predicates rather than an open-coded raw `matches!`
1864 /// pattern-match, so a future rebrand on either underlying
1865 /// per-arm classifier flows through this predicate's one
1866 /// body without a coordinated per-consumer rewrite across
1867 /// the three within-entry cross-instruction gates that route
1868 /// through it. Peer of the sibling per-`:contratos`
1869 /// shape-family union predicates [`crate::WitContract::is_http`] /
1870 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
1871 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
1872 /// per-shape WIT-prefix rule the substrate primitive's arm-
1873 /// family partition names as one typed dispatch) — the same
1874 /// "one typed dispatch on the substrate primitive, thin
1875 /// projections at each consumer" discipline extended onto the
1876 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
1877 /// cleanup-family axis.
1878 ///
1879 /// The name `is_cleanup` maps directly onto the canonical
1880 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
1881 /// `code:soft_purge/1` — wait until no process is running v1,
1882 /// then discard. (`code:purge/1` kills v1 immediately if you
1883 /// don't care.)" — the two `code:*_purge/1` operations are
1884 /// the two-phase-load contract's cleanup half, paired under
1885 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
1886 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1887 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
1888 /// reaches for the same "cleanup" vocabulary in identifier +
1889 /// diagnostic form.
1890 #[must_use]
1891 pub const fn is_cleanup(&self) -> bool {
1892 self.is_soft_purge() || self.is_purge()
1893 }
1894}
1895
1896/// Reject upgrade instruction `:module` values that aren't K8s
1897/// DNS-1123 labels. Thin wrapper around
1898/// [`crate::render::is_dns_1123_label`] that maps the shared
1899/// parser-shaped reason into the kind-tagged
1900/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
1901/// diagnostics, so the author can grep their caixa.lisp for the
1902/// offending `(:<kind> <module>)` form and fix it in one edit.
1903///
1904/// The contract — the same DNS-1123 label rule the K8s apiserver
1905/// enforces on every `metadata.name` / Service name / label value the
1906/// module name lands in. Each upgrade instruction's `:module` is a
1907/// reference to a caixa name (the wasm-engine resolves it through the
1908/// same `ComputeUnit` registry the operator manages), so the value must
1909/// match every downstream apiserver-side schema: the per-Servico
1910/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
1911/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
1912/// against the loaded-module table at hot-upgrade dispatch, and the
1913/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
1914/// per-module reference axis. Same trajectory as `:children :caixa`
1915/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
1916/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
1917/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
1918///
1919/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
1920/// variant before this predicate is consulted, mirroring
1921/// `validate_membro_caixa`'s empty-first cascade.
1922fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
1923 // Routes through the shared
1924 // [`crate::render::require_valid_dns_1123_label`] gate the peer
1925 // name axes each land on. The `kind: &'static str` field flows
1926 // through both error variants so the diagnostic names which
1927 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
1928 // offending value came from.
1929 crate::render::require_valid_dns_1123_label(
1930 module,
1931 || UpgradeError::ModuleEmpty { kind },
1932 |reason| UpgradeError::ModuleInvalid {
1933 kind,
1934 module: module.to_string(),
1935 reason,
1936 },
1937 )
1938}
1939
1940#[derive(Debug, Error, PartialEq, Eq)]
1941pub enum UpgradeError {
1942 #[error(
1943 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
1944 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
1945 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
1946 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
1947 the running version through `semver::Version::parse` and matches it against each entry's \
1948 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
1949 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
1950 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
1951 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
1952 )]
1953 FromInvalid { from: String, reason: String },
1954 #[error(
1955 "upgrade instruction `{kind}` :module is empty (every appup module reference \
1956 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
1957 the instruction entirely)"
1958 )]
1959 ModuleEmpty { kind: &'static str },
1960 #[error(
1961 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
1962 {reason} (every appup module reference resolves to a caixa name, which lands \
1963 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
1964 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
1965 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
1966 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
1967 `\"cache-v2\"`)"
1968 )]
1969 ModuleInvalid {
1970 kind: &'static str,
1971 module: String,
1972 reason: String,
1973 },
1974 #[error("instruction's :script is empty")]
1975 EmptyScript,
1976 #[error(
1977 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
1978 root (Path::join would otherwise escape the project sandbox)",
1979 script.display()
1980 )]
1981 AbsoluteScript { script: PathBuf },
1982 #[error(
1983 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
1984 above the caixa root",
1985 script.display()
1986 )]
1987 ParentEscapeScript { script: PathBuf },
1988 #[error(
1989 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
1990 wasm-engine instantiator reads every migration script as tatara-lisp source through \
1991 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
1992 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
1993 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
1994 parser error far from the source caixa.lisp, with no field naming the offending \
1995 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
1996 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
1997 `\"lib/migrations/v01-to-v02.lisp\"`).",
1998 script.display()
1999 )]
2000 NonLispExtensionScript { script: PathBuf },
2001 #[error(
2002 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2003 one matching block per running version (`release_handler:install_release/1` dispatches \
2004 on the loaded `:from` against the currently-running release), so two entries with the \
2005 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2006 pick either set non-deterministically). Author one path per prior version; if two \
2007 distinct instruction sequences are needed, fold them into one ordered list under the \
2008 single matching `(:from {from:?} :instructions (…))` block."
2009 )]
2010 DuplicateFrom { from: String },
2011 #[error(
2012 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2013 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2014 greater than or equal to the caixa's own version is structurally unreachable \
2015 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2016 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2017 is never reached because the operator never runs a version greater than or equal to \
2018 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2019 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2020 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2021 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2022 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2023 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2024 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2025 here as a self-upgrade no-op."
2026 )]
2027 FromNotBeforeVersao { from: String, versao: String },
2028 #[error(
2029 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2030 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2031 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2032 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2033 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2034 component-model world incompatibility, irreversible state shape change), and the \
2035 fallback is terminal by construction (the operator restarts the pod and the new \
2036 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2037 in both directions: if the typed instructions would succeed, `(:restart)` is \
2038 unreached; if they wouldn't, the typed instructions are dead because the operator \
2039 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2040 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2041 never repeated. If two distinct upgrade strategies are needed for the same prior \
2042 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2043 dispatch picks exactly one block per running version) — keep the typed sequence; \
2044 the fallback restart is what the operator does on any typed-sequence failure \
2045 already."
2046 )]
2047 RestartNotExclusive {
2048 from: String,
2049 restart_count: usize,
2050 other_kinds: Vec<&'static str>,
2051 },
2052 #[error(
2053 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2054 `(:load-module …)` in its :instructions list — a state migration is the \
2055 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2056 code, but the operator executes instructions in declared order, so this migration \
2057 runs while the only resident version is still the prior one (which expects the \
2058 pre-migration state shape). Load the new module first: author the canonical \
2059 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2060 resident before its state migration runs.",
2061 script.display(),
2062 script.display()
2063 )]
2064 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2065 #[error(
2066 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2067 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2068 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2069 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2070 then `code:soft_purge/1`), but the operator executes instructions in declared \
2071 order, so this cleanup runs while the only resident version is still the same \
2072 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2073 mid-request), leaving no replacement to route in-flight or future requests \
2074 to. Load the new module first: author the canonical `(:load-module …) \
2075 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2076 before the old code is drained or discarded."
2077 )]
2078 PurgeWithoutPriorLoad {
2079 from: String,
2080 kind: &'static str,
2081 module: String,
2082 },
2083 #[error(
2084 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2085 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2086 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2087 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2088 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2089 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2090 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2091 module for this reason. A second cleanup on the same module is at best redundant (the \
2092 module is already gone after the first cleanup, so the second is a no-op or undefined \
2093 depending on the operator's handling of a non-resident-module purge request) and at \
2094 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2095 wanted a fallback, but the operator runs declared instructions unconditionally — \
2096 fallback on cleanup failure is the operator's job, not authored into the entry). \
2097 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2098 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2099 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2100 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2101 )]
2102 DuplicateCleanup {
2103 from: String,
2104 module: String,
2105 kinds: Vec<&'static str>,
2106 },
2107 #[error(
2108 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2109 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2110 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2111 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2112 dispatch table reads the module name and brings up the corresponding component \
2113 alongside the running version. systools-generated `.relup` files emit at most one \
2114 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2115 {module:?})` instruction has no observable semantic relative to the first (the \
2116 component is already resident) — either dead code (copy-pasted load line) or a typo \
2117 masking a distinct module the author intended to load alongside (renamed both to \
2118 {module:?} by mistake), leaving the second module silently absent from the entry. \
2119 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2120 versions need loading alongside the running one, name them distinctly (e.g. \
2121 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2122 )]
2123 DuplicateLoadModule { from: String, module: String },
2124 #[error(
2125 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2126 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2127 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2128 state shape into the current-version shape: a one-shot transition, not a step that \
2129 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2130 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2131 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2132 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2133 author intended two distinct migration scripts) and at worst silent state corruption \
2134 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2135 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2136 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2137 transitions are needed (e.g. one module's schema *and* another module's projection), \
2138 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2139 script.display(),
2140 script.display(),
2141 script.display(),
2142 script.display()
2143 )]
2144 DuplicateStateChange { from: String, script: PathBuf },
2145 #[error(
2146 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2147 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2148 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2149 current shape, but the prior version's state only exists while the prior code is \
2150 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2151 analogs and drain or discard that prior code. The operator executes instructions in \
2152 declared order, so a cleanup ahead of a state-change has already drained the prior \
2153 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2154 the migration script runs, leaving the script either no-op (no prior-version state \
2155 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2156 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2157 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2158 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2159 strictly between load and cleanup. Author the canonical `(:load-module …) \
2160 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2161 migration runs against the prior-version state before the cleanup drains it.",
2162 script.display(),
2163 script.display()
2164 )]
2165 StateChangeAfterCleanup {
2166 from: String,
2167 script: PathBuf,
2168 prior_cleanup_kind: &'static str,
2169 prior_cleanup_module: String,
2170 },
2171 #[error(
2172 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2173 declare `:behavior :on-state-change` — the per-version migration script is the \
2174 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2175 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2176 realizes the composition by invoking the running gen_server's code_change/3 callback \
2177 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2178 composition into two typed slots, the per-version migration logic in this \
2179 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2180 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2181 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2182 migration during hot upgrades\"). The missing callback leaves the per-version script \
2183 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2184 callback at the migration step, finds it absent, and either fails the upgrade \
2185 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2186 current version stays load-bearing\") or silently skips the migration leaving the \
2187 new code running against unmigrated prior-version state. Add the callback: \
2188 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2189 path) alongside the existing `(:state-change {})` instruction (the per-version \
2190 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2191 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2192 migration — is the canonical shape).",
2193 script.display(),
2194 script.display()
2195 )]
2196 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2197}
2198
2199#[cfg(test)]
2200mod tests {
2201 use std::path::Path;
2202
2203 use super::*;
2204
2205 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
2206 UpgradeFromEntry {
2207 from: from.into(),
2208 instructions: instrs,
2209 }
2210 }
2211
2212 #[test]
2213 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
2214 // Fail-before-pass-after pin on
2215 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
2216 // posture. The accessor projects the per-`:upgrade-from :from`
2217 // [`String`] storage through the `pub const fn`
2218 // [`String::as_str`] (const-stable since Rust 1.87, well within
2219 // the workspace MSRV) — any future accidental downgrade to
2220 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
2221 // build time with E0015 (`cannot call non-const method`),
2222 // strictly stronger than a runtime `assert!`. Sibling of the
2223 // peer M2/M3 slot family pins on the sibling `const`-eval-
2224 // surface passes ([`crate::Caixa::nome`] /
2225 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
2226 // [`crate::aplicacao::Membro::nome`] /
2227 // [`crate::aplicacao::Membro::versao_requirement`],
2228 // [`crate::aplicacao::Entrada::hostname`] /
2229 // [`crate::aplicacao::Entrada::destination`],
2230 // [`crate::supervisor::ChildSpec::nome`] /
2231 // [`crate::supervisor::ChildSpec::versao_requirement`],
2232 // [`crate::dep::Dep::nome`] /
2233 // [`crate::dep::Dep::versao_requirement`], and the
2234 // per-`:contratos`
2235 // [`crate::aplicacao::WitContract::source`] /
2236 // [`crate::aplicacao::WitContract::destination`] /
2237 // [`crate::aplicacao::WitContract::world_ref`] trio the
2238 // sibling pin at 279823b already anchors).
2239 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
2240 e.prior_versao()
2241 }
2242 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
2243 let e = entry(from, vec![]);
2244 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
2245 assert_eq!(e.prior_versao(), from);
2246 }
2247 }
2248
2249 #[test]
2250 fn round_trip_load_module() {
2251 let i = UpgradeInstruction::LoadModule {
2252 module: "hello-rio".into(),
2253 };
2254 let json = serde_json::to_string(&i).unwrap();
2255 assert!(json.contains("\"kind\":\"load-module\""));
2256 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2257 assert_eq!(i, back);
2258 }
2259
2260 #[test]
2261 fn round_trip_all_variants() {
2262 let cases = vec![
2263 UpgradeInstruction::LoadModule { module: "x".into() },
2264 UpgradeInstruction::StateChange {
2265 script: PathBuf::from("lib/migrations.lisp"),
2266 },
2267 UpgradeInstruction::SoftPurge {
2268 module: "x-old".into(),
2269 },
2270 UpgradeInstruction::Purge {
2271 module: "x-old".into(),
2272 },
2273 UpgradeInstruction::Restart,
2274 ];
2275 for c in cases {
2276 let json = serde_json::to_string(&c).unwrap();
2277 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2278 assert_eq!(c, back);
2279 }
2280 }
2281
2282 #[test]
2283 fn validate_accepts_well_formed() {
2284 let e = entry(
2285 "0.1.0",
2286 vec![
2287 UpgradeInstruction::LoadModule {
2288 module: "hello-rio".into(),
2289 },
2290 UpgradeInstruction::StateChange {
2291 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
2292 },
2293 UpgradeInstruction::SoftPurge {
2294 module: "hello-rio-old".into(),
2295 },
2296 ],
2297 );
2298 e.validate().unwrap();
2299 }
2300
2301 #[test]
2302 fn validate_rejects_non_semver_from() {
2303 let e = entry("not-a-semver", vec![]);
2304 let err = e.validate().unwrap_err();
2305 assert!(
2306 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
2307 );
2308 }
2309
2310 #[test]
2311 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
2312 // Diagnostic-shape pin: the error names the offending
2313 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
2314 // reason, so a `feira lint` run can render the diagnostic
2315 // without re-parsing — the author can grep their caixa.lisp for
2316 // `:from "<value>"` and fix it in one edit. Mirrors the peer
2317 // `versao_invalid_diagnostic_carries_offending_versao` pin on
2318 // the sibling SemVer-2 axis (the top-level `:versao`), the
2319 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
2320 // pin on `:membros :versao`, and the peer
2321 // `deps_invalid_diagnostic_carries_offending_value` pin on
2322 // `:deps :versao` — every SemVer-2-parsing slot's invalid
2323 // diagnostic is now structurally equivalent.
2324 let e = entry("v0.1.0", vec![]);
2325 let err = e.validate().unwrap_err();
2326 let UpgradeError::FromInvalid { from, reason } = err else {
2327 panic!("expected FromInvalid variant, got {err:?}");
2328 };
2329 assert_eq!(from, "v0.1.0");
2330 assert!(
2331 !reason.is_empty(),
2332 "FromInvalid `reason` must carry the parser's wording verbatim"
2333 );
2334 }
2335
2336 #[test]
2337 fn prior_versao_returns_from_byte_equal_across_permutations() {
2338 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
2339 // accessor across the SemVer-2 shape lattice every consumer
2340 // reaches through it — the numeric-triad canonical shape, a
2341 // pre-release build with a dotted identifier chain, a full-
2342 // metadata build, a large-magnitude triad, and the empty
2343 // string (which reaches this accessor unchanged before any
2344 // validate gate rejects it). Sibling to the peer
2345 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
2346 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
2347 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
2348 // family — extended here onto the first M2 slot scalar-value
2349 // axis. Any silent detour on the accessor (a `.to_string()`
2350 // + retained ownership shape, a canonicalization pass, a
2351 // trim-whitespace on the return path) surfaces as a byte-
2352 // inequality failure here rather than as a downstream error-
2353 // diagnostic drift.
2354 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
2355 for from in cases {
2356 let e = entry(from, vec![]);
2357 assert_eq!(
2358 e.prior_versao(),
2359 from,
2360 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
2361 );
2362 assert_eq!(
2363 e.prior_versao().len(),
2364 from.len(),
2365 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
2366 );
2367 }
2368 }
2369
2370 #[test]
2371 fn prior_versao_borrows_from_from_storage() {
2372 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
2373 // a borrow into `self.from`'s heap allocation, never a fresh
2374 // owned copy. Guards against a future silent detour where
2375 // the accessor materializes a `Cow<'_, str>` / `String` /
2376 // `Rc<str>` intermediate — the return path stays zero-cost
2377 // even under a refactor that reshapes the storage. Sibling
2378 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
2379 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
2380 // (4a32abf) pins — extended onto the M2 slot's first
2381 // scalar-value axis.
2382 let e = entry("0.1.0", vec![]);
2383 assert!(
2384 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
2385 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
2386 );
2387 }
2388
2389 #[test]
2390 fn validate_parses_prior_versao_through_lifted_accessor() {
2391 // Coherence pin between the accessor and the SemVer-2 parse
2392 // gate: every `:upgrade-from :from` value the validator
2393 // accepts (resp. rejects) must be identical to what
2394 // `Version::parse(entry.prior_versao())` accepts (resp.
2395 // rejects) — the two must remain in lockstep across the
2396 // shape lattice so `validate_upgrade_from`'s
2397 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
2398 // assertion holds by construction. If a future extension of
2399 // `prior_versao` reshapes the return (a canonicalization
2400 // pass, a leading/trailing whitespace trim, an empty-to-
2401 // "0.0.0" fallback) it would either loosen the validator
2402 // (silently accepting shapes the parser rejects) or
2403 // tighten the parser's re-parse (silently panicking on
2404 // shapes the validator accepts) — this pin catches either
2405 // shift at caixa-core build time.
2406 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
2407 for from in accepted {
2408 let e = entry(from, vec![]);
2409 e.validate().unwrap_or_else(|err| {
2410 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
2411 });
2412 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
2413 panic!(
2414 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
2415 got {err:?}",
2416 );
2417 });
2418 }
2419 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
2420 for from in rejected {
2421 let e = entry(from, vec![]);
2422 assert!(
2423 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
2424 "validate() must reject {from:?} that Version::parse rejects",
2425 );
2426 assert!(
2427 semver::Version::parse(e.prior_versao()).is_err(),
2428 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
2429 );
2430 }
2431 }
2432
2433 #[test]
2434 fn validate_rejects_empty_module() {
2435 // Per-arm coverage: every Module-bearing variant surfaces the
2436 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
2437 // so the author can grep their caixa.lisp for `(:load-module
2438 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
2439 // edit — same self-locating shape `BehaviorError::EmptyPath`
2440 // (b0c8389) carries on the peer M2 typed slot.
2441 let cases: &[(UpgradeInstruction, &'static str)] = &[
2442 (
2443 UpgradeInstruction::LoadModule {
2444 module: String::new(),
2445 },
2446 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
2447 ),
2448 (
2449 UpgradeInstruction::SoftPurge {
2450 module: String::new(),
2451 },
2452 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
2453 ),
2454 (
2455 UpgradeInstruction::Purge {
2456 module: String::new(),
2457 },
2458 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
2459 ),
2460 ];
2461 for (instr, expected_kind) in cases {
2462 assert_eq!(
2463 instr.validate().unwrap_err(),
2464 UpgradeError::ModuleEmpty {
2465 kind: expected_kind
2466 },
2467 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
2468 );
2469 }
2470 }
2471
2472 #[test]
2473 fn validate_rejects_non_dns_1123_module() {
2474 // Every appup `:module` reference is a caixa name (the
2475 // wasm-engine resolves it through the same ComputeUnit
2476 // registry the operator manages), so the value-shape gate
2477 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
2478 // the canonical authoring footguns — uppercase letters, `_`
2479 // separator, embedded `.`, leading/trailing `-`, an embedded
2480 // whitespace byte, the >63-byte UUID-shaped slug — across
2481 // every Module-bearing variant; each must surface as
2482 // `ModuleInvalid { kind, module, reason }` carrying the
2483 // offending value verbatim and the parser-shaped reason.
2484 type Build = fn(String) -> UpgradeInstruction;
2485 let footguns: &[&str] = &[
2486 "Hello-Rio",
2487 "hello_rio",
2488 "hello.rio",
2489 "-hello",
2490 "hello-",
2491 "hello rio",
2492 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
2493 ];
2494 let variants: &[(Build, &'static str)] = &[
2495 (
2496 |m| UpgradeInstruction::LoadModule { module: m },
2497 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
2498 ),
2499 (
2500 |m| UpgradeInstruction::SoftPurge { module: m },
2501 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
2502 ),
2503 (
2504 |m| UpgradeInstruction::Purge { module: m },
2505 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
2506 ),
2507 ];
2508 for (build, expected_kind) in variants {
2509 for module in footguns {
2510 let instr = build((*module).to_string());
2511 let err = instr.validate().unwrap_err();
2512 match err {
2513 UpgradeError::ModuleInvalid {
2514 kind,
2515 module: m,
2516 reason,
2517 } => {
2518 assert_eq!(
2519 kind, *expected_kind,
2520 ":module footgun on {instr:?} must tag the lisp-form"
2521 );
2522 assert_eq!(
2523 m, *module,
2524 "ModuleInvalid must carry the offending value verbatim"
2525 );
2526 assert!(
2527 !reason.is_empty(),
2528 "ModuleInvalid reason must name the specific violation \
2529 (the predicate's parser-shaped wording from \
2530 `is_dns_1123_label`), got empty"
2531 );
2532 }
2533 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
2534 }
2535 }
2536 }
2537 }
2538
2539 #[test]
2540 fn validate_accepts_canonical_module_names() {
2541 // Positive control: every documented authoring shape — bare
2542 // identifier, with hyphens, with digits, the
2543 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
2544 // references — passes the gate. Drift here = a future
2545 // tighten that rejects any of these surfaces as a
2546 // test-failure at the predicate boundary, not piecemeal
2547 // across per-instruction call sites.
2548 let canonical: &[&str] = &[
2549 "hello-rio",
2550 "hello-rio-old",
2551 "cache",
2552 "cache-v2",
2553 "x",
2554 "a1",
2555 "0a",
2556 "abc-123-def",
2557 ];
2558 for module in canonical {
2559 UpgradeInstruction::LoadModule {
2560 module: (*module).to_string(),
2561 }
2562 .validate()
2563 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
2564 UpgradeInstruction::SoftPurge {
2565 module: (*module).to_string(),
2566 }
2567 .validate()
2568 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
2569 UpgradeInstruction::Purge {
2570 module: (*module).to_string(),
2571 }
2572 .validate()
2573 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
2574 }
2575 }
2576
2577 #[test]
2578 fn validate_empty_takes_precedence_over_invalid() {
2579 // Empty input is rejected via the narrower `ModuleEmpty`
2580 // diagnostic before the DNS-1123 predicate is consulted, so
2581 // a future tighten that adds another stage between the two
2582 // doesn't accidentally reorder the diagnostic precedence.
2583 // Mirrors the empty-first cascade on every peer DNS-1123
2584 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
2585 // `SupervisorSpec::validate`'s child-name arm).
2586 let err = UpgradeInstruction::LoadModule {
2587 module: String::new(),
2588 }
2589 .validate()
2590 .unwrap_err();
2591 assert_eq!(
2592 err,
2593 UpgradeError::ModuleEmpty {
2594 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
2595 }
2596 );
2597 }
2598
2599 #[test]
2600 fn validate_rejects_empty_script() {
2601 let i = UpgradeInstruction::StateChange {
2602 script: PathBuf::new(),
2603 };
2604 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
2605 }
2606
2607 #[test]
2608 fn validate_rejects_absolute_script() {
2609 let i = UpgradeInstruction::StateChange {
2610 script: PathBuf::from("/etc/migrations.lisp"),
2611 };
2612 assert!(matches!(
2613 i.validate().unwrap_err(),
2614 UpgradeError::AbsoluteScript { .. }
2615 ));
2616 }
2617
2618 #[test]
2619 fn validate_rejects_parent_escape_script() {
2620 let i = UpgradeInstruction::StateChange {
2621 script: PathBuf::from("../sibling/migrations.lisp"),
2622 };
2623 assert!(matches!(
2624 i.validate().unwrap_err(),
2625 UpgradeError::ParentEscapeScript { .. }
2626 ));
2627 // mid-path `..` is also caught
2628 let i2 = UpgradeInstruction::StateChange {
2629 script: PathBuf::from("lib/../../escaped.lisp"),
2630 };
2631 assert!(matches!(
2632 i2.validate().unwrap_err(),
2633 UpgradeError::ParentEscapeScript { .. }
2634 ));
2635 }
2636
2637 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
2638 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
2639 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
2640 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
2641 // consumer; the file-type contract is identical, so the per-axis
2642 // test grid is mirrored leg-for-leg.
2643
2644 #[test]
2645 fn validate_rejects_no_extension_script() {
2646 // Fail-before-pass-after: the canonical "I declared the
2647 // migration script but forgot the `.lisp` extension"
2648 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
2649 // The wasm-engine's `tatara_lisp::read` consumer needs a
2650 // file-type contract beyond the structural-shape gate; a
2651 // no-extension path past `is_sandboxed_relative_path` would
2652 // surface a parser-shaped diagnostic at hot-upgrade migration
2653 // time far from the source caixa.lisp.
2654 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
2655 let i = UpgradeInstruction::StateChange {
2656 script: PathBuf::from(relpath),
2657 };
2658 let err = i.validate().unwrap_err();
2659 assert!(
2660 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2661 if s == Path::new(relpath)),
2662 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
2663 carrying the offending path verbatim, got {err:?}"
2664 );
2665 }
2666 }
2667
2668 #[test]
2669 fn validate_rejects_non_lisp_extension_script() {
2670 // Wrong-extension sweep across common authoring footguns: the
2671 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
2672 // drag in from the workspace tree, the `.rs` shape that an
2673 // IDE auto-complete might propose, the `.lisp.bak` shape an
2674 // editor might leave behind, and the `.lispx` near-miss that
2675 // a typo would produce. Each must surface as
2676 // `NonLispExtensionScript` carrying the offending path
2677 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
2678 // rejects all of these at hot-upgrade migration time, and
2679 // the gate lifts that contract to validate time. Mirrors the
2680 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
2681 // the `:behavior :on-*` axis leg-for-leg — same downstream
2682 // consumer, same accepted set, same per-axis test grid.
2683 let footguns: &[&str] = &[
2684 "lib/migrations.rs",
2685 "lib/migrations.txt",
2686 "lib/migrations.md",
2687 "lib/migrations.json",
2688 "lib/migrations.yaml",
2689 "lib/migrations.toml",
2690 "lib/migrations.lisp.bak",
2691 "lib/migrations.lispx",
2692 "lib/migrations.lis",
2693 ];
2694 for relpath in footguns {
2695 let i = UpgradeInstruction::StateChange {
2696 script: PathBuf::from(relpath),
2697 };
2698 let err = i.validate().unwrap_err();
2699 assert!(
2700 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2701 if s == Path::new(relpath)),
2702 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
2703 carrying the offending path verbatim, got {err:?}"
2704 );
2705 }
2706 }
2707
2708 #[test]
2709 fn validate_rejects_uppercase_lisp_extension_script() {
2710 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
2711 // case-folded shapes a case-insensitive volume's existence
2712 // check would match the on-disk file — but the
2713 // canonical-form codec emits lowercase `.lisp` verbatim, so
2714 // a case-folded shape mismatches the round-trip-stable
2715 // canonical form (THEORY.md §V.2.7 render-determinism).
2716 // Same case-sensitive discipline the byte-size / duration
2717 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
2718 // and every other shape-gate predicate in `render.rs` (label
2719 // / scheme / unit boundaries). Mirrors the peer
2720 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
2721 for relpath in [
2722 "lib/migrations.LISP",
2723 "lib/migrations.Lisp",
2724 "lib/migrations.LiSp",
2725 "lib/migrations.lISP",
2726 ] {
2727 let i = UpgradeInstruction::StateChange {
2728 script: PathBuf::from(relpath),
2729 };
2730 let err = i.validate().unwrap_err();
2731 assert!(
2732 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2733 if s == Path::new(relpath)),
2734 "case-folded `.lisp` extension {relpath:?} must surface as \
2735 NonLispExtensionScript (strict lowercase, canonical-form \
2736 round-trip pin), got {err:?}"
2737 );
2738 }
2739 }
2740
2741 #[test]
2742 fn validate_accepts_canonical_lisp_extension_scripts() {
2743 // Positive-control sweep across every canonical in-tree
2744 // authoring shape: bare filename, standard `lib/`
2745 // subdirectory, deeply-nested migrations subdirectory,
2746 // explicit current-dir-relative prefix, mid-path `./`
2747 // segment, multi-dot stem (the version-suffix shape
2748 // `lib/migrations/v.0.1.lisp` an author might use to encode
2749 // the migration's `:from` version into the filename). Drift
2750 // here = a future tightening that rejects any of these
2751 // surfaces as a test-failure at the per-axis validator
2752 // boundary, not piecemeal across renderer / layout-checker
2753 // call sites. Mirrors the peer `BehaviorSpec` positive-set
2754 // sweep (c97815a).
2755 let canonical: &[&str] = &[
2756 "lib/migrations.lisp",
2757 "lib/migrations/v01-to-v02.lisp",
2758 "migrations.lisp",
2759 "a.lisp",
2760 "./lib/migrations.lisp",
2761 "lib/./migrations.lisp",
2762 "lib/migrations/v.0.1.lisp",
2763 ];
2764 for relpath in canonical {
2765 UpgradeInstruction::StateChange {
2766 script: PathBuf::from(relpath),
2767 }
2768 .validate()
2769 .unwrap_or_else(|e| {
2770 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
2771 });
2772 }
2773 }
2774
2775 #[test]
2776 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
2777 // Cross-arm precedence pin: a script that is *both*
2778 // sandbox-escaping (Empty / Absolute / ParentEscape) and
2779 // non-`.lisp` must surface the more-fundamental
2780 // sandbox-shape diagnostic first — the canonical fix
2781 // collapses both into "pin a relative `.lisp` path under the
2782 // caixa root", and the `.lisp` remediation would be
2783 // misleading when the offending path can never resolve under
2784 // the caixa root anyway. Mirrors the peer
2785 // `BehaviorError` cross-arm precedence (c97815a) and the
2786 // sibling `LimitsError`
2787 // (`MemoryZero` → `MemoryBelowWasm32Page` →
2788 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
2789 // smallest-scope-arm-fires-last posture.
2790 let i_empty = UpgradeInstruction::StateChange {
2791 script: PathBuf::new(),
2792 };
2793 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
2794 let i_abs = UpgradeInstruction::StateChange {
2795 script: PathBuf::from("/etc/migrations.txt"),
2796 };
2797 assert!(
2798 matches!(
2799 i_abs.validate().unwrap_err(),
2800 UpgradeError::AbsoluteScript { .. }
2801 ),
2802 "absolute + non-`.lisp` must surface AbsoluteScript first"
2803 );
2804 let i_esc = UpgradeInstruction::StateChange {
2805 script: PathBuf::from("../sibling/migrations.rs"),
2806 };
2807 assert!(
2808 matches!(
2809 i_esc.validate().unwrap_err(),
2810 UpgradeError::ParentEscapeScript { .. }
2811 ),
2812 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
2813 );
2814 }
2815
2816 #[test]
2817 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
2818 // Diagnostic-shape pin: the surfaced error message names the
2819 // offending path verbatim (so the author can grep their
2820 // caixa.lisp for the literal value), the `.lisp` extension
2821 // is named in the remediation, and the downstream consumer
2822 // (`tatara_lisp::read` at hot-upgrade migration time) is
2823 // named so the author can trace the contract back to its
2824 // source. Same self-locating shape every per-axis variant
2825 // carries (`BehaviorError::NonLispExtension`, c97815a;
2826 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
2827 let bad = PathBuf::from("lib/migrations.txt");
2828 let err = UpgradeInstruction::StateChange {
2829 script: bad.clone(),
2830 }
2831 .validate()
2832 .unwrap_err();
2833 let msg = err.to_string();
2834 assert!(
2835 msg.contains("lib/migrations.txt"),
2836 "diagnostic must name the offending path verbatim, got {msg:?}"
2837 );
2838 assert!(
2839 msg.contains(".lisp"),
2840 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
2841 );
2842 assert!(
2843 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
2844 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
2845 );
2846 match err {
2847 UpgradeError::NonLispExtensionScript { script } => {
2848 assert_eq!(
2849 script, bad,
2850 "variant must carry the offending path verbatim"
2851 );
2852 }
2853 other => panic!("expected NonLispExtensionScript, got {other:?}"),
2854 }
2855 }
2856
2857 #[test]
2858 fn declared_path_only_for_state_change() {
2859 let load = UpgradeInstruction::LoadModule { module: "x".into() };
2860 assert!(load.declared_path().is_none());
2861 let mig = UpgradeInstruction::StateChange {
2862 script: PathBuf::from("lib/m.lisp"),
2863 };
2864 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
2865 }
2866
2867 #[test]
2868 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
2869 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
2870 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
2871 // predicate: [`UpgradeInstruction::Restart`] is the only variant
2872 // that satisfies `.is_restart()`; every module-bearing arm
2873 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
2874 // `StateChange` arm all return `false`. This pin makes the
2875 // partition invariant load-bearing at caixa-core test time so a
2876 // future derive regression (a hole that returns `false` for
2877 // `Restart` too, or a byte-collision that flips a second variant
2878 // to `true`) trips here rather than laundering the arm at
2879 // [`Self::validate_restart_exclusive`]'s paired positive /
2880 // negated filter sites (a hole flips restart-count to 0 →
2881 // vacuous OK; a collision flips restart-count > 1 → false
2882 // `RestartNotExclusive` on an entry the author declared without
2883 // any `(:restart)`). Peer of the sibling
2884 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
2885 // pin on the M0 `CaixaKind` axis.
2886 let cases: &[(UpgradeInstruction, bool)] = &[
2887 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
2888 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
2889 (UpgradeInstruction::Purge { module: "c".into() }, false),
2890 (
2891 UpgradeInstruction::StateChange {
2892 script: PathBuf::from("lib/m.lisp"),
2893 },
2894 false,
2895 ),
2896 (UpgradeInstruction::Restart, true),
2897 ];
2898 for (variant, expected) in cases {
2899 assert_eq!(
2900 variant.is_restart(),
2901 *expected,
2902 "UpgradeInstruction::{variant:?}.is_restart() must \
2903 return {expected} (partition invariant on the \
2904 IsVariant-derived arm-discriminator predicate)"
2905 );
2906 }
2907 }
2908
2909 #[test]
2910 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
2911 // Byte-identity pin on the paired positive / negated
2912 // `.is_restart()` filters at
2913 // [`Self::validate_restart_exclusive`] against the pre-lift
2914 // `matches!(i, UpgradeInstruction::Restart)` /
2915 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
2916 // consumer of the gate previously coupled to inline. Asserts
2917 // the two projections agree byte-for-byte on every arm of the
2918 // enum, so a future derive regression that flipped either
2919 // predicate's arm-set would surface here at caixa-core test
2920 // time rather than at
2921 // [`Self::validate_restart_exclusive`]'s per-entry restart-
2922 // count / other-kinds tabulation far from the derive site.
2923 // Same peer-shape pin every sibling
2924 // `IsVariant`-derive-routed gate carries on the substrate's
2925 // closed-set typed-enum surface.
2926 let cases: Vec<UpgradeInstruction> = vec![
2927 UpgradeInstruction::LoadModule { module: "a".into() },
2928 UpgradeInstruction::SoftPurge { module: "b".into() },
2929 UpgradeInstruction::Purge { module: "c".into() },
2930 UpgradeInstruction::StateChange {
2931 script: PathBuf::from("lib/m.lisp"),
2932 },
2933 UpgradeInstruction::Restart,
2934 ];
2935 for instr in &cases {
2936 let via_predicate = instr.is_restart();
2937 let via_matches = matches!(instr, UpgradeInstruction::Restart);
2938 assert_eq!(
2939 via_predicate, via_matches,
2940 "UpgradeInstruction::{instr:?}: is_restart() must \
2941 byte-equal matches!(_, UpgradeInstruction::Restart) — \
2942 the pre-lift open-coded pattern and the \
2943 IsVariant-derived predicate are the same axis, \
2944 one typed dispatch"
2945 );
2946 }
2947 }
2948
2949 #[test]
2950 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
2951 // The fail-before-pass-after pin on the lifted
2952 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
2953 // arm-discriminator predicate:
2954 // [`UpgradeInstruction::SoftPurge`] and
2955 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
2956 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
2957 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
2958 // on the paired two-phase-load half,
2959 // [`UpgradeInstruction::StateChange`] on the
2960 // `gen_server:code_change/3`-analog migration axis,
2961 // [`UpgradeInstruction::Restart`] on the OTP terminal-
2962 // fallback shape) returns `false`. This pin makes the
2963 // partition invariant load-bearing at caixa-core test time
2964 // so a future accessor regression (a hole that returns
2965 // `false` for `SoftPurge` or `Purge`, or a byte-collision
2966 // that flips `LoadModule` / `StateChange` / `Restart` to
2967 // `true`) trips here rather than laundering the arm at the
2968 // three within-entry cross-instruction cleanup-facing gates
2969 // ([`UpgradeFromEntry::validate_purge_ordering`],
2970 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
2971 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
2972 // hole would silently accept a cleanup-shaped entry the
2973 // three gates should refuse; a collision would fire a
2974 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
2975 // `DuplicateCleanup` refusal on a well-shaped
2976 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
2977 // `Restart` arm the three gates should pass through. Peer
2978 // of the sibling
2979 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
2980 // pin on the single-arm terminal-fallback partition —
2981 // extended here from the single-arm case onto the two-arm
2982 // cleanup-family union case.
2983 let cases: &[(UpgradeInstruction, bool)] = &[
2984 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
2985 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
2986 (UpgradeInstruction::Purge { module: "c".into() }, true),
2987 (
2988 UpgradeInstruction::StateChange {
2989 script: PathBuf::from("lib/m.lisp"),
2990 },
2991 false,
2992 ),
2993 (UpgradeInstruction::Restart, false),
2994 ];
2995 for (variant, expected) in cases {
2996 assert_eq!(
2997 variant.is_cleanup(),
2998 *expected,
2999 "UpgradeInstruction::{variant:?}.is_cleanup() must \
3000 return {expected} (partition invariant on the \
3001 lifted OTP-appup two-arm cleanup-family arm-\
3002 discriminator predicate)"
3003 );
3004 }
3005 }
3006
3007 #[test]
3008 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
3009 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
3010 // composition against the two [`gen_platform::IsVariant`]-
3011 // derive-generated per-variant classifiers it routes through
3012 // — the accessor's one body must byte-equal
3013 // `self.is_soft_purge() || self.is_purge()` across every arm
3014 // of the closed-set enum, so a future silent detour that
3015 // reintroduced a raw `matches!` pattern or that stopped
3016 // composing through the derive-generated per-variant
3017 // predicates (an accidental `self.is_soft_purge()` on its
3018 // own — silently dropping the `Purge` arm; an accidental
3019 // `self.is_purge() || self.is_state_change()` — silently
3020 // folding the migration arm into the cleanup family; a
3021 // typo `&&` for the union `||` — silently classifying no
3022 // arm as cleanup) trips here at caixa-core test time
3023 // rather than laundering the arm at the three within-entry
3024 // cross-instruction cleanup-facing gates. Same peer-shape
3025 // pin the sibling
3026 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3027 // carries on the paired terminal-fallback axis.
3028 let cases: Vec<UpgradeInstruction> = vec![
3029 UpgradeInstruction::LoadModule { module: "a".into() },
3030 UpgradeInstruction::SoftPurge { module: "b".into() },
3031 UpgradeInstruction::Purge { module: "c".into() },
3032 UpgradeInstruction::StateChange {
3033 script: PathBuf::from("lib/m.lisp"),
3034 },
3035 UpgradeInstruction::Restart,
3036 ];
3037 for instr in &cases {
3038 let via_predicate = instr.is_cleanup();
3039 let via_composition = instr.is_soft_purge() || instr.is_purge();
3040 assert_eq!(
3041 via_predicate, via_composition,
3042 "UpgradeInstruction::{instr:?}: is_cleanup() must \
3043 byte-equal is_soft_purge() || is_purge() — the \
3044 lifted union predicate and its per-variant \
3045 composition are the same axis, one typed dispatch"
3046 );
3047 }
3048 }
3049
3050 #[test]
3051 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
3052 // Composition-pin the load-bearing invariant every consumer
3053 // that routes through `is_cleanup()` + `declared_module()`
3054 // relies on: any [`UpgradeInstruction`] value whose
3055 // `.is_cleanup()` returns `true` must have a `Some(_)`
3056 // `.declared_module()`. This makes the three within-entry
3057 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
3058 // implies declared_module() is Some")` structurally
3059 // infallible at build time — a future refactor that added
3060 // a cleanup-shaped variant carrying no `:module` would trip
3061 // here rather than panic at
3062 // [`UpgradeFromEntry::validate_purge_ordering`] /
3063 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
3064 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
3065 // runtime on the offending author's caixa.lisp.
3066 let cases: Vec<UpgradeInstruction> = vec![
3067 UpgradeInstruction::LoadModule { module: "a".into() },
3068 UpgradeInstruction::SoftPurge { module: "b".into() },
3069 UpgradeInstruction::Purge { module: "c".into() },
3070 UpgradeInstruction::StateChange {
3071 script: PathBuf::from("lib/m.lisp"),
3072 },
3073 UpgradeInstruction::Restart,
3074 ];
3075 for instr in &cases {
3076 if instr.is_cleanup() {
3077 assert!(
3078 instr.declared_module().is_some(),
3079 "UpgradeInstruction::{instr:?}: is_cleanup() \
3080 must imply declared_module().is_some() — the \
3081 three within-entry cross-instruction cleanup-\
3082 facing gates rely on this invariant to route \
3083 the cleanup-target :module scalar through the \
3084 sibling declared_module accessor without a \
3085 pattern-bound `module` binding"
3086 );
3087 }
3088 }
3089 }
3090
3091 #[test]
3092 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
3093 // Composition-pin the load-bearing invariant
3094 // [`UpgradeFromEntry::validate_load_singularity`] relies on
3095 // when routing the per-instruction load-family arm-discriminator
3096 // through the sibling
3097 // [`UpgradeInstruction::is_load_module`] +
3098 // [`UpgradeInstruction::declared_module`] accessor pair: any
3099 // [`UpgradeInstruction`] value whose `.is_load_module()`
3100 // returns `true` must have a `Some(_)` `.declared_module()`.
3101 // This makes the gate's `.expect("is_load_module() implies
3102 // declared_module() is Some")` structurally infallible at
3103 // build time — a future refactor that added a load-shaped
3104 // variant carrying no `:module` would trip here rather than
3105 // panic at [`UpgradeFromEntry::validate_load_singularity`]
3106 // at runtime on the offending author's caixa.lisp. Sibling
3107 // of the peer
3108 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3109 // composition pin on the two-arm cleanup-family axis — same
3110 // "predicate implies accessor" discipline extended onto the
3111 // single-arm load-family axis, closes the load-vs-cleanup
3112 // pair on the substrate primitive's typed dispatch discipline.
3113 let cases: Vec<UpgradeInstruction> = vec![
3114 UpgradeInstruction::LoadModule { module: "a".into() },
3115 UpgradeInstruction::SoftPurge { module: "b".into() },
3116 UpgradeInstruction::Purge { module: "c".into() },
3117 UpgradeInstruction::StateChange {
3118 script: PathBuf::from("lib/m.lisp"),
3119 },
3120 UpgradeInstruction::Restart,
3121 ];
3122 for instr in &cases {
3123 if instr.is_load_module() {
3124 assert!(
3125 instr.declared_module().is_some(),
3126 "UpgradeInstruction::{instr:?}: is_load_module() \
3127 must imply declared_module().is_some() — the \
3128 within-entry load-singularity gate relies on this \
3129 invariant to route the load-target :module scalar \
3130 through the sibling declared_module accessor \
3131 without a pattern-bound `module` binding"
3132 );
3133 }
3134 }
3135 }
3136
3137 #[test]
3138 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
3139 {
3140 // Byte-identity pin on the
3141 // [`UpgradeFromEntry::validate_load_singularity`] load-family
3142 // dispatch against the pre-lift
3143 // `match instr { UpgradeInstruction::LoadModule { module } =>
3144 // module.as_str(), _ => continue }` open-coded pattern-match
3145 // the site previously carried. Asserts the two projections
3146 // agree byte-for-byte on every arm of the enum — the
3147 // arm-discriminator via `is_load_module()` and the `:module`
3148 // scalar via `declared_module()` — so a future derive
3149 // regression that flipped the predicate's arm-set (a hole
3150 // returning `false` for [`UpgradeInstruction::LoadModule`], a
3151 // byte-collision flipping a second variant to `true`) or an
3152 // accessor extension that promoted an additional variant onto
3153 // the `String`-carrying axis would trip here at caixa-core
3154 // test time rather than laundering the arm at the gate's
3155 // per-entry load-singularity scan far from the derive site.
3156 // Peer of the sibling
3157 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
3158 // byte-identity pin on the paired ordering-side load-family
3159 // sticky-latch dispatch (both consumers now agree on one
3160 // typed dispatch for the load-family axis) and the peer
3161 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
3162 // pin on the migration-family script-projection axis — the
3163 // three within-entry per-instruction-class singularity gates
3164 // now share one byte-identity pin apiece against their
3165 // respective substrate-primitive typed dispatches.
3166 //
3167 // Three-arm projective coverage:
3168 // (a) `LoadModule` modules project through
3169 // `declared_module()` byte-equal to the raw
3170 // `module.as_str()` field access;
3171 // (b) a duplicate-`LoadModule` input trips the gate on the
3172 // second occurrence with `DuplicateLoadModule` carrying
3173 // the offending module verbatim;
3174 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
3175 // `StateChange` / `Restart`) leaves the gate vacuous
3176 // with `Ok(())` — the `!instr.is_load_module()`
3177 // `continue` fall-through pins.
3178 //
3179 // Fail-before-pass-after verified locally: swapping the
3180 // production `if !instr.is_load_module() { continue; } let
3181 // module = instr.declared_module().expect(…);` back to `let
3182 // module = match instr { UpgradeInstruction::LoadModule
3183 // { module } => module.as_str(), _ => continue, };` keeps
3184 // arms (a)-(c) passing but silently detaches the gate from
3185 // the accessor's typed dispatch — any future
3186 // `is_load_module` / `declared_module` extension (a hole in
3187 // either predicate, a promotion of an additional variant
3188 // onto the `String`-carrying axis, an operator-side
3189 // pre-parsed caixa-name cache the accessor materializes)
3190 // would then silently disagree between this gate's raw
3191 // pattern-match and the peer per-`UpgradeInstruction`
3192 // consumers that route through the accessor pair.
3193
3194 // (a) LoadModule projection byte-equal via
3195 // is_load_module() + declared_module().
3196 let lm = UpgradeInstruction::LoadModule {
3197 module: "hello-rio".into(),
3198 };
3199 assert!(
3200 lm.is_load_module(),
3201 "LoadModule must satisfy is_load_module() — the gate's \
3202 load-family arm-discriminator relies on this partition"
3203 );
3204 assert_eq!(
3205 lm.declared_module(),
3206 Some("hello-rio"),
3207 "declared_module() must project the LoadModule :module \
3208 byte-equal to the raw field access — accessor divergence \
3209 would silently detach the gate from the projection every \
3210 peer per-`UpgradeInstruction` consumer routes through"
3211 );
3212
3213 // (b) Duplicate-LoadModule input trips the gate.
3214 let dup = entry(
3215 "0.1.0",
3216 vec![
3217 UpgradeInstruction::LoadModule { module: "x".into() },
3218 UpgradeInstruction::LoadModule { module: "x".into() },
3219 ],
3220 );
3221 assert_eq!(
3222 dup.validate_load_singularity(),
3223 Err(UpgradeError::DuplicateLoadModule {
3224 from: "0.1.0".into(),
3225 module: "x".into(),
3226 }),
3227 "duplicate LoadModule modules within one entry must fire \
3228 DuplicateLoadModule byte-identical to the pre-lift \
3229 pattern-match shape"
3230 );
3231
3232 // (c) Non-LoadModule-only input leaves the gate vacuous.
3233 let no_load = entry(
3234 "0.1.0",
3235 vec![
3236 UpgradeInstruction::StateChange {
3237 script: PathBuf::from("lib/m.lisp"),
3238 },
3239 UpgradeInstruction::Restart,
3240 ],
3241 );
3242 assert_eq!(
3243 no_load.validate_load_singularity(),
3244 Ok(()),
3245 "non-LoadModule-only entries must leave the load-\
3246 singularity gate vacuous — the `!is_load_module()` \
3247 continue fall-through pins"
3248 );
3249 }
3250
3251 #[test]
3252 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
3253 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3254 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
3255 // predicate: [`UpgradeInstruction::LoadModule`] is the only
3256 // variant that satisfies `.is_load_module()`; every cleanup arm
3257 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
3258 // and the terminal-fallback arm (`Restart`) all return `false`.
3259 // This pin makes the partition invariant load-bearing at
3260 // caixa-core test time so a future derive regression (a hole
3261 // that returns `false` for `LoadModule` too, or a byte-collision
3262 // that flips a second variant to `true`) trips here rather than
3263 // laundering the arm at
3264 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
3265 // dispatch — a hole would silently keep `loaded = false` through
3266 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
3267 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
3268 // a collision would flip `loaded = true` on a well-shaped
3269 // cleanup-only entry and silently swallow the load-less
3270 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
3271 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3272 // and
3273 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
3274 // pins on the paired terminal-fallback and cleanup-family
3275 // arm-discriminator axes — closes the last unlifted `matches!`-
3276 // based arm-discriminator axis on the OTP-appup closed-set
3277 // typed enum.
3278 let cases: &[(UpgradeInstruction, bool)] = &[
3279 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
3280 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3281 (UpgradeInstruction::Purge { module: "c".into() }, false),
3282 (
3283 UpgradeInstruction::StateChange {
3284 script: PathBuf::from("lib/m.lisp"),
3285 },
3286 false,
3287 ),
3288 (UpgradeInstruction::Restart, false),
3289 ];
3290 for (variant, expected) in cases {
3291 assert_eq!(
3292 variant.is_load_module(),
3293 *expected,
3294 "UpgradeInstruction::{variant:?}.is_load_module() must \
3295 return {expected} (partition invariant on the \
3296 IsVariant-derived arm-discriminator predicate)"
3297 );
3298 }
3299 }
3300
3301 #[test]
3302 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
3303 // Byte-identity pin on the [`Self::validate_purge_ordering`]
3304 // load-family sticky-latch dispatch against the pre-lift
3305 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
3306 // predicate the site previously open-coded. Asserts the two
3307 // projections agree byte-for-byte on every arm of the enum, so
3308 // a future derive regression that flipped the predicate's
3309 // arm-set would surface here at caixa-core test time rather
3310 // than at [`Self::validate_purge_ordering`]'s per-entry
3311 // load-before-cleanup ordering scan far from the derive site.
3312 // Same peer-shape pin the sibling
3313 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3314 // carries on the paired terminal-fallback axis and the
3315 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
3316 // carries on the two-arm cleanup-family axis — the third and
3317 // final byte-identity pin closes the substrate primitive's
3318 // arm-discriminator dispatch discipline on the OTP-appup
3319 // closed-set typed enum.
3320 let cases: Vec<UpgradeInstruction> = vec![
3321 UpgradeInstruction::LoadModule { module: "a".into() },
3322 UpgradeInstruction::SoftPurge { module: "b".into() },
3323 UpgradeInstruction::Purge { module: "c".into() },
3324 UpgradeInstruction::StateChange {
3325 script: PathBuf::from("lib/m.lisp"),
3326 },
3327 UpgradeInstruction::Restart,
3328 ];
3329 for instr in &cases {
3330 let via_predicate = instr.is_load_module();
3331 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
3332 assert_eq!(
3333 via_predicate, via_matches,
3334 "UpgradeInstruction::{instr:?}: is_load_module() must \
3335 byte-equal matches!(_, UpgradeInstruction::LoadModule \
3336 {{ .. }}) — the pre-lift open-coded pattern and the \
3337 IsVariant-derived predicate are the same axis, one \
3338 typed dispatch"
3339 );
3340 }
3341 }
3342
3343 #[test]
3344 fn declared_module_only_for_module_bearing_variants() {
3345 // Pinned partition of the `UpgradeInstruction` closed-set
3346 // variant space against the sibling of the peer
3347 // `declared_path` accessor: every OTP-appup module-bearing
3348 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
3349 // `:module` string byte-for-byte through the lifted
3350 // `declared_module` accessor; every non-module-bearing variant
3351 // (`StateChange` on the peer `:script`-carrying axis;
3352 // `Restart` on the OTP terminal-fallback data-less axis)
3353 // returns `None`. Mirrors the peer
3354 // `declared_path_only_for_state_change` pin — the pair now
3355 // closes both scalar-carrying axes on the enum on one lifted
3356 // `Option<&…>` accessor apiece.
3357 let load = UpgradeInstruction::LoadModule {
3358 module: "hello-rio".into(),
3359 };
3360 assert_eq!(load.declared_module(), Some("hello-rio"));
3361 let soft = UpgradeInstruction::SoftPurge {
3362 module: "hello-rio-old".into(),
3363 };
3364 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
3365 let hard = UpgradeInstruction::Purge {
3366 module: "hello-rio-ancient".into(),
3367 };
3368 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
3369 let mig = UpgradeInstruction::StateChange {
3370 script: PathBuf::from("lib/m.lisp"),
3371 };
3372 assert!(mig.declared_module().is_none());
3373 assert!(UpgradeInstruction::Restart.declared_module().is_none());
3374 }
3375
3376 #[test]
3377 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
3378 // Byte-identity pin on the two-accessor partition: every
3379 // `UpgradeInstruction` variant returns `Some` from *exactly
3380 // one* of {`declared_module`, `declared_path`} (the two
3381 // module-bearing / script-carrying axes) or from *neither*
3382 // (the OTP terminal-fallback `Restart` shape). No variant
3383 // returns `Some` from both — the two axes are disjoint by
3384 // construction, and this pin closes the disjointness at the
3385 // test surface so a future variant that leaks a scalar across
3386 // both axes fails at build time. Mirrors the peer
3387 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
3388 // discipline on the `BehaviorSpec` per-slot family.
3389 let cases: Vec<UpgradeInstruction> = vec![
3390 UpgradeInstruction::LoadModule { module: "a".into() },
3391 UpgradeInstruction::SoftPurge { module: "b".into() },
3392 UpgradeInstruction::Purge { module: "c".into() },
3393 UpgradeInstruction::StateChange {
3394 script: PathBuf::from("lib/m.lisp"),
3395 },
3396 UpgradeInstruction::Restart,
3397 ];
3398 for instr in &cases {
3399 let has_module = instr.declared_module().is_some();
3400 let has_path = instr.declared_path().is_some();
3401 assert!(
3402 !(has_module && has_path),
3403 "no variant may declare both a module and a path — offending: {instr:?}"
3404 );
3405 match instr {
3406 UpgradeInstruction::LoadModule { .. }
3407 | UpgradeInstruction::SoftPurge { .. }
3408 | UpgradeInstruction::Purge { .. } => {
3409 assert!(has_module && !has_path, "module axis: {instr:?}");
3410 }
3411 UpgradeInstruction::StateChange { .. } => {
3412 assert!(!has_module && has_path, "script axis: {instr:?}");
3413 }
3414 UpgradeInstruction::Restart => {
3415 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
3416 }
3417 }
3418 }
3419 }
3420
3421 #[test]
3422 fn entry_with_chain_of_versions() {
3423 // Middle entry pairs a `:load-module` with the trailing
3424 // `:soft-purge` so it satisfies the within-entry purge-ordering
3425 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
3426 // preceding `:load-module`, mirroring the state-change-ordering
3427 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
3428 // test is *cross-entry* `:from` values; the within-entry shape
3429 // is incidental — keeping it canonical (`:load-module` before
3430 // `:soft-purge`) leaves the chain assertion load-bearing.
3431 let entries = vec![
3432 entry(
3433 "0.1.0",
3434 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3435 ),
3436 entry(
3437 "0.1.5",
3438 vec![
3439 UpgradeInstruction::LoadModule { module: "x".into() },
3440 UpgradeInstruction::SoftPurge {
3441 module: "x-old".into(),
3442 },
3443 ],
3444 ),
3445 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
3446 ];
3447 for e in &entries {
3448 e.validate().unwrap();
3449 }
3450 let json = serde_json::to_string(&entries).unwrap();
3451 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
3452 assert_eq!(entries, back);
3453 }
3454
3455 #[test]
3456 fn empty_instructions_list_is_valid() {
3457 let e = entry("0.1.0", vec![]);
3458 e.validate().unwrap();
3459 }
3460
3461 #[test]
3462 fn json_uses_kebab_case_kind_tags() {
3463 let i = UpgradeInstruction::SoftPurge {
3464 module: "x-old".into(),
3465 };
3466 let json = serde_json::to_string(&i).unwrap();
3467 assert!(json.contains("\"kind\":\"soft-purge\""));
3468 let i2 = UpgradeInstruction::StateChange {
3469 script: PathBuf::from("m.lisp"),
3470 };
3471 let json2 = serde_json::to_string(&i2).unwrap();
3472 assert!(json2.contains("\"kind\":\"state-change\""));
3473 }
3474
3475 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
3476
3477 #[test]
3478 fn validate_upgrade_from_accepts_disjoint_versions() {
3479 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
3480 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
3481 // (and `entry_with_chain_of_versions` above) passes the cross-
3482 // entry gate. Different `:from` per entry is the intended
3483 // shape; the gate must not regress this baseline. Middle entry
3484 // pairs `:load-module` with `:soft-purge` to satisfy the
3485 // within-entry purge-ordering gate (see
3486 // `entry_with_chain_of_versions` for the same shape).
3487 let entries = vec![
3488 entry(
3489 "0.1.0",
3490 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3491 ),
3492 entry(
3493 "0.1.5",
3494 vec![
3495 UpgradeInstruction::LoadModule { module: "x".into() },
3496 UpgradeInstruction::SoftPurge {
3497 module: "x-old".into(),
3498 },
3499 ],
3500 ),
3501 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
3502 ];
3503 validate_upgrade_from(&entries).unwrap();
3504 }
3505
3506 #[test]
3507 fn validate_upgrade_from_accepts_empty_list() {
3508 // Absent `:upgrade-from` (the bare `feira init` shape) — the
3509 // gate must trivially pass an empty list. Mirrors the per-axis
3510 // "empty list passes" positive control on every peer typed-
3511 // graph gate (`validate_membros` empty list, `validate_placement`
3512 // requires non-empty clusters but only after a `Placement`
3513 // exists, etc.).
3514 validate_upgrade_from(&[]).unwrap();
3515 }
3516
3517 #[test]
3518 fn validate_upgrade_from_rejects_duplicate_from() {
3519 // Fail-before-pass-after pin: two entries with the same parsed-
3520 // semver `:from` are an ambiguous edge in the typed upgrade
3521 // graph (OTP appup picks at most one matching block per running
3522 // version; with two matching blocks the operator picks either
3523 // set non-deterministically — author intent is one path per
3524 // prior version). Same set-not-multiset discipline as
3525 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
3526 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
3527 // `:entrada :paths` (eb3456d) — now extended onto the fifth
3528 // typed-graph axis.
3529 let entries = vec![
3530 entry(
3531 "0.1.0",
3532 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3533 ),
3534 entry(
3535 "0.1.0",
3536 vec![
3537 UpgradeInstruction::LoadModule { module: "x".into() },
3538 UpgradeInstruction::SoftPurge {
3539 module: "x-old".into(),
3540 },
3541 ],
3542 ),
3543 ];
3544 let err = validate_upgrade_from(&entries).unwrap_err();
3545 assert_eq!(
3546 err,
3547 UpgradeError::DuplicateFrom {
3548 from: "0.1.0".into()
3549 },
3550 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
3551 offending value verbatim"
3552 );
3553 }
3554
3555 #[test]
3556 fn validate_upgrade_from_treats_pre_release_as_distinct() {
3557 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
3558 // equal under semver (pre-release version is part of the
3559 // identity), so they're distinct upgrade paths and must not
3560 // collide. A future tightening that collapses pre-release into
3561 // the release version surfaces here.
3562 let entries = vec![
3563 entry("1.0.0", vec![UpgradeInstruction::Restart]),
3564 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
3565 ];
3566 validate_upgrade_from(&entries).unwrap();
3567 }
3568
3569 #[test]
3570 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
3571 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
3572 // compares build metadata (it derives equality across all
3573 // fields including `pre` + `build`), so `1.0.0+build1` and
3574 // `1.0.0+build2` are *not* duplicates from the gate's
3575 // perspective — the operator may treat the build-metadata
3576 // suffix as a tiebreaker even though the semver spec says
3577 // build metadata is ignored for precedence
3578 // (https://semver.org/#spec-item-10). Pin the conservative
3579 // behavior here so a future switch to a build-metadata-
3580 // stripping comparator surfaces as a test failure first; that
3581 // change would require coordinating with the wasm-operator's
3582 // `:from`-match dispatch step, which is the load-bearing
3583 // semantic we'd be mirroring.
3584 let entries = vec![
3585 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
3586 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
3587 ];
3588 validate_upgrade_from(&entries).unwrap();
3589 }
3590
3591 #[test]
3592 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
3593 // Order pin: a malformed `:from` on the second entry surfaces
3594 // its `FromInvalid` diagnostic, not a (less-useful)
3595 // `DuplicateFrom`. The per-entry shape pass runs *inline*
3596 // before the duplicate-key insert — parallel to
3597 // `child_versao_invalid_fires_before_duplicate_check`
3598 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
3599 // (9888b13). Without this pin a future shortcut that runs the
3600 // cross-entry gate first would surface a duplicate diagnostic
3601 // on a string that isn't even parsable as a version.
3602 let entries = vec![
3603 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3604 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
3605 ];
3606 let err = validate_upgrade_from(&entries).unwrap_err();
3607 assert!(
3608 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
3609 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
3610 );
3611 }
3612
3613 #[test]
3614 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
3615 // Symmetric arm: a malformed shape on the *first* entry of a
3616 // duplicate pair surfaces its per-entry diagnostic too (not
3617 // the duplicate diagnostic that would otherwise fire on the
3618 // second entry). Pinned separately so a future shortcut that
3619 // walks the duplicate-check ahead of the per-entry pass for the
3620 // first entry only — easy regression to introduce — surfaces
3621 // here.
3622 let entries = vec![
3623 entry(
3624 "0.1.0",
3625 vec![UpgradeInstruction::LoadModule {
3626 module: String::new(),
3627 }],
3628 ),
3629 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3630 ];
3631 let err = validate_upgrade_from(&entries).unwrap_err();
3632 assert_eq!(
3633 err,
3634 UpgradeError::ModuleEmpty {
3635 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3636 },
3637 "malformed instruction on the first entry of a duplicate pair must surface its \
3638 per-entry diagnostic before the duplicate gate fires, got {err:?}"
3639 );
3640 }
3641
3642 #[test]
3643 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
3644 // Diagnostic-shape pin: when three entries carry the same
3645 // `:from`, the gate reports the *first* collision (the second
3646 // entry) and stops — the third entry's duplicate is masked by
3647 // the first surfaced one. Mirrors
3648 // `validate_duplicate_child_diagnostic_names_first_collision`
3649 // (dbf50a9) on the supervisor axis.
3650 let entries = vec![
3651 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3652 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3653 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3654 ];
3655 let err = validate_upgrade_from(&entries).unwrap_err();
3656 assert_eq!(
3657 err,
3658 UpgradeError::DuplicateFrom {
3659 from: "0.1.0".into()
3660 }
3661 );
3662 }
3663
3664 #[test]
3665 fn validate_upgrade_from_single_entry_never_duplicates() {
3666 // Boundary control: a list of one entry can never produce a
3667 // duplicate, regardless of `:from` value (any single-element
3668 // set is trivially without duplicates). Pin this so a future
3669 // off-by-one in the seen-set insert doesn't accidentally flag
3670 // a single entry as duplicating itself.
3671 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
3672 validate_upgrade_from(&entries).unwrap();
3673 }
3674
3675 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
3676
3677 #[test]
3678 fn versao_gate_accepts_strict_upgrade() {
3679 // Positive control: the canonical "chain prior versions →
3680 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
3681 // `:from` strictly less than the current `:versao` under
3682 // SemVer-2 precedence. The gate must not regress this baseline.
3683 let entries = vec![
3684 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3685 entry("0.1.5", vec![UpgradeInstruction::Restart]),
3686 entry("0.1.9", vec![UpgradeInstruction::Restart]),
3687 ];
3688 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
3689 }
3690
3691 #[test]
3692 fn versao_gate_accepts_empty_entries() {
3693 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
3694 // the gate is a no-op when the entries list is empty. Mirrors
3695 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
3696 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
3697 }
3698
3699 #[test]
3700 fn versao_gate_rejects_equal_from() {
3701 // Self-upgrade no-op: declaring `:from "0.2.0"` while
3702 // `:versao "0.2.0"` means "upgrade from myself to myself" —
3703 // the operator's dispatch either skips silently or
3704 // trivially "succeeds" with no observable state change.
3705 // Reject as the canonical "I forgot to bump :versao when
3706 // adding this entry" footgun.
3707 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
3708 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3709 assert_eq!(
3710 err,
3711 UpgradeError::FromNotBeforeVersao {
3712 from: "0.2.0".into(),
3713 versao: "0.2.0".into(),
3714 },
3715 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
3716 values verbatim, got {err:?}"
3717 );
3718 }
3719
3720 #[test]
3721 fn versao_gate_rejects_downgrade_from() {
3722 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
3723 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
3724 // the operator's `:from`-match dispatch can never reach (it
3725 // never runs a version >= the current one). Reject as the
3726 // canonical "I copy-pasted from the next minor version and
3727 // forgot to bump :versao" footgun.
3728 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
3729 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3730 assert_eq!(
3731 err,
3732 UpgradeError::FromNotBeforeVersao {
3733 from: "0.3.0".into(),
3734 versao: "0.2.0".into(),
3735 }
3736 );
3737 }
3738
3739 #[test]
3740 fn versao_gate_accepts_prerelease_before_release() {
3741 // SemVer §11 precedence: pre-release versions are *less than*
3742 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
3743 // FROM an RC TO the GA release is the canonical authoring
3744 // shape — must pass. A regression that collapses pre-release
3745 // into the release version (treating them as equal) surfaces
3746 // here as a false-positive rejection.
3747 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
3748 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
3749 }
3750
3751 #[test]
3752 fn versao_gate_rejects_release_after_prerelease() {
3753 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
3754 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
3755 // the typical "I'm on an RC of a release that already
3756 // shipped" footgun. The gate names both values verbatim
3757 // so the author can grep for either side and fix in one
3758 // edit.
3759 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
3760 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
3761 assert_eq!(
3762 err,
3763 UpgradeError::FromNotBeforeVersao {
3764 from: "0.2.0".into(),
3765 versao: "0.2.0-rc.1".into(),
3766 }
3767 );
3768 }
3769
3770 #[test]
3771 fn versao_gate_rejects_build_metadata_only_difference() {
3772 // SemVer §11 explicitly excludes build metadata from
3773 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
3774 // *equal* under [`semver::Version::cmp`]. From the
3775 // operator's `:from`-match dispatch perspective this is a
3776 // self-upgrade no-op (no semantic transition between the
3777 // two), so the gate rejects it — *unlike* the peer
3778 // duplicate-`:from` gate which uses derived `PartialEq` and
3779 // treats build-metadata variants as distinct dispatch keys.
3780 // The two gates' different equality notions are deliberate:
3781 // duplicate-check is conservative (preserves operator-side
3782 // tiebreaking surface), precedence-check is permissive
3783 // (matches operator-side dispatch semantic).
3784 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
3785 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3786 assert_eq!(
3787 err,
3788 UpgradeError::FromNotBeforeVersao {
3789 from: "0.2.0+build.1".into(),
3790 versao: "0.2.0".into(),
3791 }
3792 );
3793 }
3794
3795 #[test]
3796 fn versao_gate_silently_passes_on_unparseable_versao() {
3797 // Defensive arm: a malformed `:versao` (gated by the
3798 // narrower `ManifestError::VersaoInvalid` surface at the
3799 // load-bearing call site) must not regress into a
3800 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
3801 // the precedence error over an unparseable `:versao` would
3802 // mask the more actionable root cause (the author meant to
3803 // type `"0.2.0"`, not `"v0.2.0"`).
3804 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
3805 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
3806 }
3807
3808 #[test]
3809 fn versao_gate_silently_passes_on_unparseable_from() {
3810 // Symmetric defensive arm: a malformed `:from` is gated by
3811 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
3812 // upstream at the LayoutInvariants call site. Surfacing the
3813 // precedence error over an unparseable `:from` from this
3814 // gate alone would mask the narrower `FromInvalid`
3815 // diagnostic that's expected to lead — same fall-through
3816 // posture as the unparseable-`:versao` arm above. The
3817 // wiring in `LayoutInvariants::verify` runs
3818 // `validate_upgrade_from` *before* this gate, so in practice
3819 // an unparseable `:from` surfaces as `FromInvalid` first
3820 // and this gate is never reached on that input.
3821 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
3822 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
3823 }
3824
3825 #[test]
3826 fn versao_gate_reports_first_offending_entry() {
3827 // Determinism pin: with multiple offending entries the gate
3828 // surfaces the *first* one in declaration order — same
3829 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
3830 // on the peer gate. Walks the entries in order; first
3831 // failing `:from >= :versao` short-circuits.
3832 let entries = vec![
3833 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3834 entry("0.3.0", vec![UpgradeInstruction::Restart]),
3835 entry("0.4.0", vec![UpgradeInstruction::Restart]),
3836 ];
3837 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3838 assert_eq!(
3839 err,
3840 UpgradeError::FromNotBeforeVersao {
3841 from: "0.3.0".into(),
3842 versao: "0.2.0".into(),
3843 },
3844 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
3845 );
3846 }
3847
3848 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
3849
3850 #[test]
3851 fn validate_rejects_restart_mixed_with_load_module() {
3852 // The "I'll try the typed path *then* restart anyway" footgun:
3853 // an instructions list with `(:restart)` plus `(:load-module …)`
3854 // is dead code in both directions (succeed → restart discards
3855 // the work that just succeeded, defeating the typed sequence's
3856 // whole point; fail → restart never reached because the entry
3857 // already failed). The gate names the offending entry's `:from`
3858 // verbatim plus the kebab-case lisp-form of every non-`:restart`
3859 // peer so the author can grep their caixa.lisp for either side
3860 // and fix in one edit.
3861 let e = entry(
3862 "0.1.0",
3863 vec![
3864 UpgradeInstruction::LoadModule {
3865 module: "hello-rio".into(),
3866 },
3867 UpgradeInstruction::Restart,
3868 ],
3869 );
3870 let err = e.validate().unwrap_err();
3871 assert_eq!(
3872 err,
3873 UpgradeError::RestartNotExclusive {
3874 from: "0.1.0".into(),
3875 restart_count: 1,
3876 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
3877 },
3878 "restart + load-module mix must surface as RestartNotExclusive naming the \
3879 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
3880 );
3881 }
3882
3883 #[test]
3884 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
3885 // Sweep the typed-sequence universe — every non-`:restart`
3886 // variant alongside `:restart` — and assert every typed
3887 // instruction's lisp-form appears in `other_kinds` in
3888 // declaration order. The author should be able to grep for
3889 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
3890 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
3891 // mapping surfaces here.
3892 let e = entry(
3893 "0.1.0",
3894 vec![
3895 UpgradeInstruction::LoadModule {
3896 module: "hello-rio".into(),
3897 },
3898 UpgradeInstruction::StateChange {
3899 script: PathBuf::from("lib/m.lisp"),
3900 },
3901 UpgradeInstruction::SoftPurge {
3902 module: "hello-rio-old".into(),
3903 },
3904 UpgradeInstruction::Purge {
3905 module: "hello-rio-old".into(),
3906 },
3907 UpgradeInstruction::Restart,
3908 ],
3909 );
3910 let err = e.validate().unwrap_err();
3911 assert_eq!(
3912 err,
3913 UpgradeError::RestartNotExclusive {
3914 from: "0.1.0".into(),
3915 restart_count: 1,
3916 other_kinds: vec![
3917 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3918 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
3919 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3920 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3921 ],
3922 },
3923 );
3924 }
3925
3926 #[test]
3927 fn validate_rejects_restart_duplicated() {
3928 // `((:restart) (:restart))` — multiple Restart variants in one
3929 // entry. The fallback is a single semantic (restart the pod;
3930 // the new version comes up fresh); repeating it is at best
3931 // redundant, at worst suggests the author thought the second
3932 // would re-trigger after the first. The gate reports
3933 // `restart_count: 2` so the diagnostic surfaces the duplication
3934 // mode unambiguously even when `other_kinds` is empty.
3935 let e = entry(
3936 "0.1.0",
3937 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
3938 );
3939 let err = e.validate().unwrap_err();
3940 assert_eq!(
3941 err,
3942 UpgradeError::RestartNotExclusive {
3943 from: "0.1.0".into(),
3944 restart_count: 2,
3945 other_kinds: vec![],
3946 },
3947 );
3948 }
3949
3950 #[test]
3951 fn validate_accepts_sole_restart() {
3952 // Positive control: the canonical "this prior version's typed
3953 // upgrade is impossible — restart" authoring shape from the
3954 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
3955 // is the entry's whole instructions list and the only valid
3956 // Restart-bearing shape.
3957 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
3958 e.validate().unwrap();
3959 }
3960
3961 #[test]
3962 fn validate_accepts_typed_sequence_without_restart() {
3963 // Positive control: the canonical typed hot-upgrade authoring
3964 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
3965 // `:state-change` → `:soft-purge`. Absent `:restart` is the
3966 // only shape that lets the sequence run to completion under
3967 // the wasm-operator's `:from`-match dispatch. Drift here =
3968 // a future tighten that rejects any canonical typed-only shape
3969 // surfaces as a regression at this gate.
3970 let e = entry(
3971 "0.1.0",
3972 vec![
3973 UpgradeInstruction::LoadModule {
3974 module: "hello-rio".into(),
3975 },
3976 UpgradeInstruction::StateChange {
3977 script: PathBuf::from("lib/m.lisp"),
3978 },
3979 UpgradeInstruction::SoftPurge {
3980 module: "hello-rio-old".into(),
3981 },
3982 ],
3983 );
3984 e.validate().unwrap();
3985 }
3986
3987 // ── within-entry state-change-ordering invariant ───────────────────
3988
3989 #[test]
3990 fn validate_rejects_state_change_without_load() {
3991 // Fail-before-pass-after pin: a `:state-change` migrates state
3992 // into the newly-loaded code (gen_server:code_change/3 analog),
3993 // so an entry that runs it with no preceding `:load-module`
3994 // migrates state into code that was never loaded. The operator
3995 // runs instructions in declared order, so this is a build error,
3996 // not a runtime surprise (CAIXA-SDLC §III).
3997 let e = entry(
3998 "0.1.0",
3999 vec![UpgradeInstruction::StateChange {
4000 script: PathBuf::from("lib/m.lisp"),
4001 }],
4002 );
4003 let err = e.validate().unwrap_err();
4004 assert_eq!(
4005 err,
4006 UpgradeError::StateChangeWithoutPriorLoad {
4007 from: "0.1.0".into(),
4008 script: PathBuf::from("lib/m.lisp"),
4009 },
4010 "a `:state-change` with no preceding `:load-module` must surface as \
4011 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
4012 );
4013 }
4014
4015 #[test]
4016 fn validate_rejects_state_change_before_load() {
4017 // Right-instructions-wrong-order: the load is present but runs
4018 // *after* the migration. Because the operator executes in
4019 // declared order, the migration runs before the new code is
4020 // resident — the same incoherence as the missing-load case.
4021 let e = entry(
4022 "0.1.0",
4023 vec![
4024 UpgradeInstruction::StateChange {
4025 script: PathBuf::from("lib/m.lisp"),
4026 },
4027 UpgradeInstruction::LoadModule {
4028 module: "hello-rio".into(),
4029 },
4030 ],
4031 );
4032 let err = e.validate().unwrap_err();
4033 assert!(
4034 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4035 "a `:state-change` ahead of its `:load-module` must surface as \
4036 StateChangeWithoutPriorLoad, got {err:?}"
4037 );
4038 }
4039
4040 #[test]
4041 fn validate_accepts_state_change_after_load() {
4042 // Positive control: the canonical `(:load-module …)
4043 // (:state-change …)` order validates. The load need not name
4044 // the same module the migration targets (StateChange carries a
4045 // script, not a module ref), so any preceding `:load-module`
4046 // satisfies "new code is resident before its migration runs".
4047 let e = entry(
4048 "0.1.0",
4049 vec![
4050 UpgradeInstruction::LoadModule {
4051 module: "hello-rio".into(),
4052 },
4053 UpgradeInstruction::StateChange {
4054 script: PathBuf::from("lib/m.lisp"),
4055 },
4056 ],
4057 );
4058 e.validate().unwrap();
4059 }
4060
4061 #[test]
4062 fn validate_accepts_multiple_state_changes_after_one_load() {
4063 // A single leading `:load-module` covers every subsequent
4064 // `:state-change` — the `loaded` latch stays set once the new
4065 // code is resident.
4066 let e = entry(
4067 "0.1.0",
4068 vec![
4069 UpgradeInstruction::LoadModule {
4070 module: "hello-rio".into(),
4071 },
4072 UpgradeInstruction::StateChange {
4073 script: PathBuf::from("lib/m1.lisp"),
4074 },
4075 UpgradeInstruction::StateChange {
4076 script: PathBuf::from("lib/m2.lisp"),
4077 },
4078 ],
4079 );
4080 e.validate().unwrap();
4081 }
4082
4083 #[test]
4084 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
4085 {
4086 // Byte-identity pin on the
4087 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
4088 // migrate ordering dispatch against the pre-lift
4089 // `match instr { UpgradeInstruction::LoadModule { .. } =>
4090 // loaded = true, UpgradeInstruction::StateChange { script } if
4091 // !loaded => …, _ => {} }` open-coded pattern-match the site
4092 // previously carried. Asserts the two projections agree
4093 // byte-for-byte on every arm of the enum — the load-family
4094 // arm-discriminator via `is_load_module()` and the migration-
4095 // family `:script` scalar via `declared_path()` — so a future
4096 // derive regression that flipped the predicate's arm-set (a
4097 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
4098 // a byte-collision flipping a second variant to `true`) or an
4099 // accessor extension that promoted an additional variant onto
4100 // the `PathBuf`-carrying axis would trip here at caixa-core
4101 // test time rather than laundering the arm at the gate's
4102 // per-entry ordering scan far from the derive site.
4103 //
4104 // Peer of the sibling
4105 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
4106 // (c9ce91d) pin on the peer within-entry per-instruction-class
4107 // singularity gate's load-family + `String`-carrying dispatch,
4108 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4109 // (580d0f1) pin on the paired load → cleanup ordering gate's
4110 // load-family sticky-latch dispatch, and the
4111 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4112 // pin on the peer within-entry per-instruction-class singularity
4113 // gate's migration-family script-projection dispatch — closes
4114 // the last unlifted `match`-shaped per-arm-hand-rolled load-
4115 // family arm-discriminator + migration-family script-projection
4116 // pair inside `impl UpgradeFromEntry`. The four within-entry
4117 // ordering / singularity gates now share one byte-identity pin
4118 // apiece against their respective substrate-primitive typed
4119 // dispatches on the OTP-appup closed-set enum.
4120 //
4121 // Three-arm projective coverage:
4122 // (a) `LoadModule` satisfies `is_load_module()`, so the
4123 // sticky-latch advances byte-equal to the pre-lift
4124 // `UpgradeInstruction::LoadModule { .. }` arm; every
4125 // other variant leaves the latch untouched;
4126 // (b) a `((:state-change …))`-only entry (no preceding load)
4127 // trips the gate on the first `StateChange` with
4128 // `StateChangeWithoutPriorLoad` carrying the offending
4129 // script verbatim — the migration-family script surfaces
4130 // through `declared_path()` byte-equal to the raw
4131 // `StateChange { script }` pattern-bound field;
4132 // (c) a `((:load-module …) (:state-change …))` entry leaves
4133 // the gate vacuous with `Ok(())` — the `loaded = true`
4134 // latch on the first arm satisfies the `!loaded` guard
4135 // negation on the second, so the `declared_path()`
4136 // `Some(script)` fall-through does not fire — and a
4137 // non-`StateChange`-non-`LoadModule` sequence
4138 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
4139 // the gate vacuous because `declared_path()` is `None`
4140 // on all three of those arms.
4141 //
4142 // Fail-before-pass-after verified locally: swapping the
4143 // production `if instr.is_load_module() { loaded = true; }
4144 // else if !loaded && let Some(script) = instr.declared_path()
4145 // { … }` back to `match instr { UpgradeInstruction::LoadModule
4146 // { .. } => loaded = true, UpgradeInstruction::StateChange
4147 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
4148 // passing but silently detaches the gate from the accessor's
4149 // typed dispatch — any future `is_load_module` / `declared_path`
4150 // extension (a hole in either predicate, a promotion of an
4151 // additional variant onto either axis, an operator-side
4152 // pre-resolved-path cache the accessor materializes) would
4153 // then silently disagree between this gate's raw pattern-match
4154 // and the peer per-`UpgradeInstruction` consumers that route
4155 // through the accessor pair.
4156
4157 // (a) is_load_module() partitions the arm-set byte-equal to
4158 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
4159 // { .. })` and declared_path() surfaces the StateChange
4160 // `:script` byte-equal to the raw field access.
4161 let lm = UpgradeInstruction::LoadModule {
4162 module: "hello-rio".into(),
4163 };
4164 assert!(
4165 lm.is_load_module(),
4166 "LoadModule must satisfy is_load_module() — the gate's \
4167 load-family sticky-latch relies on this partition"
4168 );
4169 assert!(
4170 lm.declared_path().is_none(),
4171 "LoadModule must not carry a declared_path — the gate's \
4172 else-if migration-family arm must not fire on load arms"
4173 );
4174 let sc = UpgradeInstruction::StateChange {
4175 script: PathBuf::from("lib/m.lisp"),
4176 };
4177 assert!(
4178 !sc.is_load_module(),
4179 "StateChange must not satisfy is_load_module() — the gate's \
4180 sticky-latch must not advance on migration arms"
4181 );
4182 assert_eq!(
4183 sc.declared_path().map(std::path::PathBuf::as_path),
4184 Some(PathBuf::from("lib/m.lisp").as_path()),
4185 "declared_path() must project the StateChange :script \
4186 byte-equal to the raw field access — accessor divergence \
4187 would silently detach the gate from the projection every \
4188 peer per-`UpgradeInstruction` consumer routes through"
4189 );
4190
4191 // (b) A `((:state-change …))`-only entry trips
4192 // StateChangeWithoutPriorLoad byte-identical to the
4193 // pre-lift match-pattern shape.
4194 let no_prior_load = entry(
4195 "0.1.0",
4196 vec![UpgradeInstruction::StateChange {
4197 script: PathBuf::from("lib/m.lisp"),
4198 }],
4199 );
4200 assert_eq!(
4201 no_prior_load.validate_state_change_ordering(),
4202 Err(UpgradeError::StateChangeWithoutPriorLoad {
4203 from: "0.1.0".into(),
4204 script: PathBuf::from("lib/m.lisp"),
4205 }),
4206 "a `:state-change` with no preceding `:load-module` must fire \
4207 StateChangeWithoutPriorLoad carrying the offending script \
4208 verbatim through the declared_path() accessor"
4209 );
4210
4211 // (c) `((:load-module …) (:state-change …))` leaves the gate
4212 // vacuous; so does a non-StateChange-non-LoadModule
4213 // sequence (SoftPurge / Purge / Restart alone).
4214 let load_before_migrate = entry(
4215 "0.1.0",
4216 vec![
4217 UpgradeInstruction::LoadModule {
4218 module: "hello-rio".into(),
4219 },
4220 UpgradeInstruction::StateChange {
4221 script: PathBuf::from("lib/m.lisp"),
4222 },
4223 ],
4224 );
4225 assert_eq!(
4226 load_before_migrate.validate_state_change_ordering(),
4227 Ok(()),
4228 "load-before-migrate entries must leave the ordering gate \
4229 vacuous — the `loaded = true` sticky-latch on the first arm \
4230 satisfies the `!loaded` guard negation on the else-if arm"
4231 );
4232 for instr in [
4233 UpgradeInstruction::SoftPurge {
4234 module: "x-old".into(),
4235 },
4236 UpgradeInstruction::Purge {
4237 module: "x-old".into(),
4238 },
4239 UpgradeInstruction::Restart,
4240 ] {
4241 let e = entry("0.1.0", vec![instr.clone()]);
4242 assert_eq!(
4243 e.validate_state_change_ordering(),
4244 Ok(()),
4245 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
4246 leave the ordering gate vacuous — declared_path() is None \
4247 on every non-StateChange arm, so the else-if migration-\
4248 family arm never fires"
4249 );
4250 }
4251 }
4252
4253 #[test]
4254 fn validate_state_change_ordering_fires_after_restart_exclusive() {
4255 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
4256 // shape is *both* state-change-without-load and restart-mixed.
4257 // The more-fundamental `RestartNotExclusive` must win (a valid
4258 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
4259 // entry should reach the ordering gate). Guards the call order
4260 // in `validate` against silent reordering.
4261 let e = entry(
4262 "0.1.0",
4263 vec![
4264 UpgradeInstruction::StateChange {
4265 script: PathBuf::from("lib/m.lisp"),
4266 },
4267 UpgradeInstruction::Restart,
4268 ],
4269 );
4270 let err = e.validate().unwrap_err();
4271 assert!(
4272 matches!(err, UpgradeError::RestartNotExclusive { .. }),
4273 "restart-mixed must surface before the ordering gate, got {err:?}"
4274 );
4275 }
4276
4277 // ── within-entry purge-ordering invariant ──────────────────────────
4278
4279 #[test]
4280 fn validate_rejects_soft_purge_without_load() {
4281 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
4282 // module after the new one is resident (OTP's two-phase code
4283 // load — code:load_module/1 then code:soft_purge/1), so an
4284 // entry that runs it with no preceding `:load-module` drains
4285 // the live module with no replacement. The operator runs
4286 // instructions in declared order, so this is a build error,
4287 // not a runtime surprise (CAIXA-SDLC §III).
4288 let e = entry(
4289 "0.1.0",
4290 vec![UpgradeInstruction::SoftPurge {
4291 module: "x-old".into(),
4292 }],
4293 );
4294 let err = e.validate().unwrap_err();
4295 assert_eq!(
4296 err,
4297 UpgradeError::PurgeWithoutPriorLoad {
4298 from: "0.1.0".into(),
4299 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4300 module: "x-old".into(),
4301 },
4302 "a `:soft-purge` with no preceding `:load-module` must surface as \
4303 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
4304 );
4305 }
4306
4307 #[test]
4308 fn validate_rejects_purge_without_load() {
4309 // Per-arm coverage: `:purge` (immediate discard, no drain) is
4310 // the more catastrophic peer of `:soft-purge`; same gate, same
4311 // shape, kind-tag differs so the author can grep their
4312 // caixa.lisp for the offending `(:purge …)` form.
4313 let e = entry(
4314 "0.1.0",
4315 vec![UpgradeInstruction::Purge {
4316 module: "x-old".into(),
4317 }],
4318 );
4319 let err = e.validate().unwrap_err();
4320 assert_eq!(
4321 err,
4322 UpgradeError::PurgeWithoutPriorLoad {
4323 from: "0.1.0".into(),
4324 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4325 module: "x-old".into(),
4326 },
4327 );
4328 }
4329
4330 #[test]
4331 fn validate_rejects_soft_purge_before_load() {
4332 // Right-instructions-wrong-order: the load is present but runs
4333 // *after* the purge. Because the operator executes in declared
4334 // order, the cleanup drains the old code before the new code
4335 // is resident — same incoherence as the missing-load case,
4336 // leaving a window during which neither version is available.
4337 let e = entry(
4338 "0.1.0",
4339 vec![
4340 UpgradeInstruction::SoftPurge {
4341 module: "x-old".into(),
4342 },
4343 UpgradeInstruction::LoadModule { module: "x".into() },
4344 ],
4345 );
4346 let err = e.validate().unwrap_err();
4347 assert!(
4348 matches!(
4349 err,
4350 UpgradeError::PurgeWithoutPriorLoad {
4351 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4352 ..
4353 }
4354 ),
4355 "a `:soft-purge` ahead of its `:load-module` must surface as \
4356 PurgeWithoutPriorLoad, got {err:?}"
4357 );
4358 }
4359
4360 #[test]
4361 fn validate_rejects_purge_before_load() {
4362 // Symmetric arm on the `:purge` variant — the kind tag
4363 // distinguishes the diagnostic so the author lands on the
4364 // offending form directly.
4365 let e = entry(
4366 "0.1.0",
4367 vec![
4368 UpgradeInstruction::Purge {
4369 module: "x-old".into(),
4370 },
4371 UpgradeInstruction::LoadModule { module: "x".into() },
4372 ],
4373 );
4374 let err = e.validate().unwrap_err();
4375 assert!(
4376 matches!(
4377 err,
4378 UpgradeError::PurgeWithoutPriorLoad {
4379 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4380 ..
4381 }
4382 ),
4383 "a `:purge` ahead of its `:load-module` must surface as \
4384 PurgeWithoutPriorLoad, got {err:?}"
4385 );
4386 }
4387
4388 #[test]
4389 fn validate_accepts_soft_purge_after_load() {
4390 // Positive control: the canonical `(:load-module …)
4391 // (:soft-purge …)` order validates. The load need not name the
4392 // same module the purge targets — the cleanup typically targets
4393 // the *old* module name (e.g. `"x-old"`) and the load brings up
4394 // the *new* one (`"x"`); the gate only requires that *some*
4395 // `:load-module` precedes the purge, so the new code is resident
4396 // before the old one is drained.
4397 let e = entry(
4398 "0.1.0",
4399 vec![
4400 UpgradeInstruction::LoadModule { module: "x".into() },
4401 UpgradeInstruction::SoftPurge {
4402 module: "x-old".into(),
4403 },
4404 ],
4405 );
4406 e.validate().unwrap();
4407 }
4408
4409 #[test]
4410 fn validate_accepts_multiple_purges_after_one_load() {
4411 // A single leading `:load-module` covers every subsequent
4412 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
4413 // the new code is resident. Same shape as
4414 // `validate_accepts_multiple_state_changes_after_one_load` on
4415 // the peer ordering gate.
4416 let e = entry(
4417 "0.1.0",
4418 vec![
4419 UpgradeInstruction::LoadModule { module: "x".into() },
4420 UpgradeInstruction::SoftPurge {
4421 module: "x-old".into(),
4422 },
4423 UpgradeInstruction::Purge {
4424 module: "x-oldest".into(),
4425 },
4426 ],
4427 );
4428 e.validate().unwrap();
4429 }
4430
4431 #[test]
4432 fn validate_purge_ordering_fires_after_state_change_ordering() {
4433 // Diagnostic-precedence pin: an entry like `((:state-change …)
4434 // (:soft-purge …))` is *both* state-change-without-load and
4435 // purge-without-load. The state-change gate must win — it's
4436 // the load-bearing semantic on this ordering contract, and
4437 // surfacing the purge diagnostic first would mask the more-
4438 // fundamental migration-against-stale-code defect. Guards the
4439 // call order in `validate` against silent reordering.
4440 let e = entry(
4441 "0.1.0",
4442 vec![
4443 UpgradeInstruction::StateChange {
4444 script: PathBuf::from("lib/m.lisp"),
4445 },
4446 UpgradeInstruction::SoftPurge {
4447 module: "x-old".into(),
4448 },
4449 ],
4450 );
4451 let err = e.validate().unwrap_err();
4452 assert!(
4453 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4454 "state-change-without-load must surface before purge-without-load, got {err:?}"
4455 );
4456 }
4457
4458 #[test]
4459 fn validate_purge_ordering_fires_after_per_instr_shape() {
4460 // Order pin: a malformed `:module` value on a `:soft-purge` (an
4461 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
4462 // diagnostic *before* the within-entry purge-ordering gate fires.
4463 // The per-instruction shape pass walks the list inline before
4464 // the ordering checks, so the narrower self-locating diagnostic
4465 // surfaces first — mirrors the empty-first cascade on every peer
4466 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
4467 // per_instr_shape` pin on the sibling ordering gate.
4468 let e = entry(
4469 "0.1.0",
4470 vec![UpgradeInstruction::SoftPurge {
4471 module: String::new(),
4472 }],
4473 );
4474 let err = e.validate().unwrap_err();
4475 assert_eq!(
4476 err,
4477 UpgradeError::ModuleEmpty {
4478 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
4479 },
4480 "malformed instruction must surface its kind-tagged diagnostic before the \
4481 purge-ordering gate fires, got {err:?}"
4482 );
4483 }
4484
4485 #[test]
4486 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
4487 // The whole-list entry-point surfaces the per-entry ordering
4488 // error (mirrors
4489 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
4490 // the gate is reachable from the LayoutInvariants call site, not
4491 // only from a direct `entry.validate()`.
4492 let entries = vec![entry(
4493 "0.1.0",
4494 vec![UpgradeInstruction::Purge {
4495 module: "x-old".into(),
4496 }],
4497 )];
4498 let err = validate_upgrade_from(&entries).unwrap_err();
4499 assert!(
4500 matches!(
4501 err,
4502 UpgradeError::PurgeWithoutPriorLoad {
4503 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4504 ..
4505 }
4506 ),
4507 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
4508 );
4509 }
4510
4511 #[test]
4512 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
4513 // The whole-list entry-point surfaces the per-entry ordering
4514 // error (mirrors `validate_restart_exclusive_threads_through_…`):
4515 // the gate is reachable from the LayoutInvariants call site, not
4516 // only from a direct `entry.validate()`.
4517 let entries = vec![entry(
4518 "0.1.0",
4519 vec![UpgradeInstruction::StateChange {
4520 script: PathBuf::from("lib/m.lisp"),
4521 }],
4522 )];
4523 let err = validate_upgrade_from(&entries).unwrap_err();
4524 assert!(
4525 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4526 "validate_upgrade_from must thread the ordering error, got {err:?}"
4527 );
4528 }
4529
4530 // ── within-entry cleanup-singularity invariant ─────────────────────
4531
4532 #[test]
4533 fn validate_rejects_duplicate_soft_purge_for_same_module() {
4534 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
4535 // its target module (code:soft_purge/1 analog); after the
4536 // first the module is gone, so a second `:soft-purge` of the
4537 // same module is at best a no-op and at worst undefined
4538 // (depending on the operator's handling of a non-resident-
4539 // module purge). Author one cleanup per module.
4540 let e = entry(
4541 "0.1.0",
4542 vec![
4543 UpgradeInstruction::LoadModule { module: "x".into() },
4544 UpgradeInstruction::SoftPurge {
4545 module: "x-old".into(),
4546 },
4547 UpgradeInstruction::SoftPurge {
4548 module: "x-old".into(),
4549 },
4550 ],
4551 );
4552 let err = e.validate().unwrap_err();
4553 assert_eq!(
4554 err,
4555 UpgradeError::DuplicateCleanup {
4556 from: "0.1.0".into(),
4557 module: "x-old".into(),
4558 kinds: vec![
4559 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4560 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4561 ],
4562 },
4563 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
4564 module + both kinds in declaration order, got {err:?}"
4565 );
4566 }
4567
4568 #[test]
4569 fn validate_rejects_duplicate_purge_for_same_module() {
4570 // Per-arm coverage: `:purge` (immediate discard, no drain) is
4571 // the more catastrophic peer of `:soft-purge`; same gate, same
4572 // shape, kind-tag distinguishes so the author can grep their
4573 // caixa.lisp for the offending `(:purge …)` form.
4574 let e = entry(
4575 "0.1.0",
4576 vec![
4577 UpgradeInstruction::LoadModule { module: "x".into() },
4578 UpgradeInstruction::Purge {
4579 module: "x-old".into(),
4580 },
4581 UpgradeInstruction::Purge {
4582 module: "x-old".into(),
4583 },
4584 ],
4585 );
4586 let err = e.validate().unwrap_err();
4587 assert_eq!(
4588 err,
4589 UpgradeError::DuplicateCleanup {
4590 from: "0.1.0".into(),
4591 module: "x-old".into(),
4592 kinds: vec![
4593 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4594 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4595 ],
4596 },
4597 );
4598 }
4599
4600 #[test]
4601 fn validate_rejects_soft_purge_then_purge_for_same_module() {
4602 // Soft-then-hard footgun: the author wrote "drain, and if
4603 // drain doesn't clean up, force-discard", but the operator
4604 // runs declared instructions unconditionally — the `:purge`
4605 // fires whether the `:soft-purge` already discarded the
4606 // module or not, so the imagined fallback semantic is
4607 // missing. Fallback on cleanup failure is the operator's
4608 // job, not authored into the entry. Both kinds carry in
4609 // declaration order so the author can grep for either side
4610 // and pick one.
4611 let e = entry(
4612 "0.1.0",
4613 vec![
4614 UpgradeInstruction::LoadModule { module: "x".into() },
4615 UpgradeInstruction::SoftPurge {
4616 module: "x-old".into(),
4617 },
4618 UpgradeInstruction::Purge {
4619 module: "x-old".into(),
4620 },
4621 ],
4622 );
4623 let err = e.validate().unwrap_err();
4624 assert_eq!(
4625 err,
4626 UpgradeError::DuplicateCleanup {
4627 from: "0.1.0".into(),
4628 module: "x-old".into(),
4629 kinds: vec![
4630 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4631 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4632 ],
4633 },
4634 );
4635 }
4636
4637 #[test]
4638 fn validate_rejects_purge_then_soft_purge_for_same_module() {
4639 // Reversed-ordering arm: `:purge` discards immediately; the
4640 // trailing `:soft-purge` has no module to drain. The kinds
4641 // list reflects declaration order so the diagnostic locates
4642 // both forms in the source.
4643 let e = entry(
4644 "0.1.0",
4645 vec![
4646 UpgradeInstruction::LoadModule { module: "x".into() },
4647 UpgradeInstruction::Purge {
4648 module: "x-old".into(),
4649 },
4650 UpgradeInstruction::SoftPurge {
4651 module: "x-old".into(),
4652 },
4653 ],
4654 );
4655 let err = e.validate().unwrap_err();
4656 assert_eq!(
4657 err,
4658 UpgradeError::DuplicateCleanup {
4659 from: "0.1.0".into(),
4660 module: "x-old".into(),
4661 kinds: vec![
4662 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4663 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4664 ],
4665 },
4666 );
4667 }
4668
4669 #[test]
4670 fn validate_accepts_distinct_cleanup_modules() {
4671 // Positive control: `:soft-purge` and `:purge` on *different*
4672 // modules pass the gate. Mirrors
4673 // `validate_accepts_multiple_purges_after_one_load` — the
4674 // cleanup-singularity gate is keyed on (module), not on
4675 // (kind, module) pair, so distinct old-version names render
4676 // distinct cleanup targets and don't collide. Sweep both
4677 // same-class (two `:soft-purge` distinct modules) and cross-
4678 // class (`:soft-purge` then `:purge` distinct modules) so a
4679 // future tighten to a kind-only key (which would over-fire on
4680 // distinct modules) surfaces here.
4681 let two_soft = entry(
4682 "0.1.0",
4683 vec![
4684 UpgradeInstruction::LoadModule { module: "x".into() },
4685 UpgradeInstruction::SoftPurge {
4686 module: "x-old".into(),
4687 },
4688 UpgradeInstruction::SoftPurge {
4689 module: "x-older".into(),
4690 },
4691 ],
4692 );
4693 two_soft.validate().unwrap();
4694 let mixed = entry(
4695 "0.1.0",
4696 vec![
4697 UpgradeInstruction::LoadModule { module: "x".into() },
4698 UpgradeInstruction::SoftPurge {
4699 module: "x-old".into(),
4700 },
4701 UpgradeInstruction::Purge {
4702 module: "x-oldest".into(),
4703 },
4704 ],
4705 );
4706 mixed.validate().unwrap();
4707 }
4708
4709 #[test]
4710 fn validate_accepts_single_cleanup_per_module() {
4711 // Boundary control: a list with exactly one `:soft-purge` and
4712 // one `:purge` (distinct modules, the canonical "drain one,
4713 // hard-discard the other" shape) is the gate's identity
4714 // element. Pin so a future off-by-one in the duplicate-detection
4715 // scan doesn't accidentally flag a single occurrence as
4716 // duplicating itself — mirrors
4717 // `validate_upgrade_from_single_entry_never_duplicates` on
4718 // the peer cross-entry duplicate axis.
4719 let e = entry(
4720 "0.1.0",
4721 vec![
4722 UpgradeInstruction::LoadModule { module: "x".into() },
4723 UpgradeInstruction::SoftPurge {
4724 module: "x-old".into(),
4725 },
4726 UpgradeInstruction::Purge {
4727 module: "y-old".into(),
4728 },
4729 ],
4730 );
4731 e.validate().unwrap();
4732 }
4733
4734 #[test]
4735 fn validate_cleanup_singularity_fires_after_purge_ordering() {
4736 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
4737 // (:soft-purge "x"))` is *both* purge-without-load and
4738 // duplicate-cleanup. The more-fundamental ordering gate must
4739 // win — the missing-load defect is load-bearing (the canonical
4740 // OTP shape requires the new code be resident before any
4741 // cleanup runs), and surfacing the duplicate diagnostic first
4742 // would mask the no-replacement-window defect the ordering
4743 // gate exists to close. Guards the call order in `validate`
4744 // against silent reordering. Same posture as
4745 // `validate_purge_ordering_fires_after_state_change_ordering`
4746 // on the sibling ordering gate.
4747 let e = entry(
4748 "0.1.0",
4749 vec![
4750 UpgradeInstruction::SoftPurge {
4751 module: "x-old".into(),
4752 },
4753 UpgradeInstruction::SoftPurge {
4754 module: "x-old".into(),
4755 },
4756 ],
4757 );
4758 let err = e.validate().unwrap_err();
4759 assert!(
4760 matches!(
4761 err,
4762 UpgradeError::PurgeWithoutPriorLoad {
4763 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4764 ..
4765 }
4766 ),
4767 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
4768 );
4769 }
4770
4771 #[test]
4772 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
4773 // Order pin: a malformed `:module` value on a `:soft-purge`
4774 // (an empty string) surfaces its narrower kind-tagged
4775 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
4776 // singularity gate fires. The per-instruction shape pass walks
4777 // the list inline before the singularity check, so the
4778 // narrower self-locating diagnostic surfaces first — mirrors
4779 // the empty-first cascade on every peer DNS-1123 gate and the
4780 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
4781 // the sibling ordering gate.
4782 //
4783 // Two empty-string `:soft-purge` would *otherwise* duplicate
4784 // (both modules are the same empty string), so this pin
4785 // double-locks the precedence: the per-instr shape gate must
4786 // win on the first malformed instruction before the duplicate
4787 // scan even reaches the second.
4788 let e = entry(
4789 "0.1.0",
4790 vec![
4791 UpgradeInstruction::LoadModule { module: "x".into() },
4792 UpgradeInstruction::SoftPurge {
4793 module: String::new(),
4794 },
4795 UpgradeInstruction::SoftPurge {
4796 module: String::new(),
4797 },
4798 ],
4799 );
4800 let err = e.validate().unwrap_err();
4801 assert_eq!(
4802 err,
4803 UpgradeError::ModuleEmpty {
4804 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
4805 },
4806 "malformed instruction must surface its kind-tagged diagnostic before the \
4807 cleanup-singularity gate fires, got {err:?}"
4808 );
4809 }
4810
4811 #[test]
4812 fn validate_cleanup_singularity_reports_first_collision() {
4813 // Determinism pin: with three cleanups of the same module the
4814 // gate reports the *first* collision (the second occurrence)
4815 // and stops — the third's duplicate is masked by the first
4816 // surfaced one. Mirrors
4817 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4818 // on the peer cross-entry duplicate axis.
4819 let e = entry(
4820 "0.1.0",
4821 vec![
4822 UpgradeInstruction::LoadModule { module: "x".into() },
4823 UpgradeInstruction::SoftPurge {
4824 module: "x-old".into(),
4825 },
4826 UpgradeInstruction::SoftPurge {
4827 module: "x-old".into(),
4828 },
4829 UpgradeInstruction::Purge {
4830 module: "x-old".into(),
4831 },
4832 ],
4833 );
4834 let err = e.validate().unwrap_err();
4835 assert_eq!(
4836 err,
4837 UpgradeError::DuplicateCleanup {
4838 from: "0.1.0".into(),
4839 module: "x-old".into(),
4840 kinds: vec![
4841 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4842 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4843 ],
4844 },
4845 "the first colliding pair must surface, not the later `:purge` collision"
4846 );
4847 }
4848
4849 #[test]
4850 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
4851 // The whole-list entry-point surfaces the per-entry singularity
4852 // error (mirrors
4853 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
4854 // the gate is reachable from the LayoutInvariants call site,
4855 // not only from a direct `entry.validate()`.
4856 let entries = vec![entry(
4857 "0.1.0",
4858 vec![
4859 UpgradeInstruction::LoadModule { module: "x".into() },
4860 UpgradeInstruction::SoftPurge {
4861 module: "x-old".into(),
4862 },
4863 UpgradeInstruction::Purge {
4864 module: "x-old".into(),
4865 },
4866 ],
4867 )];
4868 let err = validate_upgrade_from(&entries).unwrap_err();
4869 assert!(
4870 matches!(err, UpgradeError::DuplicateCleanup { .. }),
4871 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
4872 );
4873 }
4874
4875 #[test]
4876 fn validate_rejects_duplicate_load_module_for_same_module() {
4877 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
4878 // §II.4): each module is loaded exactly once per upgrade entry,
4879 // the operator's dispatch table reads the module name to bind
4880 // the wasm component, and a second `(:load-module "x")` re-reads
4881 // the same module name and re-binds the same component — a
4882 // no-op the second time. systools-generated `.relup` files emit
4883 // at most one `load_module` per module per upgrade step for
4884 // this reason. Author one `(:load-module "x")` per old module.
4885 let e = entry(
4886 "0.1.0",
4887 vec![
4888 UpgradeInstruction::LoadModule { module: "x".into() },
4889 UpgradeInstruction::LoadModule { module: "x".into() },
4890 ],
4891 );
4892 let err = e.validate().unwrap_err();
4893 assert_eq!(
4894 err,
4895 UpgradeError::DuplicateLoadModule {
4896 from: "0.1.0".into(),
4897 module: "x".into(),
4898 },
4899 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
4900 the module, got {err:?}"
4901 );
4902 }
4903
4904 #[test]
4905 fn validate_accepts_distinct_load_modules() {
4906 // Positive control: `:load-module` instructions on *different*
4907 // modules pass the gate. Mirrors
4908 // `validate_accepts_distinct_cleanup_modules` on the sibling
4909 // singularity axis — the load-singularity gate is keyed on
4910 // (module), so distinct module names render distinct load
4911 // targets and don't collide. Sweep both the bare two-load shape
4912 // and the canonical load-pair-with-cleanup shape so a future
4913 // tighten that over-fires on distinct loads surfaces here.
4914 let two_loads = entry(
4915 "0.1.0",
4916 vec![
4917 UpgradeInstruction::LoadModule { module: "x".into() },
4918 UpgradeInstruction::LoadModule { module: "y".into() },
4919 ],
4920 );
4921 two_loads.validate().unwrap();
4922 let with_cleanup = entry(
4923 "0.1.0",
4924 vec![
4925 UpgradeInstruction::LoadModule { module: "x".into() },
4926 UpgradeInstruction::LoadModule { module: "y".into() },
4927 UpgradeInstruction::SoftPurge {
4928 module: "x-old".into(),
4929 },
4930 UpgradeInstruction::SoftPurge {
4931 module: "y-old".into(),
4932 },
4933 ],
4934 );
4935 with_cleanup.validate().unwrap();
4936 }
4937
4938 #[test]
4939 fn validate_accepts_single_load_per_module() {
4940 // Boundary control: a list with exactly one `:load-module`
4941 // followed by the canonical `:state-change` + `:soft-purge`
4942 // sequence (the module-doc OTP shape) is the gate's identity
4943 // element. Pin so a future off-by-one in the duplicate-
4944 // detection scan doesn't accidentally flag a single occurrence
4945 // as duplicating itself — mirrors
4946 // `validate_accepts_single_cleanup_per_module` on the sibling
4947 // singularity axis.
4948 let e = entry(
4949 "0.1.0",
4950 vec![
4951 UpgradeInstruction::LoadModule { module: "x".into() },
4952 UpgradeInstruction::StateChange {
4953 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
4954 },
4955 UpgradeInstruction::SoftPurge {
4956 module: "x-old".into(),
4957 },
4958 ],
4959 );
4960 e.validate().unwrap();
4961 }
4962
4963 #[test]
4964 fn validate_load_singularity_fires_after_state_change_ordering() {
4965 // Diagnostic-precedence pin: an entry like `((:state-change
4966 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
4967 // state-change-without-load and duplicate-load. The more-
4968 // fundamental ordering gate must win — the missing-load defect
4969 // is load-bearing (the migration runs against unloaded code),
4970 // and surfacing the duplicate diagnostic first would mask the
4971 // migrate-into-unloaded-code defect the ordering gate exists
4972 // to close. Guards the call order in `validate` against silent
4973 // reordering. Same posture as
4974 // `validate_cleanup_singularity_fires_after_purge_ordering`
4975 // on the sibling singularity gate.
4976 let e = entry(
4977 "0.1.0",
4978 vec![
4979 UpgradeInstruction::StateChange {
4980 script: PathBuf::from("lib/m.lisp"),
4981 },
4982 UpgradeInstruction::LoadModule { module: "x".into() },
4983 UpgradeInstruction::LoadModule { module: "x".into() },
4984 ],
4985 );
4986 let err = e.validate().unwrap_err();
4987 assert!(
4988 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4989 "state-change-without-load must surface before duplicate-load, got {err:?}"
4990 );
4991 }
4992
4993 #[test]
4994 fn validate_load_singularity_fires_after_purge_ordering() {
4995 // Diagnostic-precedence pin: an entry like `((:soft-purge
4996 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
4997 // purge-without-load and duplicate-load. The more-fundamental
4998 // ordering gate must win — the missing-load defect is load-
4999 // bearing (the cleanup runs against no-replacement-window),
5000 // and surfacing the duplicate diagnostic first would mask the
5001 // drain-to-nothing defect the ordering gate exists to close.
5002 // Sibling of
5003 // `validate_cleanup_singularity_fires_after_purge_ordering` on
5004 // the load-singularity axis.
5005 let e = entry(
5006 "0.1.0",
5007 vec![
5008 UpgradeInstruction::SoftPurge {
5009 module: "x-old".into(),
5010 },
5011 UpgradeInstruction::LoadModule { module: "x".into() },
5012 UpgradeInstruction::LoadModule { module: "x".into() },
5013 ],
5014 );
5015 let err = e.validate().unwrap_err();
5016 assert!(
5017 matches!(
5018 err,
5019 UpgradeError::PurgeWithoutPriorLoad {
5020 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5021 ..
5022 }
5023 ),
5024 "purge-without-load must surface before duplicate-load, got {err:?}"
5025 );
5026 }
5027
5028 #[test]
5029 fn validate_load_singularity_fires_after_per_instr_shape() {
5030 // Order pin: a malformed `:module` value on a `:load-module`
5031 // (an empty string) surfaces its narrower kind-tagged
5032 // `ModuleEmpty` diagnostic *before* the within-entry load-
5033 // singularity gate fires. The per-instruction shape pass walks
5034 // the list inline before the singularity check, so the
5035 // narrower self-locating diagnostic surfaces first — mirrors
5036 // the empty-first cascade on every peer DNS-1123 gate and the
5037 // `validate_cleanup_singularity_fires_after_per_instr_shape`
5038 // pin on the sibling singularity gate.
5039 //
5040 // Two empty-string `:load-module` would *otherwise* duplicate
5041 // (both modules are the same empty string), so this pin
5042 // double-locks the precedence: the per-instr shape gate must
5043 // win on the first malformed instruction before the duplicate
5044 // scan even reaches the second.
5045 let e = entry(
5046 "0.1.0",
5047 vec![
5048 UpgradeInstruction::LoadModule {
5049 module: String::new(),
5050 },
5051 UpgradeInstruction::LoadModule {
5052 module: String::new(),
5053 },
5054 ],
5055 );
5056 let err = e.validate().unwrap_err();
5057 assert_eq!(
5058 err,
5059 UpgradeError::ModuleEmpty {
5060 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5061 },
5062 "malformed instruction must surface its kind-tagged diagnostic before the \
5063 load-singularity gate fires, got {err:?}"
5064 );
5065 }
5066
5067 #[test]
5068 fn validate_load_singularity_fires_before_cleanup_singularity() {
5069 // Diagnostic-precedence pin: an entry that violates *both*
5070 // singularities — duplicate load on "x" *and* duplicate cleanup
5071 // on "y-old" — must surface the load-side diagnostic first.
5072 // The load axis precedes the cleanup axis in the canonical OTP
5073 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
5074 // in [`UpgradeInstruction`] declaration order (LoadModule
5075 // before SoftPurge/Purge), so the load-side singularity is the
5076 // load-bearing diagnostic when both fire — the cleanup-side
5077 // duplicate is meaningless either way without a coherent load.
5078 // Guards the call order in `validate`: `validate_load_singularity`
5079 // runs before `validate_cleanup_singularity`.
5080 let e = entry(
5081 "0.1.0",
5082 vec![
5083 UpgradeInstruction::LoadModule { module: "x".into() },
5084 UpgradeInstruction::LoadModule { module: "x".into() },
5085 UpgradeInstruction::SoftPurge {
5086 module: "y-old".into(),
5087 },
5088 UpgradeInstruction::SoftPurge {
5089 module: "y-old".into(),
5090 },
5091 ],
5092 );
5093 let err = e.validate().unwrap_err();
5094 assert_eq!(
5095 err,
5096 UpgradeError::DuplicateLoadModule {
5097 from: "0.1.0".into(),
5098 module: "x".into(),
5099 },
5100 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
5101 );
5102 }
5103
5104 #[test]
5105 fn validate_load_singularity_reports_first_collision() {
5106 // Determinism pin: with three loads of the same module the gate
5107 // reports the *first* collision (the second occurrence) and
5108 // stops — the third's duplicate is masked by the first surfaced
5109 // one. Mirrors
5110 // `validate_cleanup_singularity_reports_first_collision` on the
5111 // sibling singularity axis and every peer duplicate gate's
5112 // first-collision discipline.
5113 let e = entry(
5114 "0.1.0",
5115 vec![
5116 UpgradeInstruction::LoadModule { module: "x".into() },
5117 UpgradeInstruction::LoadModule { module: "x".into() },
5118 UpgradeInstruction::LoadModule { module: "x".into() },
5119 ],
5120 );
5121 let err = e.validate().unwrap_err();
5122 assert_eq!(
5123 err,
5124 UpgradeError::DuplicateLoadModule {
5125 from: "0.1.0".into(),
5126 module: "x".into(),
5127 },
5128 "the first colliding occurrence must surface, not the later third-load collision"
5129 );
5130 }
5131
5132 #[test]
5133 fn validate_load_singularity_threads_through_validate_upgrade_from() {
5134 // The whole-list entry-point surfaces the per-entry singularity
5135 // error (mirrors
5136 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
5137 // the gate is reachable from the LayoutInvariants call site,
5138 // not only from a direct `entry.validate()`.
5139 let entries = vec![entry(
5140 "0.1.0",
5141 vec![
5142 UpgradeInstruction::LoadModule { module: "x".into() },
5143 UpgradeInstruction::LoadModule { module: "x".into() },
5144 ],
5145 )];
5146 let err = validate_upgrade_from(&entries).unwrap_err();
5147 assert!(
5148 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
5149 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
5150 );
5151 }
5152
5153 // ── within-entry state-change-singularity invariant ────────────────
5154
5155 #[test]
5156 fn validate_rejects_duplicate_state_change_for_same_script() {
5157 // `StateChange` is the `gen_server:code_change/3` analog
5158 // (INSPIRATIONS §II.4): the script folds the prior-version
5159 // state shape into the current-version shape — a one-shot
5160 // transition, not a step that composes with itself. OTP's
5161 // release_handler invokes `code_change/3` exactly once per
5162 // upgrade per gen_server; systools-generated `.relup` files
5163 // emit at most one `code_change` per gen_server per upgrade
5164 // step for this reason. A second `(:state-change "m.lisp")`
5165 // re-runs the same fold on the already-migrated state — at
5166 // best a no-op and at worst silent state corruption from
5167 // double-applied non-idempotent transforms (`add column`,
5168 // `increment counter`, `rename field`). Author one
5169 // `(:state-change "m.lisp")` per migration script per entry.
5170 let e = entry(
5171 "0.1.0",
5172 vec![
5173 UpgradeInstruction::LoadModule { module: "x".into() },
5174 UpgradeInstruction::StateChange {
5175 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5176 },
5177 UpgradeInstruction::StateChange {
5178 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5179 },
5180 ],
5181 );
5182 let err = e.validate().unwrap_err();
5183 assert_eq!(
5184 err,
5185 UpgradeError::DuplicateStateChange {
5186 from: "0.1.0".into(),
5187 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5188 },
5189 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
5190 the script, got {err:?}"
5191 );
5192 }
5193
5194 #[test]
5195 fn validate_accepts_distinct_state_change_scripts() {
5196 // Positive control: `:state-change` instructions on *different*
5197 // scripts pass the gate. Mirrors
5198 // `validate_accepts_distinct_cleanup_modules` /
5199 // `validate_accepts_distinct_load_modules` on the sibling
5200 // singularity axes — the state-change-singularity gate is keyed
5201 // on the script PathBuf, so distinct scripts render distinct
5202 // migration targets and don't collide. Sweep both the bare two-
5203 // migration shape and the canonical load-pair-with-cleanup shape
5204 // so a future tighten that over-fires on distinct scripts
5205 // surfaces here. This positive control is the gate-level peer of
5206 // `validate_accepts_multiple_state_changes_after_one_load` (the
5207 // ordering-gate positive control on distinct scripts), pinned
5208 // here independently so a future refactor that decouples the
5209 // gates can't accidentally drop coverage on either.
5210 let two_migrations = entry(
5211 "0.1.0",
5212 vec![
5213 UpgradeInstruction::LoadModule { module: "x".into() },
5214 UpgradeInstruction::StateChange {
5215 script: PathBuf::from("lib/m1.lisp"),
5216 },
5217 UpgradeInstruction::StateChange {
5218 script: PathBuf::from("lib/m2.lisp"),
5219 },
5220 ],
5221 );
5222 two_migrations.validate().unwrap();
5223 let with_cleanup = entry(
5224 "0.1.0",
5225 vec![
5226 UpgradeInstruction::LoadModule { module: "x".into() },
5227 UpgradeInstruction::StateChange {
5228 script: PathBuf::from("lib/m1.lisp"),
5229 },
5230 UpgradeInstruction::StateChange {
5231 script: PathBuf::from("lib/m2.lisp"),
5232 },
5233 UpgradeInstruction::SoftPurge {
5234 module: "x-old".into(),
5235 },
5236 ],
5237 );
5238 with_cleanup.validate().unwrap();
5239 }
5240
5241 #[test]
5242 fn validate_accepts_single_state_change_per_script() {
5243 // Boundary control: a list with exactly one `:state-change`
5244 // wrapped by the canonical `:load-module` + `:soft-purge`
5245 // sequence (the module-doc OTP shape) is the gate's identity
5246 // element. Pin so a future off-by-one in the duplicate-
5247 // detection scan doesn't accidentally flag a single occurrence
5248 // as duplicating itself — mirrors
5249 // `validate_accepts_single_load_per_module` /
5250 // `validate_accepts_single_cleanup_per_module` on the sibling
5251 // singularity axes.
5252 let e = entry(
5253 "0.1.0",
5254 vec![
5255 UpgradeInstruction::LoadModule { module: "x".into() },
5256 UpgradeInstruction::StateChange {
5257 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5258 },
5259 UpgradeInstruction::SoftPurge {
5260 module: "x-old".into(),
5261 },
5262 ],
5263 );
5264 e.validate().unwrap();
5265 }
5266
5267 #[test]
5268 fn validate_state_change_singularity_fires_after_state_change_ordering() {
5269 // Diagnostic-precedence pin: an entry like `((:state-change
5270 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
5271 // without-load and duplicate-state-change. The more-fundamental
5272 // ordering gate must win — the missing-load defect is load-
5273 // bearing (the migration runs against unloaded code), and
5274 // surfacing the duplicate diagnostic first would mask the
5275 // migrate-into-unloaded-code defect the ordering gate exists to
5276 // close. Guards the call order in `validate` against silent
5277 // reordering. Same posture as
5278 // `validate_load_singularity_fires_after_state_change_ordering`
5279 // on the sibling singularity gate.
5280 //
5281 // Two same-script `:state-change` would *otherwise* duplicate
5282 // (both scripts collide on the very first `:state-change`-
5283 // without-load encountered), so this pin double-locks the
5284 // precedence: the ordering gate must win on the first un-loaded
5285 // `:state-change` before the singularity scan even reaches the
5286 // second.
5287 let e = entry(
5288 "0.1.0",
5289 vec![
5290 UpgradeInstruction::StateChange {
5291 script: PathBuf::from("lib/m.lisp"),
5292 },
5293 UpgradeInstruction::StateChange {
5294 script: PathBuf::from("lib/m.lisp"),
5295 },
5296 ],
5297 );
5298 let err = e.validate().unwrap_err();
5299 assert!(
5300 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5301 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
5302 );
5303 }
5304
5305 #[test]
5306 fn validate_state_change_singularity_fires_after_purge_ordering() {
5307 // Diagnostic-precedence pin: an entry like `((:soft-purge
5308 // "x-old") (:load-module "x") (:state-change "m.lisp")
5309 // (:state-change "m.lisp"))` is *both* purge-without-load and
5310 // duplicate-state-change. The more-fundamental ordering gate
5311 // must win — the missing-load defect (a cleanup that drains the
5312 // only resident version to nothing) is load-bearing, and
5313 // surfacing the duplicate diagnostic first would mask the
5314 // drain-to-nothing defect the ordering gate exists to close.
5315 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
5316 // on the state-change-singularity axis.
5317 let e = entry(
5318 "0.1.0",
5319 vec![
5320 UpgradeInstruction::SoftPurge {
5321 module: "x-old".into(),
5322 },
5323 UpgradeInstruction::LoadModule { module: "x".into() },
5324 UpgradeInstruction::StateChange {
5325 script: PathBuf::from("lib/m.lisp"),
5326 },
5327 UpgradeInstruction::StateChange {
5328 script: PathBuf::from("lib/m.lisp"),
5329 },
5330 ],
5331 );
5332 let err = e.validate().unwrap_err();
5333 assert!(
5334 matches!(
5335 err,
5336 UpgradeError::PurgeWithoutPriorLoad {
5337 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5338 ..
5339 }
5340 ),
5341 "purge-without-load must surface before duplicate-state-change, got {err:?}"
5342 );
5343 }
5344
5345 #[test]
5346 fn validate_state_change_singularity_fires_after_per_instr_shape() {
5347 // Order pin: a malformed `:script` value on a `:state-change`
5348 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
5349 // *before* the within-entry state-change-singularity gate fires.
5350 // The per-instruction shape pass walks the list inline before
5351 // the singularity check, so the narrower self-locating
5352 // diagnostic surfaces first — mirrors the empty-first cascade on
5353 // every peer path-shape gate and the
5354 // `validate_load_singularity_fires_after_per_instr_shape` /
5355 // `validate_cleanup_singularity_fires_after_per_instr_shape`
5356 // pins on the sibling singularity gates.
5357 //
5358 // Two empty-path `:state-change` would *otherwise* duplicate
5359 // (both scripts are the same empty PathBuf), so this pin double-
5360 // locks the precedence: the per-instr shape gate must win on the
5361 // first malformed instruction before the duplicate scan even
5362 // reaches the second.
5363 let e = entry(
5364 "0.1.0",
5365 vec![
5366 UpgradeInstruction::LoadModule { module: "x".into() },
5367 UpgradeInstruction::StateChange {
5368 script: PathBuf::new(),
5369 },
5370 UpgradeInstruction::StateChange {
5371 script: PathBuf::new(),
5372 },
5373 ],
5374 );
5375 let err = e.validate().unwrap_err();
5376 assert_eq!(
5377 err,
5378 UpgradeError::EmptyScript,
5379 "malformed instruction must surface its narrower diagnostic before the \
5380 state-change-singularity gate fires, got {err:?}"
5381 );
5382 }
5383
5384 #[test]
5385 fn validate_state_change_singularity_fires_after_load_singularity() {
5386 // Diagnostic-precedence pin: an entry that violates *both*
5387 // singularities — duplicate load on "x" *and* duplicate
5388 // state-change on "m.lisp" — must surface the load-side
5389 // diagnostic first. The load axis precedes the migration axis
5390 // in the canonical OTP sequence (`code:load_module/1` then
5391 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
5392 // declaration order (LoadModule before StateChange), so the
5393 // load-side singularity is the load-bearing diagnostic when
5394 // both fire — the migration-side duplicate is meaningless
5395 // either way without a coherent load. Guards the call order in
5396 // `validate`: `validate_load_singularity` runs before
5397 // `validate_state_change_singularity`.
5398 let e = entry(
5399 "0.1.0",
5400 vec![
5401 UpgradeInstruction::LoadModule { module: "x".into() },
5402 UpgradeInstruction::LoadModule { module: "x".into() },
5403 UpgradeInstruction::StateChange {
5404 script: PathBuf::from("lib/m.lisp"),
5405 },
5406 UpgradeInstruction::StateChange {
5407 script: PathBuf::from("lib/m.lisp"),
5408 },
5409 ],
5410 );
5411 let err = e.validate().unwrap_err();
5412 assert_eq!(
5413 err,
5414 UpgradeError::DuplicateLoadModule {
5415 from: "0.1.0".into(),
5416 module: "x".into(),
5417 },
5418 "duplicate-load must surface before duplicate-state-change, got {err:?}"
5419 );
5420 }
5421
5422 #[test]
5423 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
5424 // Diagnostic-precedence pin: an entry that violates *both*
5425 // singularities — duplicate state-change on "m.lisp" *and*
5426 // duplicate cleanup on "y-old" — must surface the migration-
5427 // side diagnostic first. The migration axis precedes the
5428 // cleanup axis in the canonical OTP sequence
5429 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
5430 // [`UpgradeInstruction`] declaration order (StateChange before
5431 // SoftPurge/Purge), so the migration-side singularity is the
5432 // load-bearing diagnostic when both fire — the cleanup-side
5433 // duplicate is irrelevant once the migration has corrupted
5434 // state by double-applying. Guards the call order in
5435 // `validate`: `validate_state_change_singularity` runs before
5436 // `validate_cleanup_singularity`.
5437 let e = entry(
5438 "0.1.0",
5439 vec![
5440 UpgradeInstruction::LoadModule { module: "x".into() },
5441 UpgradeInstruction::StateChange {
5442 script: PathBuf::from("lib/m.lisp"),
5443 },
5444 UpgradeInstruction::StateChange {
5445 script: PathBuf::from("lib/m.lisp"),
5446 },
5447 UpgradeInstruction::SoftPurge {
5448 module: "y-old".into(),
5449 },
5450 UpgradeInstruction::SoftPurge {
5451 module: "y-old".into(),
5452 },
5453 ],
5454 );
5455 let err = e.validate().unwrap_err();
5456 assert_eq!(
5457 err,
5458 UpgradeError::DuplicateStateChange {
5459 from: "0.1.0".into(),
5460 script: PathBuf::from("lib/m.lisp"),
5461 },
5462 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
5463 );
5464 }
5465
5466 #[test]
5467 fn validate_state_change_singularity_reports_first_collision() {
5468 // Determinism pin: with three state-changes on the same script
5469 // the gate reports the *first* collision (the second
5470 // occurrence) and stops — the third's duplicate is masked by
5471 // the first surfaced one. Mirrors
5472 // `validate_load_singularity_reports_first_collision` /
5473 // `validate_cleanup_singularity_reports_first_collision` on the
5474 // sibling singularity axes and every peer duplicate gate's
5475 // first-collision discipline.
5476 let e = entry(
5477 "0.1.0",
5478 vec![
5479 UpgradeInstruction::LoadModule { module: "x".into() },
5480 UpgradeInstruction::StateChange {
5481 script: PathBuf::from("lib/m.lisp"),
5482 },
5483 UpgradeInstruction::StateChange {
5484 script: PathBuf::from("lib/m.lisp"),
5485 },
5486 UpgradeInstruction::StateChange {
5487 script: PathBuf::from("lib/m.lisp"),
5488 },
5489 ],
5490 );
5491 let err = e.validate().unwrap_err();
5492 assert_eq!(
5493 err,
5494 UpgradeError::DuplicateStateChange {
5495 from: "0.1.0".into(),
5496 script: PathBuf::from("lib/m.lisp"),
5497 },
5498 "the first colliding occurrence must surface, not the later third-migration collision"
5499 );
5500 }
5501
5502 #[test]
5503 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
5504 // The whole-list entry-point surfaces the per-entry singularity
5505 // error (mirrors
5506 // `validate_load_singularity_threads_through_validate_upgrade_from`
5507 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
5508 // the gate is reachable from the LayoutInvariants call site,
5509 // not only from a direct `entry.validate()`.
5510 let entries = vec![entry(
5511 "0.1.0",
5512 vec![
5513 UpgradeInstruction::LoadModule { module: "x".into() },
5514 UpgradeInstruction::StateChange {
5515 script: PathBuf::from("lib/m.lisp"),
5516 },
5517 UpgradeInstruction::StateChange {
5518 script: PathBuf::from("lib/m.lisp"),
5519 },
5520 ],
5521 )];
5522 let err = validate_upgrade_from(&entries).unwrap_err();
5523 assert!(
5524 matches!(err, UpgradeError::DuplicateStateChange { .. }),
5525 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
5526 );
5527 }
5528
5529 #[test]
5530 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
5531 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
5532 // per-instruction `StateChange`-arm script-path projection must
5533 // route through the sibling lifted
5534 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
5535 // accessor, not the raw
5536 // `match instr { UpgradeInstruction::StateChange { script } =>
5537 // script.as_path(), _ => continue }` open-coded pattern-match
5538 // the gate previously carried.
5539 //
5540 // Structurally: the gate's projection accept-set is the union
5541 // of every [`UpgradeInstruction`] variant for which
5542 // `declared_path().is_some()` — today exactly
5543 // [`UpgradeInstruction::StateChange`] per the sibling
5544 // `declared_path_only_for_state_change` pin, so a
5545 // duplicate-scripts input trips `DuplicateStateChange` and a
5546 // non-`StateChange` input (module-bearing / terminal) leaves
5547 // `seen` empty and the gate returns `Ok(())` byte-identical to
5548 // the pattern-match shape.
5549 //
5550 // Byte-equal today (`declared_path` returns `Some(script)` iff
5551 // `StateChange`, byte-for-byte from the variant's own storage);
5552 // the pin catches any future accessor extension that promotes
5553 // an additional variant onto the `PathBuf`-carrying axis — the
5554 // gate then fires on duplicate scripts from that variant too,
5555 // and the singularity discipline the sibling
5556 // `validate_load_singularity` / `validate_cleanup_singularity`
5557 // gates share on the `String`-carrying axis's per-variant
5558 // consumers extends to the promoted variant by construction.
5559 //
5560 // Peer of the sibling four per-`UpgradeInstruction` consumers
5561 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
5562 // sandbox-path fan-out, the layout-side per-`StateChange`
5563 // script-existence fan-out at
5564 // `caixa-core/src/layout.rs:1017`, the cross-slot
5565 // [`validate_upgrade_from_against_behavior`] gate's per-
5566 // `StateChange` detection loop, the peer
5567 // [`UpgradeInstruction::declared_module`] `String`-axis
5568 // per-variant unifier) — this gate now shares one typed
5569 // dispatch on the substrate primitive's `PathBuf`-carrying
5570 // axis with those consumers, so a future rebrand on the axis
5571 // migrates as a single caixa-core edit rather than a
5572 // coordinated rewrite of five call sites.
5573 //
5574 // Three-arm projective coverage:
5575 // (a) `StateChange` scripts project through `declared_path()`
5576 // byte-equal to the raw `script.as_path()` field access;
5577 // (b) a duplicate-`StateChange` input trips the gate on the
5578 // second occurrence with `DuplicateStateChange` carrying
5579 // the offending script verbatim;
5580 // (c) a non-`StateChange`-only input (`LoadModule` /
5581 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
5582 // vacuous with `Ok(())` — the `declared_path().is_none()`
5583 // arm's `continue` fall-through pins.
5584 //
5585 // Fail-before-pass-after verified locally: swapping the
5586 // production `let Some(script) = instr.declared_path() else {
5587 // continue };` back to `let script = match instr {
5588 // UpgradeInstruction::StateChange { script } =>
5589 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
5590 // passing but silently detaches the gate from the accessor's
5591 // typed dispatch — any future `declared_path` extension
5592 // (promotion of an additional variant onto the axis, an
5593 // operator-side pre-resolved-path cache the accessor
5594 // materializes) would then silently disagree between this
5595 // gate's raw pattern-match and the peer four sibling consumers
5596 // that route through the accessor.
5597 use std::path::PathBuf;
5598
5599 // (a) StateChange projection byte-equal via declared_path.
5600 let sc = UpgradeInstruction::StateChange {
5601 script: PathBuf::from("lib/m.lisp"),
5602 };
5603 assert_eq!(
5604 sc.declared_path().map(std::path::PathBuf::as_path),
5605 Some(PathBuf::from("lib/m.lisp").as_path()),
5606 "declared_path() must project the StateChange :script byte-equal to the raw \
5607 field access — accessor divergence would silently detach the gate from the \
5608 projection every peer per-`UpgradeInstruction` consumer routes through"
5609 );
5610
5611 // (b) Duplicate-StateChange input trips the gate.
5612 let dup = entry(
5613 "0.1.0",
5614 vec![
5615 UpgradeInstruction::LoadModule { module: "x".into() },
5616 UpgradeInstruction::StateChange {
5617 script: PathBuf::from("lib/m.lisp"),
5618 },
5619 UpgradeInstruction::StateChange {
5620 script: PathBuf::from("lib/m.lisp"),
5621 },
5622 ],
5623 );
5624 assert_eq!(
5625 dup.validate_state_change_singularity(),
5626 Err(UpgradeError::DuplicateStateChange {
5627 from: "0.1.0".into(),
5628 script: PathBuf::from("lib/m.lisp"),
5629 }),
5630 "duplicate StateChange scripts must trip the gate on the second occurrence \
5631 through the declared_path accessor's Some(script) arm"
5632 );
5633
5634 // (c) Non-StateChange-only inputs leave the gate vacuous.
5635 for instrs in [
5636 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
5637 vec![
5638 UpgradeInstruction::LoadModule { module: "x".into() },
5639 UpgradeInstruction::SoftPurge {
5640 module: "x-old".into(),
5641 },
5642 ],
5643 vec![
5644 UpgradeInstruction::LoadModule { module: "x".into() },
5645 UpgradeInstruction::Purge {
5646 module: "x-old".into(),
5647 },
5648 ],
5649 vec![UpgradeInstruction::Restart],
5650 ] {
5651 for instr in &instrs {
5652 assert!(
5653 instr.declared_path().is_none(),
5654 "non-StateChange variants must project None through declared_path — \
5655 accessor divergence would let this gate silently fire on a duplicate \
5656 module reference far from any :state-change site"
5657 );
5658 }
5659 let e = entry("0.1.0", instrs);
5660 assert_eq!(
5661 e.validate_state_change_singularity(),
5662 Ok(()),
5663 "the state-change-singularity gate must return Ok(()) on an entry whose \
5664 instructions all project None through declared_path — the accessor's \
5665 continue arm the pattern-match's `_ => continue` previously carried"
5666 );
5667 }
5668 }
5669
5670 // ── within-entry state-change-before-cleanup ordering invariant ──
5671
5672 #[test]
5673 fn validate_rejects_state_change_after_soft_purge() {
5674 // Fail-before-pass-after pin: `:state-change` is the
5675 // gen_server:code_change/3 analog and folds the prior-version
5676 // state shape into the current shape; `:soft-purge` drains the
5677 // prior code. The operator runs instructions in declared order,
5678 // so a `:soft-purge` ahead of a `:state-change` drains the
5679 // prior module before the migration callback runs against the
5680 // state it held — the canonical OTP error mode
5681 // "`code_change/3` invoked on a purged module" the
5682 // release_handler closes by always ordering the migration
5683 // before the cleanup.
5684 let e = entry(
5685 "0.1.0",
5686 vec![
5687 UpgradeInstruction::LoadModule { module: "x".into() },
5688 UpgradeInstruction::SoftPurge {
5689 module: "x-old".into(),
5690 },
5691 UpgradeInstruction::StateChange {
5692 script: PathBuf::from("lib/m.lisp"),
5693 },
5694 ],
5695 );
5696 let err = e.validate().unwrap_err();
5697 assert_eq!(
5698 err,
5699 UpgradeError::StateChangeAfterCleanup {
5700 from: "0.1.0".into(),
5701 script: PathBuf::from("lib/m.lisp"),
5702 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5703 prior_cleanup_module: "x-old".into(),
5704 },
5705 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
5706 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
5707 );
5708 }
5709
5710 #[test]
5711 fn validate_rejects_state_change_after_purge() {
5712 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5713 // the more catastrophic peer of `:soft-purge` on the cleanup
5714 // axis; same gate, same shape, the `prior_cleanup_kind` field
5715 // distinguishes the diagnostic so the author can grep their
5716 // caixa.lisp for the offending `(:purge …)` form.
5717 let e = entry(
5718 "0.1.0",
5719 vec![
5720 UpgradeInstruction::LoadModule { module: "x".into() },
5721 UpgradeInstruction::Purge {
5722 module: "x-old".into(),
5723 },
5724 UpgradeInstruction::StateChange {
5725 script: PathBuf::from("lib/m.lisp"),
5726 },
5727 ],
5728 );
5729 let err = e.validate().unwrap_err();
5730 assert_eq!(
5731 err,
5732 UpgradeError::StateChangeAfterCleanup {
5733 from: "0.1.0".into(),
5734 script: PathBuf::from("lib/m.lisp"),
5735 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5736 prior_cleanup_module: "x-old".into(),
5737 },
5738 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
5739 `prior_cleanup_kind: \":purge\"`, got {err:?}"
5740 );
5741 }
5742
5743 #[test]
5744 fn validate_accepts_state_change_before_cleanup() {
5745 // Positive control: the canonical `(:load-module …)
5746 // (:state-change …) (:soft-purge …)` order validates — the
5747 // exact shape the module doc example and `validate_accepts_
5748 // well_formed` already pin, restated here on the new gate's
5749 // identity element so a future shortcut that runs the
5750 // singularity gates first doesn't silently mask a regression
5751 // here.
5752 let e = entry(
5753 "0.1.0",
5754 vec![
5755 UpgradeInstruction::LoadModule { module: "x".into() },
5756 UpgradeInstruction::StateChange {
5757 script: PathBuf::from("lib/m.lisp"),
5758 },
5759 UpgradeInstruction::SoftPurge {
5760 module: "x-old".into(),
5761 },
5762 ],
5763 );
5764 e.validate().unwrap();
5765 }
5766
5767 #[test]
5768 fn validate_accepts_cleanup_without_state_change() {
5769 // Empty-set identity: an entry that carries no `:state-change`
5770 // at all has nothing to order against the cleanup, so the gate
5771 // passes regardless of how the cleanups are placed (after the
5772 // single required `:load-module`). Mirrors the
5773 // `validate_accepts_multiple_purges_after_one_load` positive
5774 // control on the peer purge-ordering gate; metadata-only
5775 // upgrades with cleanup-but-no-migration land here.
5776 let e = entry(
5777 "0.1.0",
5778 vec![
5779 UpgradeInstruction::LoadModule { module: "x".into() },
5780 UpgradeInstruction::SoftPurge {
5781 module: "x-old".into(),
5782 },
5783 UpgradeInstruction::Purge {
5784 module: "x-oldest".into(),
5785 },
5786 ],
5787 );
5788 e.validate().unwrap();
5789 }
5790
5791 #[test]
5792 fn validate_accepts_state_change_without_cleanup() {
5793 // Empty-set identity on the dual axis: an entry that carries no
5794 // cleanup at all has nothing to order against the state-change,
5795 // so the gate passes — additive-upgrade shapes (load new code,
5796 // migrate state, leave old code resident for in-flight callers
5797 // to drain naturally) land here.
5798 let e = entry(
5799 "0.1.0",
5800 vec![
5801 UpgradeInstruction::LoadModule { module: "x".into() },
5802 UpgradeInstruction::StateChange {
5803 script: PathBuf::from("lib/m.lisp"),
5804 },
5805 ],
5806 );
5807 e.validate().unwrap();
5808 }
5809
5810 #[test]
5811 fn validate_accepts_multiple_state_changes_before_cleanup() {
5812 // Coverage: every state-change must precede every cleanup, not
5813 // just the first. A chain `(load) (sc) (sc) (sp)` is the
5814 // canonical "two distinct migration scripts on a chained
5815 // upgrade" shape (one module's schema *and* another's
5816 // projection per the DuplicateStateChange diagnostic), and
5817 // it must pass when each state-change has distinct script
5818 // paths. Pinned here so a future shortcut that only checks
5819 // the first state-change doesn't silently accept a
5820 // `(load) (sc-1) (sp) (sc-2)` regression.
5821 let e = entry(
5822 "0.1.0",
5823 vec![
5824 UpgradeInstruction::LoadModule { module: "x".into() },
5825 UpgradeInstruction::StateChange {
5826 script: PathBuf::from("lib/m1.lisp"),
5827 },
5828 UpgradeInstruction::StateChange {
5829 script: PathBuf::from("lib/m2.lisp"),
5830 },
5831 UpgradeInstruction::SoftPurge {
5832 module: "x-old".into(),
5833 },
5834 ],
5835 );
5836 e.validate().unwrap();
5837 }
5838
5839 #[test]
5840 fn validate_rejects_state_change_sandwiched_between_cleanups() {
5841 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
5842 // (sp-2)` violates the gate because the state-change runs
5843 // after the first cleanup. The reported `prior_cleanup_*`
5844 // names the *first* cleanup (the load-bearing one), not the
5845 // last — mirrors every peer first-collision diagnostic
5846 // posture on this module (`validate_state_change_ordering`,
5847 // `validate_purge_ordering`, `validate_load_singularity`,
5848 // `validate_state_change_singularity`,
5849 // `validate_cleanup_singularity` all report the first
5850 // colliding instruction, not the last).
5851 let e = entry(
5852 "0.1.0",
5853 vec![
5854 UpgradeInstruction::LoadModule { module: "x".into() },
5855 UpgradeInstruction::SoftPurge {
5856 module: "x-old".into(),
5857 },
5858 UpgradeInstruction::StateChange {
5859 script: PathBuf::from("lib/m.lisp"),
5860 },
5861 UpgradeInstruction::Purge {
5862 module: "y-old".into(),
5863 },
5864 ],
5865 );
5866 let err = e.validate().unwrap_err();
5867 assert_eq!(
5868 err,
5869 UpgradeError::StateChangeAfterCleanup {
5870 from: "0.1.0".into(),
5871 script: PathBuf::from("lib/m.lisp"),
5872 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5873 prior_cleanup_module: "x-old".into(),
5874 },
5875 "the first cleanup the state-change follows must surface (not the trailing one), \
5876 got {err:?}"
5877 );
5878 }
5879
5880 #[test]
5881 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
5882 // Diagnostic-precedence pin: an entry like `((:soft-purge
5883 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
5884 // *both* purge-without-load (the cleanup runs before the
5885 // load) and state-change-after-cleanup (the state-change
5886 // runs after the cleanup). The more-fundamental ordering
5887 // gate must win — the missing-load defect (a cleanup that
5888 // drains the only resident version to nothing) is load-
5889 // bearing, and surfacing the state-change-after-cleanup
5890 // diagnostic first would mask the drain-to-nothing defect
5891 // the peer purge-ordering gate exists to close. Guards the
5892 // call order in `validate` against silent reordering. Same
5893 // posture as `validate_purge_ordering_fires_after_state_
5894 // change_ordering` on the sibling ordering gate.
5895 //
5896 // Pin specifically uses the load-after-cleanup shape (rather
5897 // than load-less) so the state-change-ordering gate (which
5898 // would otherwise fire first on a `((:soft-purge …)
5899 // (:state-change …))` shape with no leading load) is
5900 // sidestepped: with the load present after the cleanup,
5901 // state-change-ordering passes (its `loaded` latch is set
5902 // before the state-change is encountered) but purge-ordering
5903 // still fails (the cleanup precedes the load). That isolates
5904 // the precedence between purge-ordering and this gate
5905 // cleanly.
5906 let e = entry(
5907 "0.1.0",
5908 vec![
5909 UpgradeInstruction::SoftPurge {
5910 module: "x-old".into(),
5911 },
5912 UpgradeInstruction::LoadModule { module: "x".into() },
5913 UpgradeInstruction::StateChange {
5914 script: PathBuf::from("lib/m.lisp"),
5915 },
5916 ],
5917 );
5918 let err = e.validate().unwrap_err();
5919 assert!(
5920 matches!(
5921 err,
5922 UpgradeError::PurgeWithoutPriorLoad {
5923 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5924 ..
5925 }
5926 ),
5927 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
5928 );
5929 }
5930
5931 #[test]
5932 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
5933 // Diagnostic-precedence pin: an entry like `((:state-change
5934 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
5935 // load (because no `:load-module` precedes the state-change)
5936 // but *not* state-change-after-cleanup (the state-change
5937 // precedes the cleanup textually). The state-change-ordering
5938 // gate must surface first regardless — the missing-load
5939 // defect on the migration axis is the load-bearing semantic
5940 // and surfacing a different ordering diagnostic would mask
5941 // the migration-against-stale-code defect. Guards the call
5942 // order in `validate` against silent reordering on a shape
5943 // that fires only the state-change-ordering gate (not this
5944 // one), pinning that the state-change-ordering gate wins
5945 // ahead of this gate's chance to look at the list.
5946 let e = entry(
5947 "0.1.0",
5948 vec![
5949 UpgradeInstruction::StateChange {
5950 script: PathBuf::from("lib/m.lisp"),
5951 },
5952 UpgradeInstruction::SoftPurge {
5953 module: "x-old".into(),
5954 },
5955 ],
5956 );
5957 let err = e.validate().unwrap_err();
5958 assert!(
5959 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5960 "state-change-without-load must surface before purge-without-load (the canonical \
5961 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
5962 );
5963 }
5964
5965 #[test]
5966 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
5967 // Order pin: a malformed `:script` value on a `:state-change`
5968 // (an empty path) surfaces its narrower `EmptyScript`
5969 // diagnostic *before* the within-entry state-change-before-
5970 // cleanup gate fires. The per-instruction shape pass walks
5971 // the list inline before the ordering check, so the narrower
5972 // self-locating diagnostic surfaces first — mirrors the
5973 // empty-first cascade on every peer path-shape gate and the
5974 // `validate_purge_ordering_fires_after_per_instr_shape` pin
5975 // on the sibling ordering gate.
5976 let e = entry(
5977 "0.1.0",
5978 vec![
5979 UpgradeInstruction::LoadModule { module: "x".into() },
5980 UpgradeInstruction::SoftPurge {
5981 module: "x-old".into(),
5982 },
5983 UpgradeInstruction::StateChange {
5984 script: PathBuf::new(),
5985 },
5986 ],
5987 );
5988 let err = e.validate().unwrap_err();
5989 assert_eq!(
5990 err,
5991 UpgradeError::EmptyScript,
5992 "malformed instruction must surface its narrower diagnostic before the \
5993 state-change-before-cleanup gate fires, got {err:?}"
5994 );
5995 }
5996
5997 #[test]
5998 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
5999 // Diagnostic-precedence pin: an entry like `((:load-module
6000 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
6001 // (:state-change "m.lisp"))` violates *both* this ordering
6002 // gate (the first state-change follows the cleanup) and the
6003 // state-change-singularity gate (the same script appears
6004 // twice). The ordering gate must win — the canonical
6005 // "ordering before singularity" precedence the peer
6006 // `validate_state_change_ordering` / `validate_purge_
6007 // ordering` gates already establish over their own singularity
6008 // gates, applied uniformly across the OTP canonical-sequence
6009 // ordering axis here. Guards the call order in `validate`:
6010 // `validate_state_change_before_cleanup` runs before the
6011 // per-instruction-class singularity gates.
6012 let e = entry(
6013 "0.1.0",
6014 vec![
6015 UpgradeInstruction::LoadModule { module: "x".into() },
6016 UpgradeInstruction::SoftPurge {
6017 module: "x-old".into(),
6018 },
6019 UpgradeInstruction::StateChange {
6020 script: PathBuf::from("lib/m.lisp"),
6021 },
6022 UpgradeInstruction::StateChange {
6023 script: PathBuf::from("lib/m.lisp"),
6024 },
6025 ],
6026 );
6027 let err = e.validate().unwrap_err();
6028 assert!(
6029 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6030 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
6031 );
6032 }
6033
6034 #[test]
6035 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
6036 // The whole-list entry-point surfaces the per-entry ordering
6037 // error (mirrors `validate_purge_ordering_threads_through_
6038 // validate_upgrade_from` and every peer wiring pin): the gate
6039 // is reachable from the LayoutInvariants call site, not only
6040 // from a direct `entry.validate()`.
6041 let entries = vec![entry(
6042 "0.1.0",
6043 vec![
6044 UpgradeInstruction::LoadModule { module: "x".into() },
6045 UpgradeInstruction::SoftPurge {
6046 module: "x-old".into(),
6047 },
6048 UpgradeInstruction::StateChange {
6049 script: PathBuf::from("lib/m.lisp"),
6050 },
6051 ],
6052 )];
6053 let err = validate_upgrade_from(&entries).unwrap_err();
6054 assert!(
6055 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6056 "validate_upgrade_from must thread the state-change-before-cleanup error, \
6057 got {err:?}"
6058 );
6059 }
6060
6061 #[test]
6062 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
6063 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
6064 // per-instruction `StateChange`-arm script-path projection must
6065 // route through the sibling lifted
6066 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6067 // accessor, not the raw
6068 // `if let UpgradeInstruction::StateChange { script } = instr`
6069 // open-coded pattern-match the gate previously carried inside
6070 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
6071 //
6072 // Structurally: the gate's projection accept-set is the union
6073 // of every [`UpgradeInstruction`] variant for which
6074 // `declared_path().is_some()` — today exactly
6075 // [`UpgradeInstruction::StateChange`] per the sibling
6076 // `declared_path_only_for_state_change` pin, so a
6077 // state-change-after-cleanup input trips
6078 // `StateChangeAfterCleanup` and a non-`StateChange` input
6079 // (module-bearing / terminal) leaves the sticky-once latch
6080 // sweep quiet byte-identical to the pattern-match shape.
6081 //
6082 // Byte-equal today (`declared_path` returns `Some(script)` iff
6083 // `StateChange`, byte-for-byte from the variant's own storage);
6084 // the pin catches any future accessor extension that promotes
6085 // an additional variant onto the `PathBuf`-carrying axis — the
6086 // gate then fires on migrate-after-cleanup for that variant too,
6087 // and the migrate→cleanup ordering discipline the peer
6088 // [`validate_state_change_singularity`] /
6089 // [`validate_upgrade_from_against_behavior`] gates share on the
6090 // same axis extends to the promoted variant by construction.
6091 //
6092 // Peer of the sibling four per-`UpgradeInstruction` consumers
6093 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6094 // sandbox-path fan-out, the layout-side per-`StateChange`
6095 // script-existence fan-out at
6096 // `caixa-core/src/layout.rs:1058`, the within-entry
6097 // [`UpgradeFromEntry::validate_state_change_singularity`]
6098 // per-`StateChange` script-projection fan-out, the cross-slot
6099 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
6100 // detection loop) — the fifth (and last unlifted inside
6101 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
6102 // the `PathBuf`-carrying axis to now route through the accessor.
6103 // Same shape as the sibling
6104 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
6105 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
6106 // pins extended onto the within-entry migrate→cleanup ordering
6107 // gate.
6108 //
6109 // Three-arm projective coverage:
6110 // (a) `StateChange` scripts project through `declared_path()`
6111 // byte-equal to the raw `script.clone()` field access
6112 // the diagnostic previously carried;
6113 // (b) a `:state-change`-after-cleanup input trips the gate
6114 // with `StateChangeAfterCleanup` carrying the offending
6115 // script + the prior cleanup's kind/module verbatim;
6116 // (c) a non-`StateChange`-only input (`LoadModule` /
6117 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6118 // vacuous with `Ok(())` — the `declared_path().is_none()`
6119 // arm's fall-through pins.
6120 //
6121 // Fail-before-pass-after verified structurally: swapping the
6122 // production
6123 // `else if let Some(script) = instr.declared_path() && … { … }`
6124 // back to
6125 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
6126 // keeps arms (a)-(c) passing but silently detaches this within-
6127 // entry ordering gate from the accessor's typed dispatch — any
6128 // future `declared_path` extension (promotion of an additional
6129 // variant onto the axis, an operator-side pre-resolved-path
6130 // cache the accessor materializes) would then silently disagree
6131 // between this gate's raw pattern-match and the peer four
6132 // sibling consumers that route through the accessor.
6133
6134 // (a) StateChange projection byte-equal via declared_path.
6135 let sc = UpgradeInstruction::StateChange {
6136 script: PathBuf::from("lib/m.lisp"),
6137 };
6138 assert_eq!(
6139 sc.declared_path().cloned(),
6140 Some(PathBuf::from("lib/m.lisp")),
6141 "declared_path() must project the StateChange :script byte-equal to the raw \
6142 field access — accessor divergence would silently detach this within-entry \
6143 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
6144 consumer routes through"
6145 );
6146
6147 // (b) StateChange-after-cleanup trips the gate through the accessor.
6148 let after = entry(
6149 "0.1.0",
6150 vec![
6151 UpgradeInstruction::LoadModule { module: "x".into() },
6152 UpgradeInstruction::SoftPurge {
6153 module: "x-old".into(),
6154 },
6155 UpgradeInstruction::StateChange {
6156 script: PathBuf::from("lib/m.lisp"),
6157 },
6158 ],
6159 );
6160 assert_eq!(
6161 after.validate(),
6162 Err(UpgradeError::StateChangeAfterCleanup {
6163 from: "0.1.0".into(),
6164 script: PathBuf::from("lib/m.lisp"),
6165 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6166 prior_cleanup_module: "x-old".into(),
6167 }),
6168 "a :state-change following a cleanup must trip the gate through the declared_path \
6169 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
6170 kind/module verbatim byte-identical to the pattern-match shape"
6171 );
6172
6173 // (c) Non-StateChange-only inputs leave the gate vacuous.
6174 for instrs in [
6175 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6176 vec![
6177 UpgradeInstruction::LoadModule { module: "x".into() },
6178 UpgradeInstruction::SoftPurge {
6179 module: "x-old".into(),
6180 },
6181 ],
6182 vec![
6183 UpgradeInstruction::LoadModule { module: "x".into() },
6184 UpgradeInstruction::Purge {
6185 module: "x-old".into(),
6186 },
6187 ],
6188 vec![UpgradeInstruction::Restart],
6189 ] {
6190 for instr in &instrs {
6191 assert!(
6192 instr.declared_path().is_none(),
6193 "non-StateChange variants must project None through declared_path — \
6194 accessor divergence would let this within-entry ordering gate silently \
6195 fire on a cleanup-only sequence far from any :state-change site"
6196 );
6197 }
6198 let e = entry("0.1.0", instrs);
6199 assert_eq!(
6200 e.validate(),
6201 Ok(()),
6202 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
6203 instructions all project None through declared_path — the accessor's \
6204 None arm the pattern-match's implicit fall-through previously carried"
6205 );
6206 }
6207 }
6208
6209 #[test]
6210 fn validate_restart_order_independent() {
6211 // Position-agnostic: `(:restart)` leading or trailing the
6212 // mixed sequence surfaces the same RestartNotExclusive shape.
6213 // Mirrors OTP appup's order-insensitive
6214 // `restart_emulator | restart_new_emulator` terminal rule —
6215 // the position of the restart instruction in the script is
6216 // irrelevant; what matters is the script *contains* it
6217 // alongside other instructions at all. The gate must not
6218 // gain a false positive by depending on instruction ordering.
6219 let leading = entry(
6220 "0.1.0",
6221 vec![
6222 UpgradeInstruction::Restart,
6223 UpgradeInstruction::LoadModule { module: "x".into() },
6224 ],
6225 );
6226 let trailing = entry(
6227 "0.1.0",
6228 vec![
6229 UpgradeInstruction::LoadModule { module: "x".into() },
6230 UpgradeInstruction::Restart,
6231 ],
6232 );
6233 let middle = entry(
6234 "0.1.0",
6235 vec![
6236 UpgradeInstruction::LoadModule { module: "a".into() },
6237 UpgradeInstruction::Restart,
6238 UpgradeInstruction::SoftPurge {
6239 module: "a-old".into(),
6240 },
6241 ],
6242 );
6243 for e in [&leading, &trailing, &middle] {
6244 assert!(
6245 matches!(
6246 e.validate().unwrap_err(),
6247 UpgradeError::RestartNotExclusive {
6248 restart_count: 1,
6249 ..
6250 }
6251 ),
6252 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
6253 instruction order, got {:?}",
6254 e.validate()
6255 );
6256 }
6257 }
6258
6259 #[test]
6260 fn validate_restart_exclusive_fires_after_per_instr_shape() {
6261 // Order pin: a malformed `:module` value on a Module-bearing
6262 // instruction (an empty string) surfaces its narrower
6263 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
6264 // entry restart-exclusivity gate fires. The per-instruction
6265 // shape pass walks the list inline before the restart-
6266 // exclusive check, so the narrower self-locating diagnostic
6267 // surfaces first — mirrors the empty-first cascade on every
6268 // peer DNS-1123 gate (`validate_module`,
6269 // `validate_membro_caixa`, `validate_placement_cluster`) and
6270 // the `*_invalid_fires_before_duplicate_check` arm-ordering
6271 // pins on every typed-graph axis. Without this pin a future
6272 // shortcut that runs the restart-exclusive check ahead of
6273 // per-instruction shape would surface a less-actionable
6274 // RestartNotExclusive over an instruction list that's also
6275 // malformed at the per-instruction layer.
6276 let e = entry(
6277 "0.1.0",
6278 vec![
6279 UpgradeInstruction::LoadModule {
6280 module: String::new(),
6281 },
6282 UpgradeInstruction::Restart,
6283 ],
6284 );
6285 let err = e.validate().unwrap_err();
6286 assert_eq!(
6287 err,
6288 UpgradeError::ModuleEmpty {
6289 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
6290 },
6291 "malformed instruction must surface its kind-tagged diagnostic before the \
6292 restart-exclusivity gate fires, got {err:?}"
6293 );
6294 }
6295
6296 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
6297 // Helper for the cross-slot composition gate's pass arm: a
6298 // BehaviorSpec carrying just the `:on-state-change` callback,
6299 // the runtime hook the per-version `(:state-change "…")`
6300 // instruction is delivered through during hot upgrade. Mirrors
6301 // the canonical authoring shape pinned in the module doc.
6302 crate::BehaviorSpec {
6303 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
6304 ..Default::default()
6305 }
6306 }
6307
6308 #[test]
6309 fn behavior_gate_rejects_state_change_without_any_behavior() {
6310 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
6311 // caixa carries no `:behavior` at all surfaces the missing-
6312 // callback diagnostic naming the offending entry's `:from` +
6313 // script. The "I added the upgrade path but never declared
6314 // `:behavior`" footgun: `:behavior` is optional at the typed
6315 // root, the typed `:upgrade-from` slot validates on its own
6316 // merits, and the operator's hot-upgrade dispatch reaches for
6317 // a callback that doesn't exist.
6318 let entries = vec![entry(
6319 "0.1.0",
6320 vec![
6321 UpgradeInstruction::LoadModule { module: "x".into() },
6322 UpgradeInstruction::StateChange {
6323 script: PathBuf::from("lib/m.lisp"),
6324 },
6325 ],
6326 )];
6327 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6328 assert_eq!(
6329 err,
6330 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6331 from: "0.1.0".into(),
6332 script: PathBuf::from("lib/m.lisp"),
6333 },
6334 );
6335 }
6336
6337 #[test]
6338 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
6339 // `:behavior` declared with *other* callbacks set
6340 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
6341 // None still surfaces the missing-callback diagnostic — only
6342 // the `:on-state-change` axis matters for this gate. The
6343 // "I declared `:behavior` but missed the migration callback"
6344 // footgun: a caixa that registers its lifecycle hooks but
6345 // forgets the migration delivery path leaves the
6346 // `:state-change` instruction with no runtime hook to
6347 // dispatch through.
6348 let entries = vec![entry(
6349 "0.1.0",
6350 vec![
6351 UpgradeInstruction::LoadModule { module: "x".into() },
6352 UpgradeInstruction::StateChange {
6353 script: PathBuf::from("lib/m.lisp"),
6354 },
6355 ],
6356 )];
6357 let b = crate::BehaviorSpec {
6358 on_init: Some(PathBuf::from("lib/init.lisp")),
6359 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
6360 ..Default::default()
6361 };
6362 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
6363 assert_eq!(
6364 err,
6365 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6366 from: "0.1.0".into(),
6367 script: PathBuf::from("lib/m.lisp"),
6368 },
6369 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
6370 the missing migration hook"
6371 );
6372 }
6373
6374 #[test]
6375 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
6376 // The canonical composition shape: a per-version
6377 // `(:state-change "lib/m.lisp")` instruction paired with the
6378 // `:behavior :on-state-change "lib/migrations.lisp"` callback
6379 // it is delivered through at hot-upgrade time. Pins the gate's
6380 // pass arm — drift here = a future tighten that rejects the
6381 // canonical OTP-shape composition surfaces as a regression at
6382 // this positive-control pin.
6383 let entries = vec![entry(
6384 "0.1.0",
6385 vec![
6386 UpgradeInstruction::LoadModule { module: "x".into() },
6387 UpgradeInstruction::StateChange {
6388 script: PathBuf::from("lib/m.lisp"),
6389 },
6390 ],
6391 )];
6392 let b = behavior_with_state_change_callback();
6393 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6394 }
6395
6396 #[test]
6397 fn behavior_gate_accepts_entries_without_any_state_change() {
6398 // Empty-set identity: entries carrying no `:state-change`
6399 // instruction at all (load + cleanup only — the metadata-only
6400 // upgrade shape the module doc names, "On any failure, the
6401 // current version stays load-bearing — a typed atomic
6402 // upgrade") leave the gate vacuous. The composition only
6403 // requires a callback when the per-version script exists; a
6404 // load + cleanup pair has no migration to deliver, so the
6405 // absence of `:on-state-change` is coherent.
6406 let entries = vec![entry(
6407 "0.1.0",
6408 vec![
6409 UpgradeInstruction::LoadModule { module: "x".into() },
6410 UpgradeInstruction::SoftPurge {
6411 module: "x-old".into(),
6412 },
6413 ],
6414 )];
6415 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6416 }
6417
6418 #[test]
6419 fn behavior_gate_accepts_restart_only_entry() {
6420 // The terminal-fallback `((:restart))` shape carries no
6421 // `:state-change` — the operator restarts the pod and the
6422 // new version comes up fresh against its initial state, no
6423 // migration. Pinned alongside the metadata-only positive
6424 // control above as the second empty-state-change shape.
6425 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
6426 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6427 }
6428
6429 #[test]
6430 fn behavior_gate_accepts_empty_entries_list() {
6431 // Empty `:upgrade-from` (a caixa with no declared upgrade
6432 // paths — the v0.1.0 caixa before any upgrade entries are
6433 // added) trivially passes the gate. Pinned so the gate
6434 // doesn't accidentally fire on a caixa that hasn't yet
6435 // declared any upgrades.
6436 let entries: Vec<UpgradeFromEntry> = vec![];
6437 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6438 }
6439
6440 #[test]
6441 fn behavior_gate_reports_first_state_change_in_first_entry() {
6442 // First-collision determinism: with multiple `:state-change`
6443 // instructions across multiple entries, the gate reports the
6444 // *first* one encountered in declaration order — the entry's
6445 // declaration order first, then the within-entry instruction
6446 // order. Mirrors every peer first-collision diagnostic posture
6447 // on this module (`validate_state_change_ordering`,
6448 // `validate_purge_ordering`, the singularity gates), so a
6449 // future shortcut that walks the list in reverse or returns
6450 // the last collision surfaces as a regression here.
6451 let entries = vec![
6452 entry(
6453 "0.1.0",
6454 vec![
6455 UpgradeInstruction::LoadModule { module: "x".into() },
6456 UpgradeInstruction::StateChange {
6457 script: PathBuf::from("lib/m1.lisp"),
6458 },
6459 UpgradeInstruction::StateChange {
6460 script: PathBuf::from("lib/m2.lisp"),
6461 },
6462 ],
6463 ),
6464 entry(
6465 "0.1.5",
6466 vec![
6467 UpgradeInstruction::LoadModule { module: "x".into() },
6468 UpgradeInstruction::StateChange {
6469 script: PathBuf::from("lib/m3.lisp"),
6470 },
6471 ],
6472 ),
6473 ];
6474 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6475 assert_eq!(
6476 err,
6477 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6478 from: "0.1.0".into(),
6479 script: PathBuf::from("lib/m1.lisp"),
6480 },
6481 "the first :state-change in the first entry must surface, not later collisions"
6482 );
6483 }
6484
6485 #[test]
6486 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
6487 // Cross-entry pin: a first entry with no `:state-change` (just
6488 // a load + cleanup) leaves the gate's per-entry walk continuing
6489 // to the second entry, where the offending instruction lives.
6490 // The diagnostic names the *second* entry's `:from` because
6491 // that's where the missing-callback shape is exposed — pinned
6492 // so a shortcut that bails on the first entry without a
6493 // `:state-change` (rather than continuing) doesn't mask the
6494 // defect in a later entry.
6495 let entries = vec![
6496 entry(
6497 "0.1.0",
6498 vec![
6499 UpgradeInstruction::LoadModule { module: "x".into() },
6500 UpgradeInstruction::SoftPurge {
6501 module: "x-old".into(),
6502 },
6503 ],
6504 ),
6505 entry(
6506 "0.1.5",
6507 vec![
6508 UpgradeInstruction::LoadModule { module: "x".into() },
6509 UpgradeInstruction::StateChange {
6510 script: PathBuf::from("lib/m.lisp"),
6511 },
6512 ],
6513 ),
6514 ];
6515 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6516 assert_eq!(
6517 err,
6518 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6519 from: "0.1.5".into(),
6520 script: PathBuf::from("lib/m.lisp"),
6521 },
6522 "the offending entry's `:from` must surface even when an earlier entry carries no \
6523 :state-change"
6524 );
6525 }
6526
6527 #[test]
6528 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
6529 // Positive control: a multi-entry `:upgrade-from` (chained
6530 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
6531 // a `:state-change` passes when the callback is declared once
6532 // at the caixa root. The callback is a single per-caixa
6533 // runtime hook; one declaration covers every entry's
6534 // `:state-change`, mirroring OTP's
6535 // `release_handler:install_release/1` which dispatches every
6536 // appup's `code_change` instruction through the single
6537 // `gen_server:code_change/3` callback registered on the
6538 // module.
6539 let entries = vec![
6540 entry(
6541 "0.1.0",
6542 vec![
6543 UpgradeInstruction::LoadModule { module: "x".into() },
6544 UpgradeInstruction::StateChange {
6545 script: PathBuf::from("lib/m1.lisp"),
6546 },
6547 ],
6548 ),
6549 entry(
6550 "0.1.5",
6551 vec![
6552 UpgradeInstruction::LoadModule { module: "x".into() },
6553 UpgradeInstruction::StateChange {
6554 script: PathBuf::from("lib/m2.lisp"),
6555 },
6556 ],
6557 ),
6558 ];
6559 let b = behavior_with_state_change_callback();
6560 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6561 }
6562
6563 #[test]
6564 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
6565 // Symmetry pin: the gate's pass arm doesn't depend on the
6566 // entry actually carrying a `:state-change` — if no
6567 // `:state-change` is declared, the gate is vacuous regardless
6568 // of the callback (an `:on-state-change` declared without a
6569 // matching per-version script is fine, the callback is the
6570 // runtime default for any *future* migration the author hasn't
6571 // yet added). Pins that a caixa author can declare the
6572 // callback ahead of any migration without the gate
6573 // complaining.
6574 let entries = vec![entry(
6575 "0.1.0",
6576 vec![
6577 UpgradeInstruction::LoadModule { module: "x".into() },
6578 UpgradeInstruction::SoftPurge {
6579 module: "x-old".into(),
6580 },
6581 ],
6582 )];
6583 let b = behavior_with_state_change_callback();
6584 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6585 }
6586
6587 #[test]
6588 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
6589 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
6590 // per-instruction `StateChange`-arm script-path projection must
6591 // route through the sibling lifted
6592 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6593 // accessor, not the raw
6594 // `if let UpgradeInstruction::StateChange { script } = instr`
6595 // open-coded pattern-match the cross-slot gate previously
6596 // carried at caixa-core/src/upgrade.rs:1365.
6597 //
6598 // Structurally: the gate's projection accept-set is the union
6599 // of every [`UpgradeInstruction`] variant for which
6600 // `declared_path().is_some()` — today exactly
6601 // [`UpgradeInstruction::StateChange`] per the sibling
6602 // `declared_path_only_for_state_change` pin, so a
6603 // `:state-change`-carrying entry without an `:on-state-change`
6604 // callback trips `StateChangeWithoutOnStateChangeCallback` and
6605 // a non-`StateChange` entry (load-only / cleanup-only /
6606 // restart-only / empty-`:instructions`) leaves the per-entry
6607 // walk continuing past every non-projecting instruction
6608 // byte-identical to the pattern-match shape.
6609 //
6610 // Byte-equal today (`declared_path` returns `Some(script)` iff
6611 // `StateChange`, byte-for-byte from the variant's own storage);
6612 // the pin catches any future accessor extension that promotes
6613 // an additional variant onto the `PathBuf`-carrying axis — the
6614 // gate then fires on scripts from that variant too, and the
6615 // cross-slot composition discipline the sibling per-
6616 // `UpgradeInstruction` consumers share on the `PathBuf`-
6617 // carrying axis extends to the promoted variant by
6618 // construction.
6619 //
6620 // Peer of the sibling four per-`UpgradeInstruction` consumers
6621 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6622 // sandbox-path fan-out, the layout-side per-`StateChange`
6623 // script-existence fan-out at
6624 // `caixa-core/src/layout.rs:1058`, the within-entry
6625 // [`UpgradeFromEntry::validate_state_change_singularity`]
6626 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
6627 // peer [`UpgradeInstruction::declared_module`] `String`-axis
6628 // per-variant unifier) — the fourth (and last) per-
6629 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
6630 // to now route through the accessor. Same shape as the
6631 // sibling
6632 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
6633 // pin extended onto the cross-slot composition gate.
6634 //
6635 // Three-arm projective coverage:
6636 // (a) `StateChange` scripts project through `declared_path()`
6637 // byte-equal to the raw `script.clone()` field access
6638 // the diagnostic previously carried;
6639 // (b) a `:state-change`-carrying entry with `behavior: None`
6640 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
6641 // carrying the offending script verbatim;
6642 // (c) a non-`StateChange`-only entry (`LoadModule` /
6643 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6644 // vacuous with `Ok(())` — the `declared_path().is_none()`
6645 // arm's fall-through pins.
6646 //
6647 // Fail-before-pass-after verified structurally: swapping the
6648 // production
6649 // `if let Some(script) = instr.declared_path() { … }`
6650 // back to
6651 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
6652 // keeps arms (a)-(c) passing but silently detaches the gate
6653 // from the accessor's typed dispatch — any future
6654 // `declared_path` extension (promotion of an additional
6655 // variant onto the axis, an operator-side pre-resolved-path
6656 // cache the accessor materializes) would then silently
6657 // disagree between this cross-slot gate's raw pattern-match
6658 // and the peer four sibling consumers that route through the
6659 // accessor.
6660
6661 // (a) StateChange projection byte-equal via declared_path.
6662 let sc = UpgradeInstruction::StateChange {
6663 script: PathBuf::from("lib/m.lisp"),
6664 };
6665 assert_eq!(
6666 sc.declared_path().cloned(),
6667 Some(PathBuf::from("lib/m.lisp")),
6668 "declared_path() must project the StateChange :script byte-equal to the raw \
6669 field access — accessor divergence would silently detach this cross-slot \
6670 composition gate from the projection every peer per-`UpgradeInstruction` \
6671 consumer routes through"
6672 );
6673
6674 // (b) StateChange-carrying entry with behavior: None trips gate.
6675 let entries = vec![entry(
6676 "0.1.0",
6677 vec![
6678 UpgradeInstruction::LoadModule { module: "x".into() },
6679 UpgradeInstruction::StateChange {
6680 script: PathBuf::from("lib/m.lisp"),
6681 },
6682 ],
6683 )];
6684 assert_eq!(
6685 validate_upgrade_from_against_behavior(&entries, None),
6686 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
6687 from: "0.1.0".into(),
6688 script: PathBuf::from("lib/m.lisp"),
6689 }),
6690 "a :state-change-carrying entry with behavior: None must trip the gate through \
6691 the declared_path accessor's Some(script) arm — carrying the offending script \
6692 verbatim byte-identical to the pattern-match shape"
6693 );
6694
6695 // (c) Non-StateChange-only inputs leave the gate vacuous.
6696 for instrs in [
6697 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6698 vec![
6699 UpgradeInstruction::LoadModule { module: "x".into() },
6700 UpgradeInstruction::SoftPurge {
6701 module: "x-old".into(),
6702 },
6703 ],
6704 vec![
6705 UpgradeInstruction::LoadModule { module: "x".into() },
6706 UpgradeInstruction::Purge {
6707 module: "x-old".into(),
6708 },
6709 ],
6710 vec![UpgradeInstruction::Restart],
6711 ] {
6712 for instr in &instrs {
6713 assert!(
6714 instr.declared_path().is_none(),
6715 "non-StateChange variants must project None through declared_path — \
6716 accessor divergence would let this cross-slot composition gate silently \
6717 fire on a module reference far from any :state-change site"
6718 );
6719 }
6720 let entries = vec![entry("0.1.0", instrs)];
6721 assert_eq!(
6722 validate_upgrade_from_against_behavior(&entries, None),
6723 Ok(()),
6724 "the cross-slot composition gate must return Ok(()) on an entry whose \
6725 instructions all project None through declared_path — the accessor's \
6726 None arm the pattern-match's implicit fall-through previously carried"
6727 );
6728 }
6729 }
6730
6731 #[test]
6732 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
6733 // Wiring pin: the within-entry restart-exclusivity gate fires
6734 // through [`validate_upgrade_from`] (which delegates to
6735 // [`UpgradeFromEntry::validate`] per entry) before the cross-
6736 // entry duplicate-`:from` gate would have a chance to run on
6737 // the malformed entry. Pinned here so a future refactor that
6738 // walks the cross-entry gate first doesn't accidentally
6739 // surface a DuplicateFrom over an entry that's also malformed
6740 // at the within-entry restart-exclusivity layer.
6741 let entries = vec![
6742 entry(
6743 "0.1.0",
6744 vec![
6745 UpgradeInstruction::LoadModule { module: "x".into() },
6746 UpgradeInstruction::Restart,
6747 ],
6748 ),
6749 entry("0.1.0", vec![UpgradeInstruction::Restart]),
6750 ];
6751 let err = validate_upgrade_from(&entries).unwrap_err();
6752 assert!(
6753 matches!(
6754 err,
6755 UpgradeError::RestartNotExclusive {
6756 restart_count: 1,
6757 ..
6758 }
6759 ),
6760 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
6761 duplicate-`:from` gate fires, got {err:?}"
6762 );
6763 }
6764
6765 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
6766
6767 #[test]
6768 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
6769 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
6770 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
6771 // name the exact camelCase JSON keys the `#[serde(rename_all =
6772 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
6773 // test-side probe across the caixa-core / caixa-flux renderer
6774 // test fixtures navigates into each element of the rendered
6775 // `:upgrade-from` overlay sequence by consulting one of these two
6776 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
6777 // and pin that each canonical byte-sequence appears verbatim in
6778 // the JSON — a future accidental `rename_all = "snake_case"` /
6779 // `"kebab-case"` / verbatim-field-name flip at the derive
6780 // attribute (any of which would silently break every test-side
6781 // probe that reaches for one of the two consts) surfaces here as
6782 // a build-time test failure at `upgrade.rs`, not as an apply-time
6783 // `.get(<stale-canonical-const>)` returning `None` far from the
6784 // derive-attr drift's commit. Same discipline the sibling
6785 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
6786 // (d8b8b4f) and
6787 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
6788 // (21fe462) pins established on the peer `:limits` / `:behavior`
6789 // sub-slot axes: one canonical byte-string per typed sub-key
6790 // axis, pinned to the load-bearing serde derivation at the type
6791 // itself.
6792 let e = UpgradeFromEntry {
6793 from: "0.1.0".into(),
6794 instructions: vec![UpgradeInstruction::LoadModule {
6795 module: "hello-rio".into(),
6796 }],
6797 };
6798 let json = serde_json::to_string(&e).unwrap();
6799 for key in [
6800 crate::render::M2_UPGRADE_FROM_KEY_FROM,
6801 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
6802 ] {
6803 let quoted = format!("\"{key}\"");
6804 assert!(
6805 json.contains("ed),
6806 "serialized UpgradeFromEntry must carry the lifted \
6807 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
6808 the JSON emission (got: {json})",
6809 );
6810 }
6811 }
6812
6813 #[test]
6814 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
6815 // Cross-axis drift-detection pin: a future collapse of the two
6816 // canonical sub-key byte-strings onto the same value (e.g. an
6817 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
6818 // to also read `"from"`) would silently reroute every test-side
6819 // probe on one axis onto the sibling axis's per-entry field and
6820 // pass every propagation-probe test that expected only the stale
6821 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
6822 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
6823 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
6824 let all = [
6825 crate::render::M2_UPGRADE_FROM_KEY_FROM,
6826 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
6827 ];
6828 for (i, a) in all.iter().enumerate() {
6829 for b in all.iter().skip(i + 1) {
6830 assert_ne!(
6831 a, b,
6832 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
6833 canonical byte-sequences — got `{a}` == `{b}`",
6834 );
6835 }
6836 }
6837 }
6838
6839 #[test]
6840 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
6841 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
6842 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
6843 // tagged variant-discriminator key axis: the
6844 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
6845 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
6846 // attribute on [`UpgradeInstruction`] emits, and every downstream
6847 // consumer that navigates the serialized instruction blob to
6848 // route by variant (the caixa-core reflection-vs-serde round-trip
6849 // check in `dispatcher_registration.rs` that probes
6850 // `v.get("kind")` against every variant's expected kebab-case
6851 // tag, the future M4 admission-webhook path, any wasm-operator
6852 // dispatch step consuming the serialized instruction blob) reads
6853 // through the same `&'static str`. Serialize every variant and
6854 // pin that the const's byte-sequence appears verbatim as the
6855 // tag-slot JSON key with the expected kebab-case value — a
6856 // future accidental `tag = "type"` / `tag = "op"` /
6857 // `tag = "instruction"` rebrand at the derive attribute (any of
6858 // which would silently break every consumer probe reaching for
6859 // the stale-tag-key const) surfaces here as a build-time test
6860 // failure at `upgrade.rs`, not as an apply-time
6861 // `.get(<stale-tag-key>)` returning `None` far from the derive-
6862 // attr drift's commit.
6863 //
6864 // Same "one canonical byte-string per typed axis" discipline the
6865 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
6866 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
6867 // outer-container axis — this pin extends the discipline one
6868 // altitude deeper onto the per-instruction *tag* axis inside
6869 // each element of the `:instructions` list, completing the
6870 // typed coverage of the `:upgrade-from :instructions` dual
6871 // (key = "kind" + five variant-value tags): the five
6872 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
6873 // per-variant kebab-case *values*; this pin pins the tag *key*
6874 // above them.
6875 let samples: [(UpgradeInstruction, &'static str); 5] = [
6876 (
6877 UpgradeInstruction::LoadModule {
6878 module: "hello-rio".into(),
6879 },
6880 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
6881 ),
6882 (
6883 UpgradeInstruction::StateChange {
6884 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6885 },
6886 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
6887 ),
6888 (
6889 UpgradeInstruction::SoftPurge {
6890 module: "hello-rio-old".into(),
6891 },
6892 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
6893 ),
6894 (
6895 UpgradeInstruction::Purge {
6896 module: "hello-rio-old".into(),
6897 },
6898 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
6899 ),
6900 (
6901 UpgradeInstruction::Restart,
6902 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
6903 ),
6904 ];
6905 for (sample, expected_value) in &samples {
6906 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
6907 let got = v
6908 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
6909 .and_then(|k| k.as_str());
6910 assert_eq!(
6911 got,
6912 Some(*expected_value),
6913 "serialized {sample:?} must carry the lifted \
6914 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
6915 ({:?}) verbatim as the tag-slot JSON key, holding the \
6916 expected kebab-case value {expected_value:?} (got: {v})",
6917 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
6918 );
6919 }
6920 }
6921
6922 #[test]
6923 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
6924 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
6925 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
6926 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
6927 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
6928 // whitespace / colons / dots) — the canonical shape a serde
6929 // internally-tagged discriminator key takes across every peer
6930 // enum in this crate. A future flip to a non-camelCase byte at
6931 // the const surfaces here at build time. Peer of
6932 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
6933 // sibling per-entry outer-container axis.
6934 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
6935 assert!(
6936 !key.is_empty(),
6937 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
6938 );
6939 let first = key.chars().next().unwrap();
6940 assert!(
6941 first.is_ascii_lowercase(),
6942 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
6943 byte (got {key:?}, leads with {first:?})",
6944 );
6945 assert!(
6946 key.chars().all(|c| c.is_ascii_alphanumeric()),
6947 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
6948 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
6949 );
6950 }
6951
6952 #[test]
6953 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
6954 // Cross-axis drift-detection pin: the tag-slot key
6955 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
6956 // disjoint from every per-variant data-field key the
6957 // internally-tagged serialization also emits (`"module"` for
6958 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
6959 // future accidental rebrand that collapses `tag = "kind"` onto
6960 // one of the data-field names (e.g. `tag = "module"`) would
6961 // silently corrupt every serialized LoadModule blob (the
6962 // module string and the variant tag would collide on the same
6963 // JSON key) and every consumer probe would either misread the
6964 // tag or fail to distinguish variants. Pin the disjointness at
6965 // build time. Same cross-axis discipline the sibling
6966 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
6967 // (36ffe65) established on the outer container's own
6968 // `from`/`instructions` pair.
6969 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
6970 // Enumerate every per-variant data-field key across all five
6971 // variants of [`UpgradeInstruction`], routing through the two
6972 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
6973 // that name the same per-variant data-field JSON keys the
6974 // `variant_fields` reflection in
6975 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
6976 // per-variant struct-field rebrand (`module` → `component`,
6977 // `script` → `path`) lands as an edit to exactly one const and
6978 // reaches this disjointness pin by construction — the two axes
6979 // (tag-slot key on one side, per-variant data-field keys on the
6980 // other) share one source of truth per axis.
6981 for data_field in [
6982 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
6983 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
6984 ] {
6985 assert_ne!(
6986 key, data_field,
6987 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
6988 must be disjoint from every UpgradeInstruction per-variant \
6989 data-field key — got tag-key {key:?} colliding with \
6990 data-field {data_field:?}, which would silently corrupt \
6991 the internally-tagged serialization",
6992 );
6993 }
6994 }
6995
6996 #[test]
6997 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
6998 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
6999 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
7000 // data-field JSON key axis: the two
7001 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
7002 // `_SCRIPT`) name the exact per-variant field JSON keys the
7003 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
7004 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
7005 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
7006 // const — the `module: String` struct-field on
7007 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
7008 // struct-field on `StateChange` are promoted to sibling JSON keys
7009 // at the same nesting level as the tag by the internally-tagged
7010 // serialization, and every downstream consumer that navigates the
7011 // serialized instruction blob to reach the payload (the caixa-core
7012 // reflection round-trip in `dispatcher_registration.rs` that
7013 // consults `variant_fields`, the sibling disjointness pin below,
7014 // any future wasm-operator upgrade-dispatch step consuming the
7015 // serialized instruction blob to route the per-module load /
7016 // soft-purge / purge action or the per-script state-change action)
7017 // reads through the same `&'static str`. Serialize one Module-
7018 // bearing variant and one Script-bearing variant, then pin that
7019 // each const's byte-sequence appears verbatim in the JSON emission
7020 // — a future accidental struct-field rebrand (`module: String` →
7021 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
7022 // either variant surfaces here as a build-time test failure at
7023 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
7024 // returning `None` far from the field-name drift's commit.
7025 //
7026 // Same "one canonical byte-string per typed axis" discipline the
7027 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
7028 // pin established on the peer tag-slot key axis on the same
7029 // enum — this pin extends the discipline onto the per-variant
7030 // data-field key axis, completing the `:upgrade-from :instructions`
7031 // variant-JSON dual (tag key + tag values + per-variant field keys)
7032 // fully into caixa-core.
7033 let module_sample = UpgradeInstruction::LoadModule {
7034 module: "hello-rio".into(),
7035 };
7036 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
7037 assert_eq!(
7038 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
7039 .and_then(|k| k.as_str()),
7040 Some("hello-rio"),
7041 "serialized {module_sample:?} must carry the lifted \
7042 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
7043 ({:?}) verbatim as the data-field JSON key holding the \
7044 module string (got: {v})",
7045 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7046 );
7047
7048 let script_sample = UpgradeInstruction::StateChange {
7049 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7050 };
7051 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
7052 assert_eq!(
7053 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
7054 .and_then(|k| k.as_str()),
7055 Some("lib/migrations/v01-to-v02.lisp"),
7056 "serialized {script_sample:?} must carry the lifted \
7057 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
7058 ({:?}) verbatim as the data-field JSON key holding the \
7059 script path (got: {v})",
7060 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7061 );
7062 }
7063
7064 #[test]
7065 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
7066 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
7067 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7068 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7069 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7070 // whitespace / colons / dots) — the canonical shape a Rust
7071 // struct-field name promoted to a JSON key by serde takes on this
7072 // internally-tagged variant surface, matching the sibling
7073 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
7074 // shape. A future flip to a non-camelCase byte at either const
7075 // (an accidental `rename_all` regime interleave, or a struct-
7076 // field flip like `module` → `module_name`) surfaces here at
7077 // build time. Peer of
7078 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
7079 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
7080 // the sibling wire-key axes.
7081 for key in [
7082 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7083 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7084 ] {
7085 assert!(
7086 !key.is_empty(),
7087 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
7088 );
7089 let first = key.chars().next().unwrap();
7090 assert!(
7091 first.is_ascii_lowercase(),
7092 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
7093 byte (got {key:?}, leads with {first:?})",
7094 );
7095 assert!(
7096 key.chars().all(|c| c.is_ascii_alphanumeric()),
7097 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
7098 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7099 );
7100 }
7101 }
7102
7103 #[test]
7104 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
7105 // Cross-axis drift-detection pin: a future collapse of the two
7106 // canonical per-variant data-field byte-strings onto the same
7107 // value (e.g. an accidental copy-paste flip of
7108 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
7109 // `"module"`) would silently reroute every test-side probe on one
7110 // variant's payload onto the sibling variant's payload and pass
7111 // every propagation-probe test that expected only the stale
7112 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
7113 // on the sibling per-entry outer-container axis, and of
7114 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
7115 // on the sibling tag-slot key ↔ per-variant data-field key axis.
7116 let all = [
7117 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7118 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7119 ];
7120 for (i, a) in all.iter().enumerate() {
7121 for b in all.iter().skip(i + 1) {
7122 assert_ne!(
7123 a, b,
7124 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
7125 canonical byte-sequences — got `{a}` == `{b}`",
7126 );
7127 }
7128 }
7129 }
7130
7131 #[test]
7132 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
7133 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
7134 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
7135 // `kebab-case` hyphens, no `PascalCase` leading capital, no
7136 // whitespace / colons / dots) — the canonical shape the
7137 // `#[serde(rename_all = "camelCase")]` derive produces on
7138 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
7139 // at the derive surfaces both here (this test fails on the
7140 // stale-constant shape) and at
7141 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7142 // (that test fails on the mismatch between const and derive).
7143 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
7144 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
7145 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7146 for key in [
7147 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7148 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7149 ] {
7150 assert!(
7151 !key.is_empty(),
7152 "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
7153 );
7154 let first = key.chars().next().unwrap();
7155 assert!(
7156 first.is_ascii_lowercase(),
7157 "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
7158 byte (got {key:?}, leads with {first:?})",
7159 );
7160 assert!(
7161 key.chars().all(|c| c.is_ascii_alphanumeric()),
7162 "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
7163 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7164 );
7165 }
7166 }
7167
7168 #[test]
7169 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
7170 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
7171 // OTP-appup variant-tag axis: the five canonical author-facing
7172 // kebab-case labels (`:load-module` / `:state-change` /
7173 // `:soft-purge` / `:purge` / `:restart`) the substrate's
7174 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
7175 // from and every downstream consumer probes for verbatim. Same
7176 // scalar-value discipline the peer
7177 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
7178 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7179 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7180 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7181 // (be40492) established for the sibling M2 / M3 / Supervisor
7182 // top-level and sub-slot author-facing-label axes. Fail-before-
7183 // pass-after locally verified by mutating
7184 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
7185 // pin fires as expected; restoring passes.
7186 //
7187 // A future OTP-lineage per-variant rebrand (e.g.
7188 // `:load-module` → `:load` matching Erlang's abbreviated
7189 // `code:load_module` name, `:state-change` → `:code-change`
7190 // matching Erlang's verbatim `code_change/3` callback,
7191 // `:soft-purge` → `:drain` matching a hypothetical operator-side
7192 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
7193 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
7194 // matching a supervisor-tree vocabulary alignment) lands as an
7195 // edit to exactly one const, and every consumer that reaches for
7196 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
7197 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
7198 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
7199 // `prior_cleanup_kind:` diagnostic field, the
7200 // [`LayoutError::UpgradeViolation`] `issue:` probe in
7201 // `layout.rs`) picks it up at build time rather than at runtime
7202 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
7203 // far from the rename's commit.
7204 assert_eq!(
7205 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7206 ":load-module"
7207 );
7208 assert_eq!(
7209 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7210 ":state-change"
7211 );
7212 assert_eq!(
7213 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7214 ":soft-purge"
7215 );
7216 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
7217 assert_eq!(
7218 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7219 ":restart"
7220 );
7221 }
7222
7223 #[test]
7224 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
7225 // Cross-arm drift-detection pin on the M2
7226 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
7227 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
7228 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
7229 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
7230 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
7231 // closed-set OTP-appup variant-tag pentad: a future collapse
7232 // of two canonical variant byte-strings onto the same value
7233 // (an accidental copy-paste flip of
7234 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7235 // to also read `":purge"`, a per-arm rebrand that lands one
7236 // const without touching its paired peer) would silently
7237 // reroute every downstream OTP-appup dispatcher's per-
7238 // instruction branch onto the sibling arm's runtime
7239 // behavior and pass every propagation-probe test that
7240 // expected only the stale arm's tag — a `:soft-purge`
7241 // instruction (drain-then-swap: existing callers finish
7242 // under the old module, new callers land on the new one)
7243 // would come up under the `:purge` reconcile branch
7244 // (drop-existing: every in-flight caller terminates
7245 // immediately) on every hot-upgrade cycle, so a rolling
7246 // module swap would silently downgrade to a hard cutover
7247 // against its declared appup discipline, with no field
7248 // naming the instruction-tag drift root cause. Every
7249 // [`crate::UpgradeError`] diagnostic that surfaces the tag
7250 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
7251 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
7252 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
7253 // `prior_cleanup_kind:` field, the
7254 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
7255 // `layout.rs`) would emit the sibling arm's stale bytes at
7256 // the operator's console, far from the source rebrand
7257 // commit. Peer of the sibling
7258 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
7259 // (09ffb2d) /
7260 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
7261 // (ccdf955) /
7262 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
7263 // (d739850) distinctness pins on the sibling OTP-shape /
7264 // caixa-kind closed-set typed-enum discriminator axes —
7265 // the fifth closed-set OTP-appup / typed-enum axis to
7266 // converge on the same
7267 // "pairwise-distinct-by-construction" discipline, and the
7268 // canonical companion to the peer
7269 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
7270 // (ff980bb) distinctness pin on the sibling internally-
7271 // tagged-JSON per-variant data-field-key axis (the tag axis
7272 // this pin covers vs. the data-field-key axis its peer
7273 // covers — two paired axes on the same
7274 // [`crate::UpgradeInstruction`] typed enum surface).
7275 //
7276 // Fail-before-pass-after locally verified by mutating
7277 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7278 // to also read `":purge"` — this pin fires as expected;
7279 // restoring passes.
7280 let all = [
7281 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7282 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7283 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7284 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7285 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7286 ];
7287 for (i, a) in all.iter().enumerate() {
7288 for (j, b) in all.iter().enumerate() {
7289 if i != j {
7290 assert_ne!(
7291 a, b,
7292 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
7293 distinct — got duplicate {a:?} at indices {i} and {j}",
7294 );
7295 }
7296 }
7297 }
7298 }
7299
7300 #[test]
7301 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
7302 // Production-through-const pin: the five per-variant labels
7303 // [`UpgradeInstruction::lisp_form`] returns route through the
7304 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
7305 // so a future rebrand that reaches the const but not the
7306 // dispatch (or vice versa) surfaces here at build time rather
7307 // than at runtime as a downstream
7308 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
7309 // diagnostic drift far from the rename's commit. Mirror of the
7310 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
7311 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
7312 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
7313 // (f49c8b0) production-through-const pins on the sibling M3 /
7314 // M2 top-level slot axes.
7315 //
7316 // Fail-before-pass-after locally verified by mutating
7317 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
7318 // `":purge-drift"` — this pin fires as expected; restoring
7319 // passes.
7320 let cases: &[(UpgradeInstruction, &'static str)] = &[
7321 (
7322 UpgradeInstruction::LoadModule { module: "x".into() },
7323 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7324 ),
7325 (
7326 UpgradeInstruction::StateChange {
7327 script: PathBuf::from("lib/m.lisp"),
7328 },
7329 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7330 ),
7331 (
7332 UpgradeInstruction::SoftPurge {
7333 module: "x-old".into(),
7334 },
7335 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7336 ),
7337 (
7338 UpgradeInstruction::Purge {
7339 module: "x-old".into(),
7340 },
7341 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7342 ),
7343 (
7344 UpgradeInstruction::Restart,
7345 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7346 ),
7347 ];
7348 for (instr, expected) in cases {
7349 assert_eq!(
7350 instr.lisp_form(),
7351 *expected,
7352 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
7353 const (expected {expected:?})",
7354 );
7355 }
7356 }
7357
7358 #[test]
7359 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
7360 // The canonical per-`:upgrade-from :instructions` OTP-appup
7361 // migration-instruction-list slice-shape pin:
7362 // [`UpgradeFromEntry::instructions`] must return the
7363 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
7364 // a `&[UpgradeInstruction]` slice-view over the same backing
7365 // buffer the raw `self.instructions.as_slice()` field access
7366 // borrows from, byte-equal across every representative fixture
7367 // in the accept-set — the empty slice (the "no-op upgrade" /
7368 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
7369 // field's own docstring names), the singleton slice on every
7370 // variant of the [`UpgradeInstruction`] arm-space
7371 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
7372 // `Restart` — the five OTP-appup runtime-primitive variants),
7373 // and multi-instruction cohorts (the canonical
7374 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
7375 // load + state-migration triad the module doc names as the
7376 // "runs the instructions in order" example).
7377 //
7378 // Pins against a future silent detour that returned
7379 // `&Vec<UpgradeInstruction>` (which would type-check but leak
7380 // the storage-side `Vec`'s grow/push/reserve surface no
7381 // consumer of the typed view reaches for), a fresh-allocated
7382 // `Vec<UpgradeInstruction>` copy (which would type-check via
7383 // a coercion but silently break every downstream caller that
7384 // relied on the slice sharing the backing buffer's identity),
7385 // or an out-of-order or length-drifted projection (which
7386 // would silently split the paired within-entry cross-
7387 // instruction ordering gates' inputs from the peer per-
7388 // instruction shape-check loop's input, one seven-gate cohort
7389 // silently drifting from the peer gate's actual traversal
7390 // input).
7391 //
7392 // Peer of the sibling
7393 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
7394 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
7395 // `:contratos` edge-list axis, extended onto the M2 per-
7396 // `:upgrade-from :instructions` migration-instruction-list
7397 // axis — the fifth `&[T]`-return byte-equal pin, closing the
7398 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
7399 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
7400 Vec::new(),
7401 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7402 vec![UpgradeInstruction::StateChange {
7403 script: PathBuf::from("lib/m.lisp"),
7404 }],
7405 vec![UpgradeInstruction::SoftPurge {
7406 module: "x-old".into(),
7407 }],
7408 vec![UpgradeInstruction::Purge {
7409 module: "x-old".into(),
7410 }],
7411 vec![UpgradeInstruction::Restart],
7412 vec![
7413 UpgradeInstruction::LoadModule { module: "x".into() },
7414 UpgradeInstruction::StateChange {
7415 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7416 },
7417 UpgradeInstruction::SoftPurge {
7418 module: "x-old".into(),
7419 },
7420 ],
7421 ];
7422 for instructions in fixtures {
7423 let e = UpgradeFromEntry {
7424 from: "0.1.0".into(),
7425 instructions: instructions.clone(),
7426 };
7427 assert_eq!(
7428 e.instructions(),
7429 e.instructions.as_slice(),
7430 "UpgradeFromEntry::instructions must project the raw \
7431 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
7432 `&[UpgradeInstruction]` slice-view over the same backing buffer \
7433 (fixture: {instructions:?})",
7434 );
7435 assert_eq!(
7436 e.instructions().len(),
7437 instructions.len(),
7438 "UpgradeFromEntry::instructions length must match the raw \
7439 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
7440 );
7441 }
7442 }
7443
7444 #[test]
7445 fn validate_reads_through_lifted_instructions_accessor() {
7446 // Three-consumer coherence pin on the lifted
7447 // [`UpgradeFromEntry::instructions`] slice-return accessor:
7448 // exercises three of the nine paired production consumers of
7449 // the per-`:upgrade-from :instructions` OTP-appup migration-
7450 // instruction-list surface through end-to-end validate() paths
7451 // that require the accessor to reach each of the fixture's
7452 // instructions.
7453 //
7454 // (1) The per-instruction shape-check fan-out
7455 // ([`UpgradeFromEntry::validate`]'s `for instr in
7456 // self.instructions()` loop): pass the well-formed load →
7457 // state-change → soft-purge triad — `validate()` must accept
7458 // it, which requires the accessor to project every entry so
7459 // each `instr.validate()` fires.
7460 //
7461 // (2) The within-entry state-change-ordering gate
7462 // ([`Self::validate_state_change_ordering`]): pass a
7463 // `((:state-change …))` singleton — `validate()` must return
7464 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
7465 // requires the accessor to reach the state-change so the
7466 // no-prior-load probe fires.
7467 //
7468 // (3) The within-entry per-module cleanup-singularity gate
7469 // ([`Self::validate_cleanup_singularity`]): pass a
7470 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
7471 // "x-old"))` cohort — `validate()` must return
7472 // [`UpgradeError::DuplicateCleanup`], which requires the
7473 // accessor to iterate the whole list so the second `SoftPurge`
7474 // matches the first via the `seen` set.
7475 //
7476 // Peer of the sibling
7477 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
7478 // three-consumer coherence pin on the M3 per-`:contratos`
7479 // edge-list axis, extended onto the M2 per-`:upgrade-from
7480 // :instructions` migration-instruction-list axis.
7481
7482 // (1) accept the well-formed OTP two-phase code-load triad
7483 let well_formed = entry(
7484 "0.1.0",
7485 vec![
7486 UpgradeInstruction::LoadModule { module: "x".into() },
7487 UpgradeInstruction::StateChange {
7488 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7489 },
7490 UpgradeInstruction::SoftPurge {
7491 module: "x-old".into(),
7492 },
7493 ],
7494 );
7495 assert!(
7496 well_formed.validate().is_ok(),
7497 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
7498 the per-instruction shape-check fan-out requires the accessor to reach every entry"
7499 );
7500
7501 // (2) refuse a `((:state-change …))` singleton — the
7502 // state-change-without-prior-load gate must fire, which
7503 // requires the accessor to reach the single instruction.
7504 let no_prior_load = entry(
7505 "0.1.0",
7506 vec![UpgradeInstruction::StateChange {
7507 script: PathBuf::from("lib/m.lisp"),
7508 }],
7509 );
7510 match no_prior_load.validate() {
7511 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
7512 other => panic!(
7513 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
7514 — the within-entry state-change-ordering gate must reach the single \
7515 instruction through the lifted accessor; got: {other:?}"
7516 ),
7517 }
7518
7519 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
7520 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
7521 // singularity gate must fire on the second `SoftPurge`, which
7522 // requires the accessor to iterate the whole list.
7523 let duplicate_cleanup = entry(
7524 "0.1.0",
7525 vec![
7526 UpgradeInstruction::LoadModule { module: "x".into() },
7527 UpgradeInstruction::SoftPurge {
7528 module: "x-old".into(),
7529 },
7530 UpgradeInstruction::SoftPurge {
7531 module: "x-old".into(),
7532 },
7533 ],
7534 );
7535 match duplicate_cleanup.validate() {
7536 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
7537 assert_eq!(
7538 module, "x-old",
7539 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
7540 cleanup-singularity gate must iterate through the lifted accessor to \
7541 match the second SoftPurge against the first via the `seen` set"
7542 );
7543 }
7544 other => panic!(
7545 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
7546 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
7547 iterate the whole list through the lifted accessor; got: {other:?}"
7548 ),
7549 }
7550
7551 // Path::new suppresses the unused-import warning if the
7552 // outer module trims `use std::path::Path;` in a future edit.
7553 let _ = Path::new("lib/m.lisp");
7554 }
7555}