Skip to main content

caixa_core/
upgrade.rs

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