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