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