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