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