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