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())
324            .map_err(|e| UpgradeError::from_invalid(self.prior_versao(), &e.to_string()))?;
325        // Per-instruction typed shape: kind-tagged `:module` /
326        // `:script` value-shape gates fire here, *before* the
327        // within-entry restart-exclusivity gate below — so a
328        // malformed-shape diagnostic on a Module/Script-bearing
329        // instruction surfaces with its narrower self-locating
330        // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
331        // `AbsoluteScript`, `ParentEscapeScript`) rather than
332        // collapsing two unrelated authoring errors into a single
333        // exclusivity diagnostic. Same empty-first cascade discipline
334        // every peer DNS-1123 / path-shape gate inside this module
335        // uses (`validate_module`'s ModuleEmpty arm precedes the
336        // DNS-1123 predicate; `validate` on `StateChange` consults
337        // the lifted `is_sandboxed_relative_path` shape gate first).
338        // Route the per-instruction shape-check fan-out through the
339        // lifted [`Self::instructions`] slice-return accessor rather
340        // than the raw `self.instructions` field access — first of
341        // nine paired production consumers of the per-`:upgrade-from
342        // :instructions` OTP-appup migration-instruction-list surface
343        // that now key off exactly one typed dispatch on the substrate
344        // primitive.
345        for instr in self.instructions() {
346            instr.validate()?;
347        }
348        self.validate_restart_exclusive()?;
349        self.validate_state_change_ordering()?;
350        self.validate_purge_ordering()?;
351        self.validate_state_change_before_cleanup()?;
352        self.validate_load_singularity()?;
353        self.validate_state_change_singularity()?;
354        self.validate_cleanup_singularity()?;
355        Ok(())
356    }
357
358    /// Reject `:upgrade-from :instructions` lists that carry
359    /// `(:restart)` alongside any other instruction, or that carry
360    /// more than one `(:restart)`. The valid Restart-bearing shape is
361    /// exactly `((:restart))` — a single `Restart` as the entry's
362    /// whole instructions list.
363    ///
364    /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
365    /// is the *fallback* for an entry whose typed upgrade is
366    /// impossible (wasm component-model world incompatibility,
367    /// irreversible state shape change). The fallback is terminal by
368    /// construction: the operator restarts the pod and the new version
369    /// comes up fresh, so any other instructions in the same entry
370    /// are dead code in both directions — either the typed sequence
371    /// would have succeeded and `(:restart)` is unreached, or it
372    /// wouldn't and the typed instructions are dead because the
373    /// operator restarts anyway. Two canonical authoring footguns
374    /// close here:
375    ///
376    ///   - `((:load-module …) (:state-change …) (:restart))` — the
377    ///     "I'll try the typed path *then* restart anyway" footgun.
378    ///     There is no coherent OTP-shaped semantic for this: if the
379    ///     typed sequence succeeds, the trailing restart discards the
380    ///     work that just succeeded (defeating the whole point of
381    ///     declaring it); if it fails, the restart is never reached
382    ///     because the entry already failed.
383    ///   - `((:restart) (:restart))` — multiple `Restart` variants in
384    ///     one entry. The fallback is a single semantic; repeating it
385    ///     is at best redundant, at worst suggests the author thought
386    ///     the second one would re-trigger after the first.
387    ///
388    /// Same within-entry exclusivity discipline OTP's `relup` enforces
389    /// at the `restart_new_emulator | restart_emulator` instruction
390    /// boundary — those instructions are terminal in the upgrade
391    /// script (`systools(3)` rejects sequences that continue past
392    /// them); pleme-io lifts the same shape to a build-time gate,
393    /// matching the CAIXA-SDLC §III "build errors, not runtime
394    /// surprises" frame.
395    ///
396    /// Same within-entry cross-instruction discipline the
397    /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
398    /// shard-key partition (934bc58) and
399    /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
400    /// precedence partition (de7ab1a) apply on cross-slot axes — now
401    /// extended onto the first within-list cross-instruction axis on
402    /// the `:upgrade-from` typed slot.
403    fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
404        // Route the paired restart-count / instructions-len / other-
405        // kind projections through the lifted [`Self::instructions`]
406        // slice-return accessor rather than the raw `self.instructions`
407        // field access — three raw-access sites in one gate collapse
408        // onto exactly one typed dispatch on the substrate primitive.
409        //
410        // The paired positive / negated `Self::Restart` arm-discriminator
411        // predicates route through the `gen_platform::IsVariant`
412        // derive-generated [`UpgradeInstruction::is_restart`] rather than
413        // the raw `matches!(i, UpgradeInstruction::Restart)` /
414        // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
415        // matches — same closed-set-typed-enum arm-discriminator dispatch
416        // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
417        // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
418        // / `!= CaixaKind::X` production sites in the substrate's own
419        // layout invariant verifier + typed-view projection gates,
420        // extended here onto the last unlifted `matches!`-based
421        // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
422        // typed enum. A future sixth `UpgradeInstruction` arm (an
423        // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
424        // wasm-operator's hot-upgrade runtime could adopt to bracket the
425        // typed instruction sequence against a per-cluster readiness
426        // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
427        // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
428        // materializer could resolve per-CR) migrates as a single
429        // enum-declaration edit — the derive auto-generates the paired
430        // `.is_<new_arm>()` predicate; every consumer inherits the new
431        // arm on the next re-derive, rather than the two `matches!` sites
432        // here having to be threaded through in lockstep.
433        let instructions = self.instructions();
434        let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
435        if restart_count == 0 {
436            return Ok(());
437        }
438        if restart_count == 1 && instructions.len() == 1 {
439            return Ok(());
440        }
441        let other_kinds: Vec<&'static str> = instructions
442            .iter()
443            .filter(|i| !i.is_restart())
444            .map(UpgradeInstruction::lisp_form)
445            .collect();
446        Err(UpgradeError::RestartNotExclusive {
447            from: self.prior_versao().to_string(),
448            restart_count,
449            other_kinds,
450        })
451    }
452
453    /// Reject an entry whose `(:state-change …)` is not preceded by a
454    /// `(:load-module …)` in the same `:instructions` list.
455    ///
456    /// `StateChange` is the `gen_server:code_change/3` analog
457    /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
458    /// it runs the migration script that folds the *old* state into the
459    /// shape the *new* code expects. In OTP, `code_change/3` is invoked
460    /// in the context of the newly-loaded code — `release_handler`
461    /// always loads the new module before running the advanced update
462    /// that triggers the callback. caixa decomposes that into two
463    /// explicit instructions (`LoadModule` brings the new version up
464    /// "alongside the current one"; `StateChange` migrates the state),
465    /// and the module doc pins that the operator "runs the instructions
466    /// in order" and only swaps traffic after all succeed. So a
467    /// `:state-change` with no preceding `:load-module` migrates state
468    /// into code that was never loaded — the migration script runs while
469    /// the only resident version is still the *old* one, which expects
470    /// the *old* state. Two authoring footguns close here:
471    ///
472    ///   - `((:state-change "…"))` — the "I wrote the migration but
473    ///     forgot to load the new module" footgun. The new code that
474    ///     defines the new state representation (and that the migration
475    ///     output is destined for) never comes up; the operator runs
476    ///     the script against the old code and either no-ops or corrupts
477    ///     live state.
478    ///   - `((:state-change "…") (:load-module "…"))` — the
479    ///     right-instructions-wrong-order footgun. Because the operator
480    ///     executes in declared order, the migration runs *before* the
481    ///     new code is resident, then the load brings up code expecting
482    ///     already-migrated state that the just-run script produced
483    ///     against the old version's shape. The canonical order is
484    ///     `(:load-module …) (:state-change …) (:soft-purge …)`
485    ///     (module doc example).
486    ///
487    /// Same within-entry cross-instruction discipline as
488    /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
489    /// exclusivity gate it runs beside): both reject an
490    /// `:instructions` list whose instructions are individually
491    /// well-shaped but jointly incoherent, at the typed build surface
492    /// rather than as a runtime surprise. Runs *after*
493    /// `validate_restart_exclusive` so a `((:state-change …)
494    /// (:restart))` shape still surfaces the more-fundamental
495    /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
496    /// alone, so no Restart-bearing entry reaches this gate carrying a
497    /// `StateChange`).
498    fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
499        // Route the per-instruction load-family arm-discriminator through
500        // the `gen_platform::IsVariant`-derive-generated
501        // [`UpgradeInstruction::is_load_module`] predicate and the
502        // per-instruction migration-family `:script` scalar projection
503        // through the sibling lifted [`UpgradeInstruction::declared_path`]
504        // `Option<&PathBuf>` accessor rather than the raw two-arm
505        // `match instr { UpgradeInstruction::LoadModule { .. } =>
506        // loaded = true, UpgradeInstruction::StateChange { script } if
507        // !loaded => …, _ => {} }` open-coded pattern-match — closes the
508        // last unlifted `match`-shaped per-arm-hand-rolled load-family
509        // arm-discriminator + migration-family script-projection pair
510        // inside `impl UpgradeFromEntry`. Sibling of the peer
511        // [`Self::validate_purge_ordering`] (580d0f1) routing already
512        // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
513        // load → cleanup ordering axis, the peer
514        // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
515        // onto the [`UpgradeInstruction::is_load_module`] +
516        // [`UpgradeInstruction::declared_module`] pair on the singularity
517        // axis, and the peer [`Self::validate_state_change_singularity`]
518        // routing already lifted onto the sibling
519        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
520        // accessor on the migration-family script-projection axis — both
521        // ordering-gate load-family sticky-latch dispatches now key off
522        // exactly one typed dispatch on the substrate primitive for
523        // their load-family arm-discriminator, and both migration-family
524        // projection sites (this ordering gate + the peer singularity
525        // gate) now key off exactly one typed dispatch on the substrate
526        // primitive for the `:script`-carrying axis. A future sixth arm
527        // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
528        // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
529        // `CanaryTraffic` split-traffic variant the M4 CR materializer
530        // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
531        // enum-declaration edit through the derive rather than a
532        // coordinated rewrite of every ordering / singularity gate's
533        // per-arm hand-rolled pattern-match. Byte-identity of this
534        // dispatch against the pre-lift `match` shape is pinned by
535        // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
536        let mut loaded = false;
537        for instr in self.instructions() {
538            if instr.is_load_module() {
539                loaded = true;
540            } else if !loaded && let Some(script) = instr.declared_path() {
541                return Err(UpgradeError::state_change_without_prior_load(
542                    self.prior_versao(),
543                    script,
544                ));
545            }
546        }
547        Ok(())
548    }
549
550    /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
551    /// preceded by a `(:load-module …)` in the same `:instructions` list.
552    ///
553    /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
554    /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
555    /// *old* module from memory after the new one is resident. OTP's
556    /// two-phase code load is `code:load_module/1` *then*
557    /// `code:soft_purge/1` — load the new version alongside the old
558    /// (both in memory, new requests route to new), then purge the old
559    /// after in-flight callers drain. caixa decomposes that into two
560    /// explicit instructions (`LoadModule` brings the new version up
561    /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
562    /// doc; `SoftPurge` "waits for in-flight requests on a named module
563    /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
564    /// and the module doc pins that the operator "runs the instructions
565    /// in order". So a `:soft-purge` / `:purge` with no preceding
566    /// `:load-module` purges old code while the only resident version is
567    /// still the *same* old code, leaving the upgrade entry asking the
568    /// operator to drain or discard the live module with no replacement
569    /// resident. Two authoring footguns close here:
570    ///
571    ///   - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
572    ///     cleanup but forgot to load the new module" footgun. The new
573    ///     code never comes up alongside; the operator either drains the
574    ///     old version to nothing (`SoftPurge`) or discards it outright
575    ///     mid-request (`Purge`), with no replacement to route in-flight
576    ///     or future requests to.
577    ///   - `((:soft-purge "…") (:load-module "…"))` /
578    ///     `((:purge "…") (:load-module "…"))` — the right-instructions-
579    ///     wrong-order footgun. Because the operator executes in declared
580    ///     order, the cleanup runs *before* the new code is resident,
581    ///     leaving a window during which neither version is available;
582    ///     the canonical order is `(:load-module …) (:state-change …)
583    ///     (:soft-purge …)` (module doc example).
584    ///
585    /// Same within-entry cross-instruction discipline as
586    /// [`Self::validate_state_change_ordering`] (the `:state-change`-
587    /// ordering gate it runs beside): both close the same load-before-X
588    /// post-condition on the OTP appup ordering contract, now extending
589    /// the typed coverage from "new code resident before its state
590    /// migration runs" to "new code resident before the old code is
591    /// drained or discarded" — the second half of OTP's two-phase code
592    /// load. Runs *after* `validate_state_change_ordering` so an entry
593    /// like `((:state-change …) (:soft-purge …))` surfaces the more-
594    /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
595    /// are load-less, but state-change is the load-bearing semantic — the
596    /// purge is meaningless either way without a preceding load, so the
597    /// author should see the migration-side diagnostic first).
598    fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
599        let mut loaded = false;
600        for instr in self.instructions() {
601            // Route the per-instruction cleanup-family arm-discriminator
602            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
603            // predicate rather than the raw
604            // `UpgradeInstruction::SoftPurge { module } |
605            // UpgradeInstruction::Purge { module }` open-coded per-arm
606            // union pattern-match — the first of three within-entry cross-
607            // instruction cleanup-facing gates now keys off exactly one
608            // typed dispatch on the substrate primitive, so any future
609            // fifth cleanup-shaped variant (a `Discard` variant the
610            // `code:delete/1` peer inspires) added to
611            // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
612            // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
613            // through the accessor's one body. The paired cleanup-arm
614            // `:module` scalar is routed through the sibling
615            // [`UpgradeInstruction::declared_module`] accessor rather than
616            // the raw pattern-bound `module` binding — same substrate-
617            // primitive-owns-the-scalar discipline every peer
618            // per-`UpgradeInstruction` scalar-value axis already routes
619            // through, with the `is_cleanup`-implies-`declared_module`-is-
620            // `Some` composition pin at
621            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
622            // making the `.expect(…)` structurally infallible at build
623            // time. Peer of the sibling
624            // [`UpgradeFromEntry::validate_restart_exclusive`]
625            // paired positive / negated
626            // [`UpgradeInstruction::is_restart`] routing (915a934) on the
627            // per-arm terminal-fallback partition — same closed-set-typed-
628            // enum arm-discriminator dispatch discipline extended from
629            // the single-arm terminal-fallback family onto the two-arm
630            // cleanup family here.
631            //
632            // Route the paired load-family arm-discriminator through the
633            // `gen_platform::IsVariant`-derive-generated
634            // [`UpgradeInstruction::is_load_module`] predicate rather than
635            // the raw `matches!(instr, UpgradeInstruction::LoadModule
636            // { .. })` open-coded pattern-match — closes the last
637            // unlifted `matches!`-based per-variant arm-discriminator
638            // axis on the [`UpgradeInstruction`] closed-set typed enum,
639            // sibling of the [`UpgradeInstruction::is_restart`] terminal-
640            // fallback routing (915a934) and the
641            // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
642            // routing (0bc469f) that already lifted the paired
643            // arm-discriminator sites in this method. Every arm-family
644            // partition the gate keys off — load-family (`LoadModule`),
645            // cleanup-family (`SoftPurge | Purge`), terminal-fallback
646            // (`Restart`) — now consults exactly one typed dispatch on
647            // the substrate primitive, so a future sixth arm added to
648            // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
649            // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
650            // a `CanaryTraffic` split-traffic variant the M4 CR
651            // materializer could resolve per-CR — INSPIRATIONS §II.4)
652            // migrates as a single enum-declaration edit through the
653            // derive rather than a scattered per-consumer rewrite. The
654            // partition invariant is pinned by
655            // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
656            // and the byte-identity of this dispatch against the pre-lift
657            // `matches!` pattern by
658            // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
659            if instr.is_load_module() {
660                loaded = true;
661            } else if instr.is_cleanup() && !loaded {
662                return Err(UpgradeError::purge_without_prior_load(
663                    self.prior_versao(),
664                    instr.lisp_form(),
665                    instr
666                        .declared_module()
667                        .expect("is_cleanup() implies declared_module() is Some"),
668                ));
669            }
670        }
671        Ok(())
672    }
673
674    /// Reject an entry whose `(:state-change …)` appears after any
675    /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
676    /// list — completing the canonical OTP appup `code:load_module/1`
677    /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
678    /// chain on the typed `:upgrade-from` slot.
679    ///
680    /// `StateChange` is the `gen_server:code_change/3` analog
681    /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
682    /// verbatim: "State migration uses `gen_server:code_change/3` …
683    /// migrate state from v0.1.0 shape to current shape"). The
684    /// callback's input is the *prior* version's state shape, which
685    /// only exists while the prior code is still resident — the running
686    /// `gen_server` processes hold the v0.1.0 state, and the operator's
687    /// dispatch invokes `code_change/3` to fold that state into the
688    /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
689    /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
690    /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
691    /// *old* module after the new one is resident. The operator runs
692    /// instructions in declared order (module doc), so a cleanup ahead
693    /// of a state-change discards the prior code before the migration
694    /// fold runs against the state it held — the canonical OTP error
695    /// mode "`code_change/3` invoked on a purged module" the
696    /// `release_handler` enforces by always emitting the migration
697    /// callback before the soft-purge step.
698    ///
699    /// `systools`-generated `.relup` files always emit `code_change`
700    /// before `soft_purge` for this reason; the appup cookbook's
701    /// canonical pattern (`[{load_module, m}, {update, m, soft},
702    /// {soft_purge, m}]`) places the migration-triggering `update`
703    /// strictly between the load and the cleanup. The caixa module
704    /// doc pins the same canonical order verbatim — `(:load-module
705    /// …) (:state-change …) (:soft-purge …)` — and this gate makes
706    /// that ordering a structural property at build time. Three
707    /// authoring footguns close here:
708    ///
709    ///   - `((:load-module "x") (:soft-purge "x-old") (:state-change
710    ///     "lib/m.lisp"))` — the right-instructions-wrong-order
711    ///     footgun on the migrate ↔ cleanup axis. Because the operator
712    ///     executes in declared order, the cleanup drains the v0.1.0
713    ///     module to nothing before the migration callback runs, and
714    ///     the script either no-ops (no v0.1.0 state left to fold) or
715    ///     crashes (`code_change/3` invoked on an unloaded version).
716    ///     The canonical order is `(:load-module …) (:state-change
717    ///     …) (:soft-purge …)` (module doc example).
718    ///   - `((:load-module "x") (:purge "x-old") (:state-change
719    ///     "lib/m.lisp"))` — same shape on the more catastrophic
720    ///     `:purge` variant. The immediate-discard semantic destroys
721    ///     v0.1.0 state mid-request; the trailing migration script
722    ///     has nothing to fold from and the `gen_server` processes that
723    ///     held v0.1.0 state were killed by the `:purge`.
724    ///   - `((:load-module "x") (:soft-purge "x-old") (:state-change
725    ///     "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
726    ///     sandwiched between two cleanups" footgun. The first
727    ///     cleanup discards v0.1.0; the migration runs against
728    ///     drained state; the second cleanup is irrelevant. The first
729    ///     cleanup → state-change boundary is the load-bearing defect
730    ///     surfaced.
731    ///
732    /// Same within-entry cross-instruction discipline as
733    /// [`Self::validate_state_change_ordering`] (the load → state-
734    /// change ordering gate it runs after) and
735    /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
736    /// gate it runs after): all three close one boundary of the OTP
737    /// canonical sequence `code:load_module/1` →
738    /// `gen_server:code_change/3` → `code:soft_purge/1`. The
739    /// state-change-ordering gate closes the load → migrate boundary;
740    /// the purge-ordering gate closes the load → cleanup boundary;
741    /// this gate closes the migrate → cleanup boundary, completing
742    /// the typed coverage of the canonical sequence. Runs *after*
743    /// [`Self::validate_purge_ordering`] (and therefore after
744    /// [`Self::validate_state_change_ordering`]) so an entry like
745    /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
746    /// which violates *both* the purge-without-load gate and this
747    /// state-change-after-cleanup gate — surfaces the more-
748    /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
749    /// defect is load-bearing; once a coherent `(:load-module …)`
750    /// precedes both, the migrate ↔ cleanup ordering becomes the
751    /// next live defect). Runs *before* the per-instruction-class
752    /// singularity gates ([`Self::validate_load_singularity`],
753    /// [`Self::validate_state_change_singularity`],
754    /// [`Self::validate_cleanup_singularity`]) so an entry like
755    /// `((:load-module "x") (:soft-purge "x-old") (:state-change
756    /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
757    /// *both* this ordering gate and the state-change-singularity
758    /// gate — surfaces the ordering defect first; the canonical
759    /// "ordering before singularity" precedence the peer
760    /// `validate_state_change_ordering` / `validate_purge_ordering`
761    /// gates already establish.
762    ///
763    /// Detection: linear scan of the instructions list with a
764    /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
765    /// recording the first cleanup encountered; on any subsequent
766    /// `StateChange` the gate fires with the script + the prior
767    /// cleanup's kind/module. Diagnostic-order pin: the first
768    /// colliding state-change-after-cleanup pair surfaces, not the
769    /// last — mirrors every peer ordering gate's first-collision
770    /// posture ([`Self::validate_state_change_ordering`] returns on
771    /// the first `StateChange` without prior load,
772    /// [`Self::validate_purge_ordering`] on the first cleanup
773    /// without prior load).
774    fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
775        let mut prior_cleanup: Option<(&str, &'static str)> = None;
776        for instr in self.instructions() {
777            // Route the per-instruction cleanup-family arm-discriminator
778            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
779            // predicate rather than the raw
780            // `UpgradeInstruction::SoftPurge { module } |
781            // UpgradeInstruction::Purge { module }` open-coded per-arm
782            // union pattern-match — the second of three within-entry
783            // cross-instruction cleanup-facing gates the peer
784            // [`Self::validate_purge_ordering`] routing already lifted;
785            // both now key off exactly one typed dispatch on the substrate
786            // primitive so the "which arms belong to the cleanup family"
787            // question resolves at exactly one caixa-core edit. The
788            // sticky-once latch's `:module` scalar is routed through the
789            // sibling [`UpgradeInstruction::declared_module`] accessor
790            // rather than the raw pattern-bound `module.as_str()`
791            // projection, with the `is_cleanup`-implies-`declared_module`-
792            // is-`Some` composition pin at
793            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
794            // making the `.expect(…)` structurally infallible at build
795            // time.
796            if instr.is_cleanup() && prior_cleanup.is_none() {
797                prior_cleanup = Some((
798                    instr
799                        .declared_module()
800                        .expect("is_cleanup() implies declared_module() is Some"),
801                    instr.lisp_form(),
802                ));
803            } else if let Some(script) = instr.declared_path()
804                && let Some((prior_module, prior_kind)) = prior_cleanup
805            {
806                // Route the per-instruction `StateChange`-arm script-path
807                // projection through the sibling lifted
808                // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
809                // accessor rather than the raw
810                // `if let UpgradeInstruction::StateChange { script } = instr`
811                // open-coded pattern-match — the last unlifted per-
812                // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
813                // inside `impl UpgradeFromEntry`, sibling to the four peer
814                // per-`UpgradeInstruction` consumers already routed through
815                // the accessor: [`UpgradeInstruction::validate`]'s per-
816                // `StateChange` sandbox-path fan-out, the layout-side per-
817                // `StateChange` script-existence fan-out at
818                // [`crate::layout::StandardLayout::verify`]
819                // (caixa-core/src/layout.rs:1058), the within-entry
820                // [`UpgradeFromEntry::validate_state_change_singularity`]
821                // per-`StateChange` script-projection fan-out, and the
822                // cross-slot
823                // [`validate_upgrade_from_against_behavior`]
824                // per-`StateChange` detection loop. Byte-equal today
825                // (`declared_path` returns `Some(script)` iff the
826                // instruction is [`UpgradeInstruction::StateChange`], per
827                // the sibling `declared_path_only_for_state_change` pin),
828                // so a state-change-after-cleanup surfaces
829                // `StateChangeAfterCleanup` byte-identical to the pattern-
830                // match shape. Any future accessor extension that promotes
831                // an additional variant onto the `PathBuf`-carrying axis
832                // reaches this gate through one caixa-core edit rather
833                // than a coordinated rewrite of five call sites — the
834                // migrate→cleanup ordering discipline extends to the
835                // promoted variant by construction. Same "one typed
836                // dispatch on the substrate primitive, thin projections at
837                // each consumer" trajectory the sibling
838                // [`UpgradeInstruction::declared_module`] `String`-axis
839                // per-variant unifier already established.
840                return Err(UpgradeError::state_change_after_cleanup(
841                    self.prior_versao(),
842                    script,
843                    prior_kind,
844                    prior_module,
845                ));
846            }
847        }
848        Ok(())
849    }
850
851    /// Reject an entry whose `:instructions` list names the same module
852    /// as the target of more than one cleanup instruction (`:soft-purge`
853    /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
854    /// module) axis, narrowed to the cleanup class.
855    ///
856    /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
857    /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
858    /// `code:load_module/1` — load v2 alongside v1 … 2.
859    /// `code:soft_purge/1` — wait until no process is running v1, then
860    /// discard. (`code:purge/1` kills v1 immediately if you don't
861    /// care.)"). The author picks *one* cleanup semantic per old
862    /// module — `:soft-purge` (preferred: waits for in-flight callers
863    /// to drain) or `:purge` (when the drain isn't possible) — and the
864    /// operator runs that one in declared order alongside any other
865    /// distinct-module cleanups. systools-generated `.relup` files
866    /// always emit at most one purge per module for this reason; any
867    /// retry / fallback decision is the operator's job on
868    /// instruction failure, not authored into the entry. Three
869    /// authoring footguns close here:
870    ///
871    ///   - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
872    ///     — the "I copy-pasted the cleanup line twice" footgun. The
873    ///     second `:soft-purge` is a no-op (the module is already gone
874    ///     after the first drain-and-discard) or undefined depending
875    ///     on the operator's handling of a non-resident-module purge
876    ///     request; either way the second instruction carries no
877    ///     observable semantic, far from the source caixa.lisp.
878    ///   - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
879    ///     — the "soft-then-hard fallback" footgun. The author wrote
880    ///     "drain, and if drain didn't clean it up, force-discard",
881    ///     but the operator runs instructions unconditionally in
882    ///     declared order — the `:purge` fires whether the
883    ///     `:soft-purge` already discarded the module or not, so the
884    ///     fallback semantic the author imagined is missing; the
885    ///     pair is incoherent (drain *and* force-discard semantics
886    ///     on one module is two contradictory dispositions). The
887    ///     operator's failure-handling surface is its own
888    ///     responsibility: if `:soft-purge` doesn't drain within its
889    ///     cooldown the operator escalates, not the author's entry.
890    ///   - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
891    ///     — same shape on the reversed ordering. The `:purge`
892    ///     discards immediately; the trailing `:soft-purge` has no
893    ///     module to drain.
894    ///
895    /// Same within-entry exclusivity discipline as
896    /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
897    /// exclusivity gate it joins on the per-module cleanup axis): both
898    /// reject an `:instructions` list whose instructions are
899    /// individually well-shaped but jointly incoherent on a chosen
900    /// semantic axis (restart-fallback for the whole entry there;
901    /// cleanup-semantic for one module here), at the typed build
902    /// surface rather than as a runtime surprise. Runs *after*
903    /// [`Self::validate_purge_ordering`] (the load-before-cleanup
904    /// ordering gate) so an entry like `((:soft-purge "x-old")
905    /// (:soft-purge "x-old"))` surfaces the more-fundamental
906    /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
907    /// the missing-load defect is the load-bearing one — the duplicate
908    /// is meaningless either way without the preceding load).
909    ///
910    /// Same set-not-multiset discipline applied to every peer
911    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
912    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
913    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
914    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
915    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
916    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
917    /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
918    /// Each closes the same authoring footgun: a Vec authoring surface
919    /// that silently accepts duplicate entries and renders the "second
920    /// wins" (or "operator processes both, second is a no-op or
921    /// errors") shape downstream, far from the source caixa.lisp.
922    /// This gate extends the discipline onto the within-entry
923    /// instruction-target axis — duplicate cleanup targets *within*
924    /// one `:upgrade-from` entry — the peer of the cross-entry
925    /// duplicate-`:from` axis at one level of nesting deeper.
926    ///
927    /// Detection: linear scan of the instructions list collecting
928    /// the (module, kind) pair from every `SoftPurge` / `Purge`
929    /// encountered; on the second occurrence of any module the gate
930    /// fires with the prior kind and the colliding kind in declaration
931    /// order. Diagnostic-order pin: the first colliding pair surfaces,
932    /// not the last — mirrors
933    /// [`validate_upgrade_from`]'s
934    /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
935    /// posture (the first detected collision wins) and every peer
936    /// duplicate gate's first-collision discipline.
937    fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
938        let mut seen: Vec<(&str, &'static str)> = Vec::new();
939        for instr in self.instructions() {
940            // Route the per-instruction cleanup-family arm-discriminator
941            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
942            // predicate rather than the raw two-arm
943            // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
944            // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
945            // `UpgradeInstruction::Purge { module } => (module.as_str(),
946            // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
947            // per-arm dispatch — the third of three within-entry cross-
948            // instruction cleanup-facing gates the peer
949            // [`Self::validate_purge_ordering`] +
950            // [`Self::validate_state_change_before_cleanup`] routing
951            // already lifted; all three now key off exactly one typed
952            // dispatch on the substrate primitive, structurally. The
953            // cleanup-target `(module, kind)` pair is projected through
954            // the peer [`UpgradeInstruction::declared_module`] /
955            // [`UpgradeInstruction::lisp_form`] accessors rather than
956            // the per-arm-hand-rolled scalar-value + kind-const pair,
957            // with the `is_cleanup`-implies-`declared_module`-is-`Some`
958            // composition pin at
959            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
960            // making the `.expect(…)` structurally infallible at build
961            // time. Any future fifth cleanup-shaped variant added under
962            // the `is_cleanup` predicate + registered through the peer
963            // `lisp_form` per-arm kebab-case-const dispatch reaches this
964            // dedup gate through the accessor's one body rather than a
965            // fourth per-arm-hand-rolled scalar/kind projection here.
966            if !instr.is_cleanup() {
967                continue;
968            }
969            let module = instr
970                .declared_module()
971                .expect("is_cleanup() implies declared_module() is Some");
972            let kind = instr.lisp_form();
973            if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
974                let prior_kind = seen[prior_idx].1;
975                return Err(UpgradeError::DuplicateCleanup {
976                    from: self.prior_versao().to_string(),
977                    module: module.to_string(),
978                    kinds: vec![prior_kind, kind],
979                });
980            }
981            seen.push((module, kind));
982        }
983        Ok(())
984    }
985
986    /// Reject an entry whose `:instructions` list names the same module
987    /// as the target of more than one `(:load-module …)` instruction —
988    /// set-not-multiset on the `LoadModule` axis.
989    ///
990    /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
991    /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
992    /// new code is 'current', old code is 'old'."). The instruction
993    /// brings the new wasm component up resident alongside the old
994    /// one so the operator can route new traffic to the new code
995    /// while in-flight callers drain on the old — and the operator's
996    /// dispatch table reads the module *name* (a caixa name) to bind
997    /// the component, so two `(:load-module "x")` instructions in one
998    /// entry ask the operator to re-bind the same component twice.
999    /// `systools`-generated `.relup` files emit at most one
1000    /// `load_module` per module per upgrade step for this reason; the
1001    /// second load has no observable semantic relative to the first
1002    /// (the component is already resident). Three authoring footguns
1003    /// close here:
1004    ///
1005    ///   - `((:load-module "x") (:load-module "x"))` — the "I
1006    ///     copy-pasted the load line twice" footgun. The second
1007    ///     `:load-module` re-reads the same module name and re-binds
1008    ///     the same wasm component — a no-op in both directions
1009    ///     (no new code becomes resident; no old code is purged) —
1010    ///     and any cleanup / migration the author intended for a
1011    ///     *distinct* module is silently absent from the entry.
1012    ///   - `((:load-module "x") (:load-module "x") (:state-change …))`
1013    ///     — the "I meant to load two distinct modules" typo. The
1014    ///     author intended `((:load-module "x") (:load-module "y"))`
1015    ///     but renamed both to "x" (or copied the first line and
1016    ///     forgot to change the module). The migration runs against
1017    ///     code that's resident only on one module name, and the
1018    ///     second module the author imagined was being loaded never
1019    ///     comes up at all — far from the source caixa.lisp.
1020    ///   - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1021    ///     — same shape with a trailing cleanup. The duplicate load
1022    ///     is dead code; the cleanup still fires correctly, masking
1023    ///     the load-side duplication as a silently-passing entry.
1024    ///
1025    /// Same within-entry exclusivity discipline as
1026    /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1027    /// singularity gate this runs beside) on the sibling
1028    /// `LoadModule` axis: both reject an `:instructions` list whose
1029    /// instructions are individually well-shaped but jointly
1030    /// incoherent on a per-module-per-class basis (load-once for the
1031    /// load axis here; cleanup-once for the cleanup axis there), at
1032    /// the typed build surface rather than as a runtime surprise.
1033    /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1034    /// before-cleanup ordering gate) so an entry like
1035    /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1036    /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1037    /// first (the missing-load defect is load-bearing — the migration
1038    /// runs against unloaded code; the duplicate is meaningless either
1039    /// way without the preceding load). Runs *before*
1040    /// [`Self::validate_cleanup_singularity`] so an entry like
1041    /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1042    /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1043    /// the load axis precedes the cleanup axis in the canonical OTP
1044    /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1045    /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1046    /// before `SoftPurge`/`Purge`), so the load-side singularity is
1047    /// the load-bearing diagnostic when both fire.
1048    ///
1049    /// Same set-not-multiset discipline applied to every peer
1050    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1051    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1052    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1053    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1054    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1055    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1056    /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1057    /// the per-module cleanup-target axis (9cedd8b —
1058    /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1059    /// discipline onto the within-entry `LoadModule` instruction-target
1060    /// axis — the third within-entry per-module singularity completing
1061    /// the load+cleanup pair across the OTP two-phase code-load
1062    /// contract.
1063    ///
1064    /// Detection: linear scan of the instructions list collecting the
1065    /// module name from every `LoadModule` encountered; on the second
1066    /// occurrence of any module the gate fires. Diagnostic-order pin:
1067    /// the first colliding occurrence surfaces, not the last — mirrors
1068    /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1069    /// and every peer duplicate gate's first-collision discipline.
1070    fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1071        let mut seen: Vec<&str> = Vec::new();
1072        for instr in self.instructions() {
1073            // Route the per-instruction load-family arm-discriminator
1074            // through the `gen_platform::IsVariant`-derive-generated
1075            // [`UpgradeInstruction::is_load_module`] predicate rather
1076            // than the raw single-arm `match instr {
1077            // UpgradeInstruction::LoadModule { module } =>
1078            // module.as_str(), _ => continue }` open-coded pattern-
1079            // match — closes the last unlifted `matches!`-shaped
1080            // per-arm-hand-rolled scalar-value + arm-discriminator
1081            // pair inside `impl UpgradeFromEntry`, sibling of the
1082            // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1083            // routing already lifted onto the two-arm cleanup-family
1084            // axis's per-arm arm-discriminator + `:module` projection
1085            // dispatch. The load-target `:module` scalar is projected
1086            // through the sibling [`UpgradeInstruction::declared_module`]
1087            // accessor rather than the per-arm-hand-rolled scalar-
1088            // value binding, with the
1089            // `is_load_module`-implies-`declared_module`-is-`Some`
1090            // composition pin at
1091            // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1092            // making the `.expect(…)` structurally infallible at
1093            // build time. Every arm-family partition the three
1094            // within-entry per-instruction-class singularity gates
1095            // key off — load-family
1096            // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1097            // ([`UpgradeInstruction::SoftPurge`] |
1098            // [`UpgradeInstruction::Purge`]), migration-family
1099            // ([`UpgradeInstruction::StateChange`]) — now consults
1100            // exactly one typed dispatch on the substrate primitive
1101            // (`is_load_module()` here, `is_cleanup()` at
1102            // [`Self::validate_cleanup_singularity`],
1103            // `declared_path()` at
1104            // [`Self::validate_state_change_singularity`]), so a
1105            // future sixth arm added to [`UpgradeInstruction`] (an
1106            // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1107            // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1108            // split-traffic variant the M4 CR materializer could
1109            // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1110            // single enum-declaration edit through the derive rather
1111            // than a scattered per-consumer rewrite. Byte-identity of
1112            // this dispatch against the pre-lift match-pattern is
1113            // pinned by
1114            // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1115            if !instr.is_load_module() {
1116                continue;
1117            }
1118            let module = instr
1119                .declared_module()
1120                .expect("is_load_module() implies declared_module() is Some");
1121            if seen.contains(&module) {
1122                return Err(UpgradeError::duplicate_load_module(
1123                    self.prior_versao(),
1124                    module,
1125                ));
1126            }
1127            seen.push(module);
1128        }
1129        Ok(())
1130    }
1131
1132    /// Reject an entry whose `:instructions` list names the same script
1133    /// as the target of more than one `(:state-change …)` instruction —
1134    /// set-not-multiset on the `StateChange` axis.
1135    ///
1136    /// `StateChange` is the `gen_server:code_change/3` analog
1137    /// (INSPIRATIONS §II.4: "State migration uses
1138    /// `gen_server:code_change/3`"). The instruction folds the *old*
1139    /// state into the shape the *new* code expects — a one-shot
1140    /// transition from one declared state representation to another.
1141    /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1142    /// exactly once per upgrade per `gen_server`; `systools`-generated
1143    /// `.relup` files emit at most one `code_change` per `gen_server` per
1144    /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1145    /// instruction targeting the same script in one entry re-runs the
1146    /// migration fold — at best a no-op (idempotent script masking a
1147    /// typo where the author intended two distinct scripts) and at
1148    /// worst silent state corruption (non-idempotent fold double-
1149    /// applied: an `add column` migration that runs twice, an
1150    /// `increment counter` that double-bumps, a `rename field` that
1151    /// renames-then-fails the second time). Three authoring footguns
1152    /// close here:
1153    ///
1154    ///   - `((:load-module "x") (:state-change "lib/m.lisp")
1155    ///     (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1156    ///     migration line twice" footgun. The second `:state-change`
1157    ///     re-runs the same fold on the already-migrated state — a
1158    ///     no-op if the script is idempotent (dead code masking the
1159    ///     duplication) or state corruption if not (the migration's
1160    ///     pre-condition no longer holds because the post-condition is
1161    ///     already in place).
1162    ///   - `((:load-module "x") (:state-change "lib/m.lisp")
1163    ///     (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1164    ///     "duplicate migrate masked by trailing cleanup" footgun. The
1165    ///     cleanup still fires correctly, masking the migration-side
1166    ///     duplication as a silently-passing entry.
1167    ///   - `((:load-module "x") (:state-change "lib/m1.lisp")
1168    ///     (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1169    ///     two distinct modules" typo. The author intended
1170    ///     `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1171    ///     but renamed both to `m1.lisp` (or copy-pasted the first line
1172    ///     and forgot to change the script). The migration that should
1173    ///     have folded the second module's state never runs, far from
1174    ///     the source caixa.lisp.
1175    ///
1176    /// Same within-entry exclusivity discipline as
1177    /// [`Self::validate_load_singularity`] (the per-module load-
1178    /// singularity gate it runs after) and
1179    /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1180    /// singularity gate it runs before) on the sibling `StateChange`
1181    /// axis: each rejects an `:instructions` list whose instructions
1182    /// are individually well-shaped but jointly incoherent on a per-
1183    /// instruction-class basis (load-once per module for the load
1184    /// axis; migrate-once per script for the migration axis here;
1185    /// cleanup-once per module for the cleanup axis), at the typed
1186    /// build surface rather than as a runtime surprise. Runs *after*
1187    /// [`Self::validate_load_singularity`] so an entry like
1188    /// `((:load-module "x") (:load-module "x") (:state-change
1189    /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1190    /// `DuplicateLoadModule` first — the load axis precedes the
1191    /// migration axis in the canonical OTP sequence
1192    /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1193    /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1194    /// `StateChange`), so the load-side singularity is the load-
1195    /// bearing diagnostic when both fire. Runs *before*
1196    /// [`Self::validate_cleanup_singularity`] so an entry like
1197    /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1198    /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1199    /// surfaces `DuplicateStateChange` first — the migration axis
1200    /// precedes the cleanup axis in the canonical OTP sequence
1201    /// (`code:code_change/3` then `code:soft_purge/1`) and in
1202    /// [`UpgradeInstruction`] declaration order (`StateChange` before
1203    /// `SoftPurge`/`Purge`).
1204    ///
1205    /// Same set-not-multiset discipline applied to every peer
1206    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1207    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1208    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1209    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1210    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1211    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1212    /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1213    /// per-module cleanup-target axis (9cedd8b —
1214    /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1215    /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1216    /// This gate extends the discipline onto the within-entry
1217    /// `StateChange` instruction-target axis — the third within-entry
1218    /// per-instruction-class singularity, completing the OTP two-phase
1219    /// code-load + state-migration coverage triad
1220    /// (`code:load_module/1` → `gen_server:code_change/3` →
1221    /// `code:soft_purge/1`).
1222    ///
1223    /// Detection: linear scan of the instructions list collecting the
1224    /// script path from every `StateChange` encountered; on the second
1225    /// occurrence of any script the gate fires. Diagnostic-order pin:
1226    /// the first colliding occurrence surfaces, not the last — mirrors
1227    /// [`Self::validate_load_singularity`]'s and
1228    /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1229    /// and every peer duplicate gate's first-collision discipline.
1230    fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1231        // Route the per-instruction `StateChange`-arm script-path
1232        // projection through the sibling lifted
1233        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1234        // accessor rather than the raw
1235        // `match instr { UpgradeInstruction::StateChange { script } =>
1236        // script.as_path(), _ => continue }` open-coded pattern-match —
1237        // the third within-entry singularity gate's per-instruction
1238        // script-projection site now keys off exactly one typed
1239        // dispatch on the substrate primitive's `PathBuf`-carrying
1240        // axis, sibling to the four peer per-`UpgradeInstruction`
1241        // consumers ([`Self::validate`]'s per-`StateChange`
1242        // sandbox-path fan-out, the layout-side per-`StateChange`
1243        // script-existence fan-out at
1244        // `caixa-core/src/layout.rs:1017`, the cross-slot
1245        // [`validate_upgrade_from_against_behavior`] gate's
1246        // per-`StateChange` detection loop, the future wasm-operator's
1247        // per-`StateChange` runtime hook-dispatch) that already route
1248        // through `declared_path` / `declared_module`. Byte-equal
1249        // today (`declared_path` returns `Some(script)` iff the
1250        // instruction is [`UpgradeInstruction::StateChange`], per the
1251        // sibling `declared_path_only_for_state_change` pin), so a
1252        // duplicate `:state-change` script surfaces
1253        // `DuplicateStateChange` byte-identical to the pattern-match
1254        // shape. Same "one typed dispatch on the substrate primitive,
1255        // thin projections at each consumer" discipline the sibling
1256        // [`UpgradeInstruction::declared_module`] accessor established
1257        // (b13c4f9) on the peer `String`-carrying axis's per-variant
1258        // consumers, extended here onto the last unlifted
1259        // pattern-match on the `PathBuf`-carrying axis inside
1260        // `impl UpgradeFromEntry`.
1261        let mut seen: Vec<&std::path::Path> = Vec::new();
1262        for instr in self.instructions() {
1263            let Some(script) = instr.declared_path() else {
1264                continue;
1265            };
1266            let script = script.as_path();
1267            if seen.contains(&script) {
1268                return Err(UpgradeError::duplicate_state_change(
1269                    self.prior_versao(),
1270                    script,
1271                ));
1272            }
1273            seen.push(script);
1274        }
1275        Ok(())
1276    }
1277}
1278
1279/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1280/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1281/// invariant — at most one `(:from <prior>)` block per parsed semver.
1282///
1283/// OTP's appup picks at most one matching block to apply to the running
1284/// release (`release_handler:install_release/1` matches the loaded
1285/// `:from` against the currently-running version and executes the
1286/// associated instruction sequence; the wasm-operator picks the matching
1287/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1288/// the same parsed-semver `:from` are an ambiguous edge in the typed
1289/// upgrade graph — the operator can pick either set deterministically,
1290/// but each set may carry different `LoadModule | StateChange |
1291/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1292/// non-deterministic relative to the source caixa.lisp. The author's
1293/// intent is one path per prior version; the typed graph must enforce
1294/// that shape.
1295///
1296/// Same set-not-multiset discipline already applied to every peer
1297/// typed-graph axis: `:children :caixa` (dbf50a9 —
1298/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1299/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1300/// `AplicacaoError::MembroDuplicate`), `:contratos`
1301/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1302/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1303/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1304/// Each closes the same authoring footgun: a Vec authoring surface that
1305/// silently accepts duplicate entries and renders the "second wins"
1306/// (or "operator picks arbitrarily") shape downstream, far from the
1307/// source caixa.lisp.
1308///
1309/// Duplicates are detected by [`semver::Version`] equality (the
1310/// crate's `PartialEq` compares the full identity — major.minor.patch +
1311/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1312/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1313/// The conservative choice mirrors what the wasm-operator's
1314/// `:from`-match dispatch can see; collapsing build metadata to catch
1315/// a wider net of duplicates is a future tightening that requires
1316/// coordinating with the operator's match step.
1317///
1318/// Per-entry shape errors fire before the duplicate gate so the
1319/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1320/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1321/// errors into a single duplicate diagnostic. Mirrors the
1322/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1323/// axis ([`crate::SupervisorSpec::validate`],
1324/// [`crate::AplicacaoSpec::validate_membros`],
1325/// [`crate::AplicacaoSpec::validate_placement`]).
1326pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1327    use semver::Version;
1328    let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1329    for entry in entries {
1330        entry.validate()?;
1331        // `entry.validate()` accepted this `:from`, so parse cannot
1332        // fail here — the FromInvalid arm above is the only gate
1333        // and both call `Version::parse(entry.prior_versao())`.
1334        let parsed = Version::parse(entry.prior_versao()).expect(
1335            "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1336             two gates aligned",
1337        );
1338        if seen.contains(&parsed) {
1339            return Err(UpgradeError::duplicate_from(entry));
1340        }
1341        seen.push(parsed);
1342    }
1343    Ok(())
1344}
1345
1346/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1347/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1348/// same ordering [`semver::Version::cmp`] implements, with build
1349/// metadata ignored per [SemVer §11][semver-11]).
1350///
1351/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1352/// declarative answer to "given the wasm-operator is loading a node
1353/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1354/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1355/// semantic). The operator's `:from`-match dispatch loads the
1356/// current `:versao` and matches the *running* version against each
1357/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1358/// unreachable — the operator never runs a version greater than or
1359/// equal to the current `:versao` that it could then "upgrade *to*"
1360/// the current `:versao`. Two authoring footguns close here:
1361///
1362///   - `:from > :versao` (downgrade-shaped) — the canonical
1363///     "I copy-pasted from the next minor version and forgot to bump
1364///     `:versao`" / "I bumped `:versao` then reverted but left the
1365///     `:upgrade-from` entry behind" footgun. Until this gate landed
1366///     `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1367///     silently passed `feira build` and the wasm-operator's
1368///     `:from`-match dispatch would never fire on the entry — the
1369///     instructions sat dormant in the caixa.lisp forever, the
1370///     author's intent ("upgrade users coming from 0.2.0") permanently
1371///     unreached because they actually meant to bump `:versao`.
1372///
1373///   - `:from == :versao` (precedence-equal self-upgrade) — the
1374///     "I declared an upgrade from myself to myself" no-op the
1375///     operator's dispatch would either skip silently (no semantic
1376///     transition) or attempt and trivially "succeed" with no
1377///     observable state change. Includes the build-metadata-only
1378///     difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1379///     SemVer-2 precedence ignores build metadata so they compare
1380///     equal under [`semver::Version::cmp`] — the gate rejects this
1381///     even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1382///     gate uses derived `PartialEq` which keeps them distinct;
1383///     they're distinct dispatch keys but the same "from" version
1384///     for our purposes here).
1385///
1386/// Same cross-slot value-shape discipline as
1387/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1388/// partition (934bc58 — the typed partition between two declared
1389/// slots): one slot's value constrains the valid set of another's,
1390/// and the constraint is a structural property visible at validate
1391/// time. The validated set after this gate satisfies
1392/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1393/// for every entry, so the future operator-side hot-upgrade dispatch
1394/// step can reach for `entry.from` knowing the precedence relation
1395/// holds without re-deriving it from inline checks.
1396///
1397/// Silent-pass semantics on malformed inputs:
1398///
1399///   - When `versao` itself doesn't parse as semver, this gate
1400///     returns `Ok(())` silently — the narrower
1401///     [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1402///     diagnostics are the load-bearing surfaces for those failure
1403///     modes, and surfacing a `FromNotBeforeVersao` over an
1404///     unparseable `:versao` would mask the more actionable root
1405///     cause.
1406///   - Likewise, an entry whose `:from` itself doesn't parse falls
1407///     through to its narrower diagnostic surface
1408///     ([`UpgradeError::FromInvalid`]), which is expected to fire
1409///     via [`validate_upgrade_from`] *before* this gate runs at the
1410///     [`crate::LayoutInvariants`] call site.
1411///
1412/// [semver-11]: https://semver.org/#spec-item-11
1413pub fn validate_upgrade_from_against_versao(
1414    entries: &[UpgradeFromEntry],
1415    versao: &str,
1416) -> Result<(), UpgradeError> {
1417    use semver::Version;
1418    let Ok(current) = Version::parse(versao) else {
1419        // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1420        // surfacing a precedence-relation diagnostic over an unparseable
1421        // top-level version would mask the more actionable root cause.
1422        return Ok(());
1423    };
1424    for entry in entries {
1425        // Per-entry shape — including a malformed `:from` — is gated
1426        // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1427        // upstream at the LayoutInvariants call site; an unparseable
1428        // `:from` here falls through silently to keep the
1429        // FromInvalid diagnostic load-bearing. Same fall-through
1430        // posture as the `versao` arm above.
1431        let Ok(prior) = Version::parse(entry.prior_versao()) else {
1432            continue;
1433        };
1434        if prior >= current {
1435            return Err(UpgradeError::from_not_before_versao(
1436                entry.prior_versao(),
1437                versao,
1438            ));
1439        }
1440    }
1441    Ok(())
1442}
1443
1444/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1445/// `(:state-change <script>)` instruction unless the caixa also declares
1446/// `:behavior :on-state-change` — the runtime callback the per-version
1447/// migration script is delivered through during hot upgrade.
1448///
1449/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1450/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1451/// callback to deliver state migration during hot upgrades." The peer
1452/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1453/// promise from the callback side: the slot is the
1454/// `gen_server:code_change/3` analog — "receives old state + version,
1455/// returns new state. Composes with the `:upgrade-from` slot declared at
1456/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1457/// the composition by invoking the running `gen_server`'s
1458/// `code_change/3` callback during the appup's `code_change` /
1459/// `update, m, soft` step — the appup's instruction triggers the
1460/// callback, the callback folds the prior-version state shape into the
1461/// current-version shape, and the operator advances to the next
1462/// instruction only after the callback returns successfully. caixa
1463/// decomposes the same composition into two typed slots: the per-version
1464/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1465/// instruction's `:script` (the `:upgrade-from` author surface), and the
1466/// runtime hook the operator dispatches the migration through lives in
1467/// the `:behavior :on-state-change` callback (the `:behavior` author
1468/// surface). A `:state-change` instruction declared without the callback
1469/// is half the composition: the per-version script the author wrote has
1470/// no runtime delivery path, and the operator's hot-upgrade dispatch
1471/// reaches for `caixa.behavior.on_state_change` at the migration step,
1472/// finds `None`, and either fails the upgrade mid-flight (the
1473/// transactional rollback the module doc names — "On any failure, the
1474/// current version stays load-bearing — a typed atomic upgrade") or
1475/// silently skips the migration depending on the operator's handling of
1476/// a missing callback, both far from the source caixa.lisp.
1477///
1478/// Two authoring footguns close here:
1479///
1480///   - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1481///     :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1482///     (:soft-purge "x-old")))))` — the "I declared the migration script
1483///     but forgot the callback" footgun. The author wrote the per-version
1484///     fold against the prior state shape, the typed `:upgrade-from`
1485///     slot validated every per-instruction shape + ordering + singularity
1486///     gate, and the missing callback only surfaces at upgrade time as
1487///     either a transactional rollback to the prior version (no progress
1488///     across the upgrade) or as a silently-skipped migration that leaves
1489///     v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1490///     state shape).
1491///   - `:behavior` absent entirely + `:upgrade-from` carrying any
1492///     `:state-change` — the "I added the upgrade path but never declared
1493///     `:behavior`" footgun. `:behavior` is optional at the typed root
1494///     ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1495///     `:upgrade-from` slot validates on its own merits, but a `Caixa`
1496///     with `behavior: None` and a `:state-change` instruction is the
1497///     same missing-callback shape — the operator's dispatch can't reach
1498///     a callback that doesn't exist.
1499///
1500/// Same cross-slot composition discipline as
1501/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1502/// precedence gate at the peer wire-up site): one slot's value
1503/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1504/// the valid set of another's (the entry must be dispatchable there; the
1505/// callback must be declared here), and the constraint is a structural
1506/// property visible at validate time. The validated set after this gate
1507/// satisfies the documented composition: every `:state-change`
1508/// instruction the operator iterates at hot-upgrade time has a
1509/// corresponding `:on-state-change` callback declared on the same caixa,
1510/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1511/// `release_handler` canonical-sequence loop) can reach for
1512/// `behavior.on_state_change` at the migration step knowing the
1513/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1514/// from inline checks.
1515///
1516/// Diagnostic-precedence:
1517///
1518///   - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1519///     shape + the within-entry ordering / singularity gates) and
1520///     [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1521///     gate), so a malformed `:state-change` (`EmptyScript`,
1522///     `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1523///     (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1524///     duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1525///     self-locating diagnostic first — the canonical "per-instr-shape +
1526///     within-entry ordering + cross-entry uniqueness before
1527///     cross-slot composition" precedence the peer
1528///     `validate_upgrade_from_against_versao` gate establishes at the
1529///     same wire-up site. Without this precedence pin a malformed
1530///     `:state-change` instruction would surface this gate's
1531///     missing-callback diagnostic over the narrower
1532///     `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1533///     load-bearing per-instruction defect with a cross-slot composition
1534///     diagnostic.
1535///   - Within the entries, walks the list in declaration order and
1536///     surfaces the *first* `:state-change` instruction encountered —
1537///     mirrors every peer first-collision diagnostic posture on this
1538///     module (`validate_state_change_ordering` returns on the first
1539///     `StateChange` without prior load,
1540///     `validate_load_singularity` returns on the second matching
1541///     module, etc.). A future entry's later `:state-change` doesn't
1542///     surface a different diagnostic — the missing callback is the same
1543///     defect regardless of which entry's `:state-change` exposes it.
1544///
1545/// Silent-pass semantics:
1546///
1547///   - Entries carrying no `:state-change` instruction (load-only,
1548///     cleanup-only, restart-only, or empty `:instructions`) leave the
1549///     gate vacuous — no per-version migration means no callback to
1550///     dispatch through, so the absence of `:on-state-change` is
1551///     coherent. Pins the gate's identity element on the empty-set side
1552///     of the composition.
1553///   - `behavior: None` is *not* a free pass when a `:state-change`
1554///     instruction is present — the same missing-callback shape as
1555///     `behavior: Some(_)` with `on_state_change: None`. The gate reads
1556///     `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1557///     surface the same diagnostic.
1558pub fn validate_upgrade_from_against_behavior(
1559    entries: &[UpgradeFromEntry],
1560    behavior: Option<&crate::BehaviorSpec>,
1561) -> Result<(), UpgradeError> {
1562    if behavior
1563        .and_then(crate::BehaviorSpec::on_state_change)
1564        .is_some()
1565    {
1566        return Ok(());
1567    }
1568    for entry in entries {
1569        // Route the per-instruction `StateChange`-arm script-path
1570        // projection through the sibling lifted
1571        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1572        // accessor rather than the raw
1573        // `if let UpgradeInstruction::StateChange { script } = instr`
1574        // open-coded pattern-match — the cross-slot
1575        // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1576        // script-projection site now keys off exactly one typed dispatch
1577        // on the substrate primitive's `PathBuf`-carrying axis, sibling
1578        // to the four peer per-`UpgradeInstruction` consumers
1579        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1580        // sandbox-path fan-out, the layout-side per-`StateChange`
1581        // script-existence fan-out at
1582        // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1583        // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1584        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1585        // peer [`UpgradeInstruction::declared_module`] `String`-axis
1586        // per-variant unifier) that already route through
1587        // `declared_path` / `declared_module`. Byte-equal today
1588        // (`declared_path` returns `Some(script)` iff the instruction is
1589        // [`UpgradeInstruction::StateChange`], per the sibling
1590        // `declared_path_only_for_state_change` pin), so a
1591        // `:state-change`-without-`:on-state-change`-callback
1592        // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1593        // byte-identical to the pattern-match shape. Fourth (and last)
1594        // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1595        // axis now routed through the accessor — closes the last
1596        // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1597        // site outside `impl UpgradeFromEntry`, so the peer four
1598        // consumer set named in the sibling
1599        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1600        // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1601        // closed.
1602        for instr in entry.instructions() {
1603            if let Some(script) = instr.declared_path() {
1604                return Err(UpgradeError::state_change_without_on_state_change_callback(
1605                    entry.prior_versao(),
1606                    script,
1607                ));
1608            }
1609        }
1610    }
1611    Ok(())
1612}
1613
1614impl UpgradeInstruction {
1615    /// Kebab-case lisp form name for this instruction, used as the
1616    /// `:kind` tag in [`UpgradeError::ModuleEmpty`] /
1617    /// [`UpgradeError::ModuleInvalid`] diagnostics so the author can
1618    /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)`
1619    /// / `(:purge …)` and fix it in one edit. Mirrors the kebab-case
1620    /// slot tags `BehaviorError::EmptyPath` (b0c8389) and
1621    /// `UpgradeFromEntry`'s `:from` field already carry.
1622    #[must_use]
1623    const fn lisp_form(&self) -> &'static str {
1624        match self {
1625            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1626            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1627            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1628            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1629            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1630        }
1631    }
1632
1633    /// Validate the instruction's typed shape. Path existence is
1634    /// checked separately by [`crate::layout::StandardLayout`].
1635    ///
1636    /// The per-variant scalar the value-shape gates fire against is
1637    /// read through this method's two sibling accessors — the
1638    /// `String`-carrying axis via [`Self::declared_module`] (the
1639    /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1640    /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1641    /// carrying axis via [`Self::declared_path`] (the `StateChange`
1642    /// variant's tatara-lisp `:script`) — rather than the per-arm
1643    /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1644    /// Self::Purge { module }` pattern the module-axis previously
1645    /// open-coded and the per-arm `Self::StateChange { script }` the
1646    /// script-axis previously open-coded. Every scalar this enum
1647    /// carries now flows through one of the two `Option<&…>`
1648    /// accessors, so a future extension of either axis (a fifth
1649    /// module-bearing variant, an operator-side pre-parsed scalar
1650    /// cache the accessors materialize behind the same return
1651    /// contract, an M4 typed sub-slot the accessors could route
1652    /// alongside the existing scalar) migrates as a single edit on
1653    /// the accessor rather than a coordinated rewrite of every
1654    /// downstream value-shape gate. `Restart` (the only variant that
1655    /// carries neither scalar) falls through both `Option` checks and
1656    /// returns `Ok(())` — the terminal-fallback shape the
1657    /// [`Self::Restart`] variant doc pins.
1658    pub fn validate(&self) -> Result<(), UpgradeError> {
1659        if let Some(module) = self.declared_module() {
1660            return validate_module(self.lisp_form(), module);
1661        }
1662        if let Some(script) = self.declared_path() {
1663            // Delegate the four-arm cascade (empty / absolute /
1664            // parent-escape / non-`.lisp`-extension) to the lifted
1665            // [`crate::render::require_sandboxed_lisp_path`] helper —
1666            // same `Empty → Absolute → ParentEscape → NonLispExtension`
1667            // arm-ordering this method previously inlined verbatim,
1668            // now shared with [`crate::BehaviorSpec::validate`]'s
1669            // per-`:on-*`-callback gate so every author-supplied
1670            // tatara-lisp source path on every M2 typed slot consults
1671            // one gate, not two-and-counting verbatim copies of the
1672            // same four-arm cascade. Each closure wraps the tag in
1673            // the same `*Script` variant the original inline code
1674            // raised, so the diagnostic shape every caller depends
1675            // on (the `:state-change :script` self-locating error)
1676            // is preserved by construction. See
1677            // [`crate::render::require_sandboxed_lisp_path`] for the
1678            // smallest-scope-arm-fires-last ordering rationale.
1679            crate::render::require_sandboxed_lisp_path(
1680                script,
1681                || UpgradeError::EmptyScript,
1682                || UpgradeError::absolute_script(script),
1683                || UpgradeError::parent_escape_script(script),
1684                || UpgradeError::non_lisp_extension_script(script),
1685            )?;
1686        }
1687        // `Restart` (the only variant with no `Option<&…>`-carrying
1688        // scalar) falls through both accessor gates and returns
1689        // `Ok(())` — the terminal-fallback shape.
1690        Ok(())
1691    }
1692
1693    /// The `:module` scalar carried by this instruction — the
1694    /// K8s DNS-1123-label OTP-appup caixa-name reference every
1695    /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1696    /// variant declares against, and every author expects `feira lint`
1697    /// to name verbatim in per-instruction diagnostics. Returns `None`
1698    /// on [`Self::StateChange`] (which carries a `:script` — closed by
1699    /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1700    /// (which carries no data at all, the OTP terminal-fallback
1701    /// shape).
1702    ///
1703    /// Sibling in shape to [`Self::declared_path`] on the second and
1704    /// final scalar-carrying axis of [`UpgradeInstruction`]:
1705    /// `declared_path` closes the `PathBuf`-carrying arm
1706    /// (`StateChange`); `declared_module` closes the `String`-carrying
1707    /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1708    /// enum carries now routes through one of the two `Option<&…>`
1709    /// accessors — a caller that doesn't care which variant declared
1710    /// the scalar reads through one `if let Some(…)` rather than a
1711    /// per-variant pattern match. The pair is the enum-variant-
1712    /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1713    /// on the M3 side ([`crate::WitContract::source`] /
1714    /// [`crate::WitContract::destination`] /
1715    /// [`crate::WitContract::world_ref`] closing `:contratos`;
1716    /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1717    /// closing `:entrada`; [`crate::Membro::nome`] /
1718    /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1719    /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1720    /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1721    /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1722    /// closed OTP-shape supervisor family) — those peer accessors
1723    /// return a struct field verbatim; this pair unifies enum-
1724    /// variant-carried scalars into one accessor per typed axis.
1725    ///
1726    /// Byte-for-byte from the typed variant's own `String` storage;
1727    /// no cloning, no re-parsing. A future extension of the axis (an
1728    /// M4 typed sub-slot the module string is derived from, an
1729    /// operator-side pre-parsed caixa-name cache the accessor could
1730    /// materialize behind the same `&str` return contract, a fifth
1731    /// module-bearing OTP-appup variant the enum grows) migrates as
1732    /// a single caixa-core edit rather than a coordinated rewrite
1733    /// of every downstream module-axis consumer (currently
1734    /// [`Self::validate`]'s DNS-1123-label gate through
1735    /// [`validate_module`]; extensible to future consumers on the
1736    /// same axis without further per-variant match sites).
1737    #[must_use]
1738    pub const fn declared_module(&self) -> Option<&str> {
1739        match self {
1740            Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1741                Some(module.as_str())
1742            }
1743            Self::StateChange { .. } | Self::Restart => None,
1744        }
1745    }
1746
1747    /// If the instruction references an on-disk path, return it —
1748    /// used by the layout checker to verify the path resolves.
1749    ///
1750    /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1751    /// on the `String`-carrying axis: `declared_path` closes the
1752    /// `StateChange` arm's `:script`; `declared_module` closes the
1753    /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1754    /// they route every scalar this enum carries through one of two
1755    /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1756    /// gates dispatch on the accessor return rather than a per-variant
1757    /// pattern match on the enum shape itself.
1758    ///
1759    /// Four per-`UpgradeInstruction` consumers now key off this
1760    /// accessor's `PathBuf`-carrying axis:
1761    /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1762    /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1763    /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1764    /// within-entry
1765    /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1766    /// per-`StateChange` script-projection fan-out, and the cross-slot
1767    /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1768    /// :behavior` composition gate's per-`StateChange` detection loop
1769    /// — every downstream consumer of the `PathBuf`-carrying axis
1770    /// reaches through this one dispatch, so a future accessor
1771    /// extension (an M4 typed sub-slot the script path is derived from,
1772    /// an operator-side pre-resolved-path cache the accessor
1773    /// materializes behind the same `Option<&PathBuf>` return contract,
1774    /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1775    /// migrates as a single caixa-core edit rather than a coordinated
1776    /// rewrite of four call sites.
1777    #[must_use]
1778    pub const fn declared_path(&self) -> Option<&PathBuf> {
1779        match self {
1780            Self::StateChange { script } => Some(script),
1781            _ => None,
1782        }
1783    }
1784
1785    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1786    /// family arm-discriminator predicate every within-entry cross-
1787    /// instruction cleanup-facing gate keys off — true iff `self` is
1788    /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1789    /// named module until no process is running it, then GC) or
1790    /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1791    /// module immediately, without waiting for drain), the two OTP
1792    /// two-phase-code-load cleanup arms the closed-set enum's
1793    /// non-terminal / non-migration / non-load variants exhaust.
1794    /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1795    /// two-phase-load half, [`Self::StateChange`] on the
1796    /// `gen_server:code_change/3`-analog migration axis,
1797    /// [`Self::Restart`] on the OTP terminal-fallback shape)
1798    /// returns `false`.
1799    ///
1800    /// Prior to this lift the `Self::SoftPurge { module } |
1801    /// Self::Purge { module }` two-arm cleanup-family pattern-
1802    /// match sat inline at three within-entry cross-instruction
1803    /// gate sites, each hand-rolling its own copy of the union
1804    /// with no compile-time link back to the substrate primitive's
1805    /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1806    /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1807    /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1808    /// arriving before a preceding [`Self::LoadModule`]),
1809    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1810    /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1811    /// recording the first-encountered cleanup so a subsequent
1812    /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1813    /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1814    /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1815    /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1816    /// second cleanup targeting the same `:module`). Three open-
1817    /// coded per-arm-union pattern-matches that expressed no
1818    /// compile-time link back to the substrate primitive. A future
1819    /// fifth cleanup-shaped variant (a `Discard` variant the
1820    /// `code:delete/1` peer inspires that folds under the same
1821    /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1822    /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1823    /// cool-down policy grows a two-arm shape, an operator-side
1824    /// pre-resolved cleanup-decision cache the predicate could
1825    /// route through the same `bool` return contract) would have
1826    /// had to be threaded through every open-coded per-arm-union
1827    /// pattern-match in lockstep or one gate would silently
1828    /// classify the new arm outside the cleanup family while the
1829    /// peer gates classified it in (or vice versa) — a
1830    /// classification split across the three within-entry cross-
1831    /// instruction gates at build time that lands far from the
1832    /// source [`UpgradeInstruction`] declaration with no field
1833    /// naming which gate carries the drifted arm-set. Lifting the
1834    /// resolution to a typed predicate on the substrate primitive
1835    /// means every downstream cleanup-facing consumer of the
1836    /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1837    /// one typed dispatch — the resolver's arm-set migrates as a
1838    /// unit on any future arm addition composing under this
1839    /// predicate's `||` chain.
1840    ///
1841    /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1842    /// derive-generated [`Self::is_restart`] terminal-fallback
1843    /// arm-discriminator predicate on the same closed-set
1844    /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1845    /// family partition as one typed dispatch on the substrate
1846    /// primitive; `is_restart` on the single-arm terminal-
1847    /// fallback family, `is_cleanup` on the two-arm cleanup
1848    /// family), extended here from the single-arm case onto the
1849    /// two-arm arm-family union case. Composes through the
1850    /// [`gen_platform::IsVariant`]-derive-generated
1851    /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1852    /// predicates rather than an open-coded raw `matches!`
1853    /// pattern-match, so a future rebrand on either underlying
1854    /// per-arm classifier flows through this predicate's one
1855    /// body without a coordinated per-consumer rewrite across
1856    /// the three within-entry cross-instruction gates that route
1857    /// through it. Peer of the sibling per-`:contratos`
1858    /// shape-family union predicates [`crate::WitContract::is_http`] /
1859    /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
1860    /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
1861    /// per-shape WIT-prefix rule the substrate primitive's arm-
1862    /// family partition names as one typed dispatch) — the same
1863    /// "one typed dispatch on the substrate primitive, thin
1864    /// projections at each consumer" discipline extended onto the
1865    /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
1866    /// cleanup-family axis.
1867    ///
1868    /// The name `is_cleanup` maps directly onto the canonical
1869    /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
1870    /// `code:soft_purge/1` — wait until no process is running v1,
1871    /// then discard. (`code:purge/1` kills v1 immediately if you
1872    /// don't care.)" — the two `code:*_purge/1` operations are
1873    /// the two-phase-load contract's cleanup half, paired under
1874    /// one concept), and the peer [`Self::validate_cleanup_singularity`]
1875    /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1876    /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
1877    /// reaches for the same "cleanup" vocabulary in identifier +
1878    /// diagnostic form.
1879    #[must_use]
1880    pub const fn is_cleanup(&self) -> bool {
1881        self.is_soft_purge() || self.is_purge()
1882    }
1883}
1884
1885/// Reject upgrade instruction `:module` values that aren't K8s
1886/// DNS-1123 labels. Thin wrapper around
1887/// [`crate::render::is_dns_1123_label`] that maps the shared
1888/// parser-shaped reason into the kind-tagged
1889/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
1890/// diagnostics, so the author can grep their caixa.lisp for the
1891/// offending `(:<kind> <module>)` form and fix it in one edit.
1892///
1893/// The contract — the same DNS-1123 label rule the K8s apiserver
1894/// enforces on every `metadata.name` / Service name / label value the
1895/// module name lands in. Each upgrade instruction's `:module` is a
1896/// reference to a caixa name (the wasm-engine resolves it through the
1897/// same `ComputeUnit` registry the operator manages), so the value must
1898/// match every downstream apiserver-side schema: the per-Servico
1899/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
1900/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
1901/// against the loaded-module table at hot-upgrade dispatch, and the
1902/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
1903/// per-module reference axis. Same trajectory as `:children :caixa`
1904/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
1905/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
1906/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
1907///
1908/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
1909/// variant before this predicate is consulted, mirroring
1910/// `validate_membro_caixa`'s empty-first cascade.
1911fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
1912    // Routes through the shared
1913    // [`crate::render::require_valid_dns_1123_label`] gate the peer
1914    // name axes each land on. The `kind: &'static str` field flows
1915    // through both error variants so the diagnostic names which
1916    // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
1917    // offending value came from.
1918    crate::render::require_valid_dns_1123_label(
1919        module,
1920        || UpgradeError::ModuleEmpty { kind },
1921        |reason| UpgradeError::ModuleInvalid {
1922            kind,
1923            module: module.to_string(),
1924            reason,
1925        },
1926    )
1927}
1928
1929#[derive(Debug, Error, PartialEq, Eq)]
1930pub enum UpgradeError {
1931    #[error(
1932        ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
1933         consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
1934         `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
1935         every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
1936         the running version through `semver::Version::parse` and matches it against each entry's \
1937         `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
1938         SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
1939         git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
1940         requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
1941    )]
1942    FromInvalid { from: String, reason: String },
1943    #[error(
1944        "upgrade instruction `{kind}` :module is empty (every appup module reference \
1945         must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
1946         the instruction entirely)"
1947    )]
1948    ModuleEmpty { kind: &'static str },
1949    #[error(
1950        "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
1951         {reason} (every appup module reference resolves to a caixa name, which lands \
1952         verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
1953         creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
1954         dispatch, and every future `app-operator` rolling-load CR's per-module reference \
1955         axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
1956         `\"cache-v2\"`)"
1957    )]
1958    ModuleInvalid {
1959        kind: &'static str,
1960        module: String,
1961        reason: String,
1962    },
1963    #[error("instruction's :script is empty")]
1964    EmptyScript,
1965    #[error(
1966        "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
1967         root (Path::join would otherwise escape the project sandbox)",
1968        script.display()
1969    )]
1970    AbsoluteScript { script: PathBuf },
1971    #[error(
1972        "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
1973         above the caixa root",
1974        script.display()
1975    )]
1976    ParentEscapeScript { script: PathBuf },
1977    #[error(
1978        ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
1979         wasm-engine instantiator reads every migration script as tatara-lisp source through \
1980         `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
1981         peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
1982         other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
1983         parser error far from the source caixa.lisp, with no field naming the offending \
1984         `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
1985         terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
1986         `\"lib/migrations/v01-to-v02.lisp\"`).",
1987        script.display()
1988    )]
1989    NonLispExtensionScript { script: PathBuf },
1990    #[error(
1991        ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
1992         one matching block per running version (`release_handler:install_release/1` dispatches \
1993         on the loaded `:from` against the currently-running release), so two entries with the \
1994         same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
1995         pick either set non-deterministically). Author one path per prior version; if two \
1996         distinct instruction sequences are needed, fold them into one ordered list under the \
1997         single matching `(:from {from:?} :instructions (…))` block."
1998    )]
1999    DuplicateFrom { from: String },
2000    #[error(
2001        ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2002         `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2003         greater than or equal to the caixa's own version is structurally unreachable \
2004         (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2005         the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2006         is never reached because the operator never runs a version greater than or equal to \
2007         the current one that it could then upgrade *to* the current one). Bump the caixa's \
2008         `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2009         *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2010         reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2011         version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2012         than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2013         values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2014         here as a self-upgrade no-op."
2015    )]
2016    FromNotBeforeVersao { from: String, versao: String },
2017    #[error(
2018        ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2019         exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2020         `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2021         instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2022         `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2023         component-model world incompatibility, irreversible state shape change), and the \
2024         fallback is terminal by construction (the operator restarts the pod and the new \
2025         version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2026         in both directions: if the typed instructions would succeed, `(:restart)` is \
2027         unreached; if they wouldn't, the typed instructions are dead because the operator \
2028         restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2029         (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2030         never repeated. If two distinct upgrade strategies are needed for the same prior \
2031         version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2032         dispatch picks exactly one block per running version) — keep the typed sequence; \
2033         the fallback restart is what the operator does on any typed-sequence failure \
2034         already."
2035    )]
2036    RestartNotExclusive {
2037        from: String,
2038        restart_count: usize,
2039        other_kinds: Vec<&'static str>,
2040    },
2041    #[error(
2042        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2043         `(:load-module …)` in its :instructions list — a state migration is the \
2044         gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2045         code, but the operator executes instructions in declared order, so this migration \
2046         runs while the only resident version is still the prior one (which expects the \
2047         pre-migration state shape). Load the new module first: author the canonical \
2048         `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2049         resident before its state migration runs.",
2050        script.display(),
2051        script.display()
2052    )]
2053    StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2054    #[error(
2055        ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2056         `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2057         code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2058         resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2059         then `code:soft_purge/1`), but the operator executes instructions in declared \
2060         order, so this cleanup runs while the only resident version is still the same \
2061         old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2062         mid-request), leaving no replacement to route in-flight or future requests \
2063         to. Load the new module first: author the canonical `(:load-module …) \
2064         (:state-change …) ({kind} {module:?})` order so the new code is resident \
2065         before the old code is drained or discarded."
2066    )]
2067    PurgeWithoutPriorLoad {
2068        from: String,
2069        kind: &'static str,
2070        module: String,
2071    },
2072    #[error(
2073        ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2074         more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2075         code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2076         wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2077         if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2078         them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2079         both, never repeated. systools-generated `.relup` files emit at most one purge per \
2080         module for this reason. A second cleanup on the same module is at best redundant (the \
2081         module is already gone after the first cleanup, so the second is a no-op or undefined \
2082         depending on the operator's handling of a non-resident-module purge request) and at \
2083         worst incoherent (mixing drain and discard semantics on one module suggests the author \
2084         wanted a fallback, but the operator runs declared instructions unconditionally — \
2085         fallback on cleanup failure is the operator's job, not authored into the entry). \
2086         Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2087         callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2088         can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2089         cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2090    )]
2091    DuplicateCleanup {
2092        from: String,
2093        module: String,
2094        kinds: Vec<&'static str>,
2095    },
2096    #[error(
2097        ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2098         once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2099         `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2100         'old'.\"), and the instruction binds the named wasm component once: the operator's \
2101         dispatch table reads the module name and brings up the corresponding component \
2102         alongside the running version. systools-generated `.relup` files emit at most one \
2103         `load_module` per module per upgrade step for this reason. A second `(:load-module \
2104         {module:?})` instruction has no observable semantic relative to the first (the \
2105         component is already resident) — either dead code (copy-pasted load line) or a typo \
2106         masking a distinct module the author intended to load alongside (renamed both to \
2107         {module:?} by mistake), leaving the second module silently absent from the entry. \
2108         Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2109         versions need loading alongside the running one, name them distinctly (e.g. \
2110         `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2111    )]
2112    DuplicateLoadModule { from: String, module: String },
2113    #[error(
2114        ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2115         once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2116         \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2117         state shape into the current-version shape: a one-shot transition, not a step that \
2118         composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2119         per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2120         callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2121         the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2122         author intended two distinct migration scripts) and at worst silent state corruption \
2123         (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2124         counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2125         Author one `(:state-change {})` per migration script per entry; if two distinct state \
2126         transitions are needed (e.g. one module's schema *and* another module's projection), \
2127         name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2128        script.display(),
2129        script.display(),
2130        script.display(),
2131        script.display()
2132    )]
2133    DuplicateStateChange { from: String, script: PathBuf },
2134    #[error(
2135        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2136         {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2137         gen_server:code_change/3 analog and folds the prior-version state shape into the \
2138         current shape, but the prior version's state only exists while the prior code is \
2139         still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2140         analogs and drain or discard that prior code. The operator executes instructions in \
2141         declared order, so a cleanup ahead of a state-change has already drained the prior \
2142         module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2143         the migration script runs, leaving the script either no-op (no prior-version state \
2144         left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2145         canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2146         `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2147         {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2148         strictly between load and cleanup. Author the canonical `(:load-module …) \
2149         (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2150         migration runs against the prior-version state before the cleanup drains it.",
2151        script.display(),
2152        script.display()
2153    )]
2154    StateChangeAfterCleanup {
2155        from: String,
2156        script: PathBuf,
2157        prior_cleanup_kind: &'static str,
2158        prior_cleanup_module: String,
2159    },
2160    #[error(
2161        ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2162         declare `:behavior :on-state-change` — the per-version migration script is the \
2163         gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2164         hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2165         realizes the composition by invoking the running gen_server's code_change/3 callback \
2166         during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2167         composition into two typed slots, the per-version migration logic in this \
2168         `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2169         `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2170         verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2171         migration during hot upgrades\"). The missing callback leaves the per-version script \
2172         with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2173         callback at the migration step, finds it absent, and either fails the upgrade \
2174         mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2175         current version stays load-bearing\") or silently skips the migration leaving the \
2176         new code running against unmigrated prior-version state. Add the callback: \
2177         `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2178         path) alongside the existing `(:state-change {})` instruction (the per-version \
2179         script). If the upgrade truly carries no state migration, drop the `(:state-change \
2180         …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2181         migration — is the canonical shape).",
2182        script.display(),
2183        script.display()
2184    )]
2185    StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2186}
2187
2188// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2189// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2190// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2191// two-slot struct-variant wire-up sites at
2192// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2193// / `script` from `instr.declared_path()`),
2194// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2195// (`self.prior_versao()` / `script.as_path()` from
2196// `instr.declared_path()`), and
2197// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2198// / `script` from `instr.declared_path()`) onto one substrate primitive
2199// per typed variant — the paired `{ from: String, script: PathBuf }`
2200// two-slot sibling on [`UpgradeError`] of the peer
2201// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2202// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2203// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2204// (8580068, 4 variants on `{ de, para }`),
2205// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2206// `{ de, para, wit, expected }`),
2207// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2208// variants on `{ <field>: String, reason: String }`), and
2209// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2210// variants on `{ de, para, <field>: String, reason: String }`) on the
2211// sibling `AplicacaoError` envelopes, and the peer
2212// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2213// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2214// (0419438, 4 variants on `{ caixa, kind, slots }`),
2215// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2216// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2217// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2218// `LayoutError` envelopes, plus the peer
2219// [`crate::limits::limits_codec_value_only_ctors!`] /
2220// [`crate::limits::limits_codec_value_byte_ctors!`] /
2221// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2222// wire-ups) on the sibling `LimitsError` envelopes.
2223//
2224// Each of the three wire-up sites on this shape opens the identical
2225// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2226// script: <script>.to_path_buf() }` struct-literal against a local
2227// `prior_versao()` and `declared_path()` accessor pair — the exact
2228// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2229// names as a bug, on the same altitude the peer `SupervisorError` /
2230// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2231// closed on their sibling envelopes. The three variants share one
2232// `{ from: String, script: PathBuf }` shape, so the fold routes each
2233// wire-up site through one dispatch per typed variant.
2234//
2235// The macro below generates one `#[must_use]` inherent constructor per
2236// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2237// Self`, so every wire-up site collapses onto one dispatch:
2238// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2239// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2240// uniform two-field construction (`from.to_string()` /
2241// `script.to_path_buf()`) is spelled once — inside the macro — rather
2242// than at every wire-up site. The `&Path` parameter accepts both
2243// `&Path` (from `script.as_path()` at the uniqueness gate) and
2244// `&PathBuf` (from `instr.declared_path()` at the ordering /
2245// callback-declaration gates, via Deref coercion), so every existing
2246// wire-up threads through the ctor without a pre-conversion.
2247//
2248// Every future consumer that wants to construct one of these three
2249// variants outside the three in-crate `UpgradeFromEntry` /
2250// `validate_state_change_on_state_change_callback` gates (a deferred
2251// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2252// re-checker at hot-upgrade dispatch time, a future
2253// `feira validate --upgrade-from` per-caixa admission verb re-checking
2254// the three axes, a per-`Caixa` overlay resolver rejecting an
2255// ordering / uniqueness / callback-declaration invariant against a
2256// cluster-local snapshot) now reaches each variant through one call
2257// rather than re-inlining the three-line struct-literal in lockstep
2258// with the three in-crate wire-up sites.
2259macro_rules! upgrade_from_script_ctors {
2260    ($($ctor:ident => $variant:ident),* $(,)?) => {
2261        impl UpgradeError {
2262            $(
2263                #[doc = concat!(
2264                    "Construct an [`UpgradeError::",
2265                    stringify!($variant),
2266                    "`] naming the offending `(:from <prior-versao>)` and ",
2267                    "`(:state-change <script>)` pair. Folds the uniform ",
2268                    "`Self::",
2269                    stringify!($variant),
2270                    " { from: from.to_string(), script: script.to_path_buf() }` ",
2271                    "two-field struct-literal onto one substrate primitive so ",
2272                    "every wire-up on this variant reads through one dispatch ",
2273                    "rather than the pre-lift three-line open-coded block. The ",
2274                    "`from` string threads verbatim from ",
2275                    "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2276                    "from [`UpgradeInstruction::declared_path`] at the call site."
2277                )]
2278                #[must_use]
2279                pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2280                    Self::$variant {
2281                        from: from.to_string(),
2282                        script: script.to_path_buf(),
2283                    }
2284                }
2285            )*
2286        }
2287    };
2288}
2289
2290upgrade_from_script_ctors! {
2291    state_change_without_prior_load => StateChangeWithoutPriorLoad,
2292    duplicate_state_change => DuplicateStateChange,
2293    state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2294}
2295
2296// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2297// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2298// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2299// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2300// onto one substrate primitive per typed variant — the paired
2301// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2302// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2303// `{ from: String, script: PathBuf }`) two-slot family on the same
2304// envelope, and of the peer
2305// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2306// variants on `{ caixa: String }`) and
2307// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2308// `{ nome: String }`) single-slot families on the sibling
2309// `SupervisorError` / `DepError` envelopes, and of the peer
2310// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2311// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2312// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2313// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2314// variants on `{ <field>: String, reason: String }`), and
2315// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2316// variants on `{ de, para, <field>: String, reason: String }`) on the
2317// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2318// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2319// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2320// [`crate::LayoutError::missing_entry`] 1b09f9d;
2321// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2322// `LimitsError` codec families (81c856c), and the sibling
2323// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2324// `{ nome, caminho }`) two-slot family.
2325//
2326// The three wire-up sites this fold closes are the three closures
2327// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2328// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2329// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2330// passed to [`crate::render::require_sandboxed_lisp_path`] at
2331// [`UpgradeInstruction::validate`] — each opens the identical
2332// `UpgradeError::<Variant> { script: script.clone() }` three-line
2333// struct-literal against the same `script: &PathBuf` local threaded
2334// from [`UpgradeInstruction::declared_path`], the exact "same block
2335// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2336// bug. The three variants share one `{ script: PathBuf }` shape, so
2337// the fold routes each closure through one dispatch per typed variant.
2338// The sibling `EmptyScript` unit-variant on the same envelope stays on
2339// its pre-lift open-coded shape — it carries no `script` field (the
2340// offending `:script` value *is* the empty path this variant catches),
2341// so the uniform `fn(script: &Path) -> Self` signature this macro
2342// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2343// closure is already a one-liner. This is the second fold family on
2344// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2345// two-slot family established in 8e67041, which explicitly named this
2346// `{ script: PathBuf }` single-slot family as the next fold to land
2347// on the envelope; per that commit's coverage roster, both of the two
2348// most-populated shapes on `UpgradeError` — the two-slot
2349// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2350//
2351// The macro below generates one `#[must_use]` inherent constructor per
2352// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2353// every closure collapses onto one dispatch:
2354// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2355// struct-literal on the same `&Path` fixture. The uniform one-field
2356// construction (`script.to_path_buf()`) is spelled once — inside the
2357// macro — rather than at every wire-up site. The `&Path` parameter
2358// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2359// `instr.declared_path()` at the three closures, via Deref coercion),
2360// so every existing closure threads through the ctor without a
2361// pre-conversion.
2362//
2363// Every future consumer that wants to construct one of these three
2364// variants outside the three in-crate closures (a deferred
2365// wasm-operator's `install_release/1` per-instruction script-shape
2366// re-checker at hot-upgrade dispatch time, a future
2367// `feira validate --upgrade-from` per-caixa admission verb re-checking
2368// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2369// an author-supplied `:state-change :script` against a cluster-local
2370// snapshot) now reaches each variant through one call rather than
2371// re-inlining the three-line struct-literal in lockstep with the three
2372// in-crate closure sites.
2373macro_rules! upgrade_script_only_ctors {
2374    ($($ctor:ident => $variant:ident),* $(,)?) => {
2375        impl UpgradeError {
2376            $(
2377                #[doc = concat!(
2378                    "Construct an [`UpgradeError::",
2379                    stringify!($variant),
2380                    "`] naming the offending `(:state-change <script>)`. ",
2381                    "Folds the uniform `Self::",
2382                    stringify!($variant),
2383                    " { script: script.to_path_buf() }` one-field ",
2384                    "struct-literal onto one substrate primitive so every ",
2385                    "closure passed to ",
2386                    "[`crate::render::require_sandboxed_lisp_path`] at ",
2387                    "[`UpgradeInstruction::validate`] on this variant reads ",
2388                    "through one dispatch rather than the pre-lift three-line ",
2389                    "open-coded block. The `script` path threads verbatim ",
2390                    "from [`UpgradeInstruction::declared_path`] at the call ",
2391                    "site."
2392                )]
2393                #[must_use]
2394                pub fn $ctor(script: &std::path::Path) -> Self {
2395                    Self::$variant {
2396                        script: script.to_path_buf(),
2397                    }
2398                }
2399            )*
2400        }
2401    };
2402}
2403
2404upgrade_script_only_ctors! {
2405    absolute_script => AbsoluteScript,
2406    parent_escape_script => ParentEscapeScript,
2407    non_lisp_extension_script => NonLispExtensionScript,
2408}
2409
2410// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2411// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2412// <value>.to_string() }` two-slot struct-variant wire-up sites at
2413// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2414// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2415// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2416// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2417// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2418// self.prior_versao().to_string(), module: module.to_string() });`),
2419// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2420// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2421// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2422// });`) onto one substrate-primitive family per typed variant — the
2423// missing paired two-slot rung on the `UpgradeError`-side four-family
2424// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2425// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2426// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2427// script: PathBuf }`), and mirror-symmetric sibling of the peer
2428// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2429// String, <axis>: String }` fold on the `DepError` envelope — same
2430// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2431// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2432// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2433// carries the offending prior-version `:from` verbatim so the author
2434// can grep their caixa.lisp for the offending `(:from "<value>")` /
2435// `(:load-module …)` / `:versao` block in one edit). The three
2436// variants share the same `{ from: String, <axis>: String }` two-slot
2437// shape: the `from` field names the offending per-`:upgrade-from` block's
2438// prior-version tag the diagnostic points the author back at, and the
2439// middle `<axis>: String` field carries the offending per-envelope axis
2440// value verbatim (`reason` on `FromInvalid` carries the wrapped
2441// `semver::Version::parse` error message that pinpoints why the tag
2442// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2443// own current-`:versao` the entry's `:from` failed to precede; `module`
2444// on `DuplicateLoadModule` carries the caixa name the second
2445// `(:load-module …)` instruction re-loaded within the same entry).
2446// The middle axis-field name differs across variants (`reason` /
2447// `versao` / `module`) so the ctor family below takes the axis field
2448// name as a macro parameter (`$axis:ident`) alongside the ctor +
2449// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2450// -> Self` inherent constructor per typed variant that spells the
2451// uniform two-field construction (`from.to_string()` /
2452// `<axis>.to_string()`) exactly once.
2453//
2454// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2455// variants on `{ from: String, script: PathBuf }`) two-slot family on
2456// the same envelope — both key off the same `from: String` axis at the
2457// same per-`:upgrade-from :from`-owned altitude; this family carries the
2458// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2459// carrier) where the script-slot family carries the owned-`PathBuf`
2460// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2461// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2462// same envelope, of the sibling
2463// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2464// variants on `{ caixa: String }`) and
2465// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2466// `{ nome: String }`) single-slot families on the sibling
2467// `SupervisorError` / `DepError` envelopes, and of the peer
2468// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2469// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2470// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2471// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2472// variants on `{ <field>: String, reason: String }`),
2473// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2474// variants on `{ caixa: String }`),
2475// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2476// on `{ path: String }`), and
2477// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2478// variants on `{ de, para, <field>: String, reason: String }`) on the
2479// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2480// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2481// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2482// [`crate::LayoutError::missing_entry`] 1b09f9d;
2483// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2484// `LimitsError` codec families (81c856c), the sibling
2485// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2486// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2487// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2488// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2489// `{ nome, list: &'static str }`), and
2490// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2491// `{ nome, <axis>: String, reason: String }`) families.
2492//
2493// Each of the three wire-up sites on this shape opens the identical
2494// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2495// <value>.to_string() }` four-line struct-literal against a local
2496// `(prior_versao(), <axis-value>)` pair threaded from
2497// [`UpgradeFromEntry::prior_versao`] (or, at the
2498// [`validate_upgrade_from_against_versao`] site, directly from the
2499// caller-supplied `versao: &str` argument) — the exact "same block
2500// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2501// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2502// / `upgrade_script_only_ctors!` families closed on the sibling
2503// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2504// axis-field discriminators are the only things that vary between them;
2505// the rest of the struct-literal is a byte-for-byte re-inline.
2506//
2507// The macro below generates one `#[must_use]` inherent constructor per
2508// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2509// every wire-up site collapses onto one dispatch:
2510// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2511// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2512// parameters accept `&str` literals and `&String` (via Deref coercion)
2513// so every existing wire-up threads through the ctor without a
2514// pre-conversion.
2515//
2516// Every future consumer that wants to construct one of these three
2517// variants outside the three in-crate `UpgradeFromEntry::validate` /
2518// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2519// gates (a deferred wasm-operator's `install_release/1` per-entry
2520// `:from`-parse / per-`:load-module` singularity / per-entry
2521// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2522// `feira validate --upgrade-from` per-caixa admission verb re-checking
2523// the three axes, a per-`Caixa` overlay resolver rejecting a
2524// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2525// invariant against a cluster-local snapshot) now reaches each variant
2526// through one call rather than re-inlining the four-line struct-literal
2527// in lockstep with the three in-crate wire-up sites.
2528macro_rules! upgrade_from_axis_ctors {
2529    ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2530        impl UpgradeError {
2531            $(
2532                #[doc = concat!(
2533                    "Construct an [`UpgradeError::",
2534                    stringify!($variant),
2535                    "`] naming the offending `(:from <prior-versao>)` and ",
2536                    "the offending `:", stringify!($axis), "` axis value. ",
2537                    "Folds the uniform `Self::",
2538                    stringify!($variant),
2539                    " { from: from.to_string(), ",
2540                    stringify!($axis),
2541                    ": ",
2542                    stringify!($axis),
2543                    ".to_string() }` two-field struct-literal onto one ",
2544                    "substrate primitive so every in-crate wire-up on ",
2545                    "this variant reads through one dispatch rather than ",
2546                    "the pre-lift four-line open-coded block. Both `from: ",
2547                    "&str` and `",
2548                    stringify!($axis),
2549                    ": &str` parameters accept `&str` literals and ",
2550                    "`&String` (via Deref coercion) so every existing ",
2551                    "wire-up threads through the ctor without a pre-",
2552                    "conversion."
2553                )]
2554                #[must_use]
2555                pub fn $ctor(from: &str, $axis: &str) -> Self {
2556                    Self::$variant {
2557                        from: from.to_string(),
2558                        $axis: $axis.to_string(),
2559                    }
2560                }
2561            )*
2562        }
2563    };
2564}
2565
2566upgrade_from_axis_ctors! {
2567    from_invalid => FromInvalid { reason },
2568    from_not_before_versao => FromNotBeforeVersao { versao },
2569    duplicate_load_module => DuplicateLoadModule { module },
2570}
2571
2572// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2573// entry.prior_versao().to_string() }` one-slot struct-literal inside
2574// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2575// one substrate primitive on the [`UpgradeError`] envelope, projecting
2576// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2577// on the substrate primitive. The `DuplicateFrom` variant is the last
2578// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2579// every peer envelope shape (`{ script: PathBuf }` one-slot via
2580// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2581// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2582// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2583// 8e67041) already reads through one substrate-primitive dispatch, so
2584// this fold closes the last one-off single-slot on the envelope.
2585//
2586// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2587// (b30edfe) on the paired [`WitContract`] projection — same
2588// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2589// through the substrate primitive's own scalar accessor rather than
2590// re-inlining the `.to_string()` at the call site. Extended here onto
2591// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2592// M2 companion of the M3 mesh-slot accessors (see
2593// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2594// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2595// 4a32abf, and the [`crate::WitContract::{source, destination,
2596// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2597// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2598//
2599// The one wire-up site this fold closes opens the identical
2600// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2601// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2602// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2603// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2604// names as a bug, on the same altitude the peer `contrato_self_loop`
2605// closed on the sibling `{ caixa: String, wit: String }` two-slot
2606// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2607// parameter accepts the borrowed entry verbatim so the wire-up site
2608// threads through the ctor without a pre-projection — the ctor body
2609// spells the paired `prior_versao().to_string()` projection once.
2610//
2611// Every future consumer that wants to construct this variant outside
2612// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2613// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2614// re-checker at hot-upgrade dispatch time rejecting a second entry
2615// with the same prior-versao tag, a future `feira validate --upgrade-
2616// from` per-caixa admission verb re-running the cross-entry duplicate
2617// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2618// supplied duplicate `(:from "<value>")` against a cluster-local
2619// snapshot — now reaches the variant through one call rather than
2620// re-inlining the three-line struct-literal in lockstep with the one
2621// in-crate wire-up site.
2622impl UpgradeError {
2623    /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2624    /// duplicate `(:from <prior-versao>)` entry, projecting through the
2625    /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2626    /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2627    /// from: entry.prior_versao().to_string() }` one-field struct-literal
2628    /// onto one substrate primitive so every wire-up on this variant
2629    /// reads through one dispatch, matching the sibling
2630    /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2631    /// substrate-primitive-projection ctor's shape on the peer
2632    /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2633    /// parameter accepts the borrowed entry verbatim so the paired
2634    /// `prior_versao().to_string()` projection is spelled once — inside
2635    /// the ctor body — rather than at every wire-up site.
2636    #[must_use]
2637    pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2638        Self::DuplicateFrom {
2639            from: entry.prior_versao().to_string(),
2640        }
2641    }
2642
2643    /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2644    /// offending `(:from <prior-versao>)` entry, the offending cleanup
2645    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2646    /// its `:module` target. Folds the uniform
2647    /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2648    /// module: module.to_string() }` three-field struct-literal onto one
2649    /// substrate primitive so every wire-up on this sole-variant
2650    /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2651    /// through one dispatch rather than the pre-lift seven-line
2652    /// open-coded block.
2653    ///
2654    /// The `from: &str` parameter accepts `&str` literals and `&String`
2655    /// via Deref coercion so the sole in-crate wire-up site threads
2656    /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2657    /// pre-conversion. The `kind: &'static str` parameter accepts the
2658    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2659    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2660    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2661    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2662    /// re-projection at the ctor path. The `module: &str` parameter
2663    /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
2664    /// via `.expect("is_cleanup() implies declared_module() is Some")`
2665    /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
2666    /// `Some` composition pin at
2667    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2668    /// makes the `.expect(…)` structurally infallible at build time.
2669    ///
2670    /// Peer of the sibling one-off standalone-ctor
2671    /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
2672    /// String }` envelope on the same `UpgradeError` envelope, and of
2673    /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
2674    /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
2675    /// variant standalone ctor on the peer `AplicacaoError` envelope.
2676    /// Closes the last unlifted `{ from: String, kind: &'static str,
2677    /// module: String }` three-slot open-coded struct-literal wire-up
2678    /// on the OTP-appup load-before-cleanup ordering axis, sibling of
2679    /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
2680    /// (41d08db, three variants on `{ from: String, <axis>: String }`)
2681    /// on the paired ordering / uniqueness / callback-declaration axes,
2682    /// and of the peer standalone [`UpgradeError::duplicate_from`]
2683    /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
2684    /// `:from` gate. Every future consumer that raises this refusal
2685    /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
2686    /// wasm-operator's `install_release/1` per-entry load-before-cleanup
2687    /// re-checker at hot-upgrade dispatch time, a future
2688    /// `feira validate --upgrade-from` per-caixa admission verb
2689    /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
2690    /// overlay resolver rejecting a cluster-local `:soft-purge` /
2691    /// `:purge` overlay lacking a preceding `:load-module` — reaches
2692    /// the variant through one call rather than re-inlining the
2693    /// seven-line struct-literal in lockstep with the sole in-crate
2694    /// wire-up site.
2695    #[must_use]
2696    pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
2697        Self::PurgeWithoutPriorLoad {
2698            from: from.to_string(),
2699            kind,
2700            module: module.to_string(),
2701        }
2702    }
2703
2704    /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
2705    /// offending `(:from <prior-versao>)` entry, the offending
2706    /// `(:state-change …)` `:script` path, and the prior cleanup
2707    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
2708    /// `:module` target. Folds the uniform
2709    /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
2710    /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
2711    /// prior_cleanup_module.to_string() }` four-field struct-literal
2712    /// onto one substrate primitive so every wire-up on this sole-
2713    /// variant migrate-after-cleanup ordering-refusal envelope reads
2714    /// through one dispatch rather than the pre-lift seven-line open-
2715    /// coded block. Closes the last unlifted `{ from: String, script:
2716    /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
2717    /// String }` four-slot open-coded struct-literal wire-up on the
2718    /// OTP-appup migrate-before-cleanup ordering axis, filling the
2719    /// missing four-slot rung on the `UpgradeError`-side ctor-family
2720    /// ladder alongside the sibling one-slot
2721    /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
2722    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2723    /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
2724    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
2725    /// families, and the one-slot [`upgrade_script_only_ctors!`]
2726    /// (7468ca9) family. Sole in-crate wire-up site is inside
2727    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
2728    /// migrate-family sticky-latch dispatch — the third of three
2729    /// within-entry cross-instruction OTP-appup ordering gates the
2730    /// module doc pins (`validate_state_change_ordering` on the load →
2731    /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
2732    /// `state_change_without_prior_load`; `validate_purge_ordering` on
2733    /// the load → cleanup boundary via `purge_without_prior_load`;
2734    /// `validate_state_change_before_cleanup` on the migrate → cleanup
2735    /// boundary via this ctor — now).
2736    ///
2737    /// The `from: &str` parameter accepts `&str` literals and `&String`
2738    /// via Deref coercion so the sole in-crate wire-up site threads
2739    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2740    /// without a pre-conversion. The `script: &std::path::Path`
2741    /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
2742    /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
2743    /// via Deref coercion) so the wire-up threads the sticky-latch
2744    /// script projection through the ctor without a pre-conversion; the
2745    /// uniform `script.to_path_buf()` one-field construction is spelled
2746    /// once — inside the ctor body — rather than at every wire-up site.
2747    /// The `prior_cleanup_kind: &'static str` parameter accepts the
2748    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2749    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2750    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2751    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2752    /// re-projection at the ctor path. The `prior_cleanup_module: &str`
2753    /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
2754    /// returns via `.expect("is_cleanup() implies declared_module() is
2755    /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
2756    /// is-`Some` composition pin at
2757    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2758    /// makes the `.expect(…)` structurally infallible at build time.
2759    ///
2760    /// Every future consumer that raises this refusal outside
2761    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
2762    /// deferred wasm-operator's `install_release/1` per-entry
2763    /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
2764    /// a future `feira validate --upgrade-from` per-caixa admission verb
2765    /// re-running the migrate-before-cleanup gate on demand, a
2766    /// per-`Caixa` overlay resolver rejecting a cluster-local
2767    /// `:state-change` overlay authored after a `:soft-purge` /
2768    /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
2769    /// webhook re-checking a per-`:upgrade-from`-patched candidate
2770    /// before the migrate-before-cleanup gate re-fires — reaches the
2771    /// variant through one call rather than re-inlining the seven-line
2772    /// struct-literal in lockstep with the sole in-crate wire-up site.
2773    #[must_use]
2774    pub fn state_change_after_cleanup(
2775        from: &str,
2776        script: &std::path::Path,
2777        prior_cleanup_kind: &'static str,
2778        prior_cleanup_module: &str,
2779    ) -> Self {
2780        Self::StateChangeAfterCleanup {
2781            from: from.to_string(),
2782            script: script.to_path_buf(),
2783            prior_cleanup_kind,
2784            prior_cleanup_module: prior_cleanup_module.to_string(),
2785        }
2786    }
2787}
2788
2789#[cfg(test)]
2790mod tests {
2791    use std::path::Path;
2792
2793    use super::*;
2794
2795    fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
2796        UpgradeFromEntry {
2797            from: from.into(),
2798            instructions: instrs,
2799        }
2800    }
2801
2802    #[test]
2803    fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
2804        // Fail-before-pass-after pin on
2805        // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
2806        // posture. The accessor projects the per-`:upgrade-from :from`
2807        // [`String`] storage through the `pub const fn`
2808        // [`String::as_str`] (const-stable since Rust 1.87, well within
2809        // the workspace MSRV) — any future accidental downgrade to
2810        // non-`const` fails `prior_versao_via_const_fn` at caixa-core
2811        // build time with E0015 (`cannot call non-const method`),
2812        // strictly stronger than a runtime `assert!`. Sibling of the
2813        // peer M2/M3 slot family pins on the sibling `const`-eval-
2814        // surface passes ([`crate::Caixa::nome`] /
2815        // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
2816        // [`crate::aplicacao::Membro::nome`] /
2817        // [`crate::aplicacao::Membro::versao_requirement`],
2818        // [`crate::aplicacao::Entrada::hostname`] /
2819        // [`crate::aplicacao::Entrada::destination`],
2820        // [`crate::supervisor::ChildSpec::nome`] /
2821        // [`crate::supervisor::ChildSpec::versao_requirement`],
2822        // [`crate::dep::Dep::nome`] /
2823        // [`crate::dep::Dep::versao_requirement`], and the
2824        // per-`:contratos`
2825        // [`crate::aplicacao::WitContract::source`] /
2826        // [`crate::aplicacao::WitContract::destination`] /
2827        // [`crate::aplicacao::WitContract::world_ref`] trio the
2828        // sibling pin at 279823b already anchors).
2829        const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
2830            e.prior_versao()
2831        }
2832        for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
2833            let e = entry(from, vec![]);
2834            assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
2835            assert_eq!(e.prior_versao(), from);
2836        }
2837    }
2838
2839    #[test]
2840    fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
2841        // Fail-before-pass-after pin on
2842        // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
2843        // posture. The accessor destructures the per-`:upgrade-from
2844        // :instructions` `Vec<UpgradeInstruction>` storage through the
2845        // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
2846        // 1.66, well within the workspace MSRV) — any future
2847        // accidental downgrade to non-`const` fails
2848        // `instructions_via_const_fn` at caixa-core build time with
2849        // E0015 (`cannot call non-const method`), strictly stronger
2850        // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
2851        // slot `Vec → &[T]` slice-return accessor family pin
2852        // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
2853        // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
2854        // per-`:membros` / per-`:contratos` slice-return axes, and of
2855        // the peer M2 supervisor-tree axis pin
2856        // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
2857        // on the per-`:children` slice-return axis.
2858        const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
2859            e.instructions()
2860        }
2861        // Sweep both the empty-instructions arm (author-declared
2862        // per-`:from` entry with no migration steps — the degenerate
2863        // shape the appup `restart`-only path folds through) and the
2864        // populated-instructions arm (the canonical OTP-appup shape
2865        // carrying a `LoadModule` + `StateChange` + `SoftPurge`
2866        // chain) so the accessor carries a const-dispatch pin on
2867        // both arms.
2868        let e_empty = entry("0.1.0", vec![]);
2869        assert!(instructions_via_const_fn(&e_empty).is_empty());
2870        assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
2871        let e_full = entry(
2872            "0.1.0",
2873            vec![
2874                UpgradeInstruction::LoadModule {
2875                    module: "hello-rio".into(),
2876                },
2877                UpgradeInstruction::StateChange {
2878                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
2879                },
2880                UpgradeInstruction::SoftPurge {
2881                    module: "hello-rio-old".into(),
2882                },
2883            ],
2884        );
2885        assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
2886        assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
2887    }
2888
2889    #[test]
2890    fn round_trip_load_module() {
2891        let i = UpgradeInstruction::LoadModule {
2892            module: "hello-rio".into(),
2893        };
2894        let json = serde_json::to_string(&i).unwrap();
2895        assert!(json.contains("\"kind\":\"load-module\""));
2896        let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2897        assert_eq!(i, back);
2898    }
2899
2900    #[test]
2901    fn round_trip_all_variants() {
2902        let cases = vec![
2903            UpgradeInstruction::LoadModule { module: "x".into() },
2904            UpgradeInstruction::StateChange {
2905                script: PathBuf::from("lib/migrations.lisp"),
2906            },
2907            UpgradeInstruction::SoftPurge {
2908                module: "x-old".into(),
2909            },
2910            UpgradeInstruction::Purge {
2911                module: "x-old".into(),
2912            },
2913            UpgradeInstruction::Restart,
2914        ];
2915        for c in cases {
2916            let json = serde_json::to_string(&c).unwrap();
2917            let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2918            assert_eq!(c, back);
2919        }
2920    }
2921
2922    #[test]
2923    fn validate_accepts_well_formed() {
2924        let e = entry(
2925            "0.1.0",
2926            vec![
2927                UpgradeInstruction::LoadModule {
2928                    module: "hello-rio".into(),
2929                },
2930                UpgradeInstruction::StateChange {
2931                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
2932                },
2933                UpgradeInstruction::SoftPurge {
2934                    module: "hello-rio-old".into(),
2935                },
2936            ],
2937        );
2938        e.validate().unwrap();
2939    }
2940
2941    #[test]
2942    fn validate_rejects_non_semver_from() {
2943        let e = entry("not-a-semver", vec![]);
2944        let err = e.validate().unwrap_err();
2945        assert!(
2946            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
2947        );
2948    }
2949
2950    #[test]
2951    fn from_invalid_diagnostic_carries_offending_from_and_reason() {
2952        // Diagnostic-shape pin: the error names the offending
2953        // `:upgrade-from :from` verbatim with a non-empty parser-shaped
2954        // reason, so a `feira lint` run can render the diagnostic
2955        // without re-parsing — the author can grep their caixa.lisp for
2956        // `:from "<value>"` and fix it in one edit. Mirrors the peer
2957        // `versao_invalid_diagnostic_carries_offending_versao` pin on
2958        // the sibling SemVer-2 axis (the top-level `:versao`), the
2959        // peer `membro_versao_invalid_diagnostic_carries_offending_value`
2960        // pin on `:membros :versao`, and the peer
2961        // `deps_invalid_diagnostic_carries_offending_value` pin on
2962        // `:deps :versao` — every SemVer-2-parsing slot's invalid
2963        // diagnostic is now structurally equivalent.
2964        let e = entry("v0.1.0", vec![]);
2965        let err = e.validate().unwrap_err();
2966        let UpgradeError::FromInvalid { from, reason } = err else {
2967            panic!("expected FromInvalid variant, got {err:?}");
2968        };
2969        assert_eq!(from, "v0.1.0");
2970        assert!(
2971            !reason.is_empty(),
2972            "FromInvalid `reason` must carry the parser's wording verbatim"
2973        );
2974    }
2975
2976    #[test]
2977    fn prior_versao_returns_from_byte_equal_across_permutations() {
2978        // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
2979        // accessor across the SemVer-2 shape lattice every consumer
2980        // reaches through it — the numeric-triad canonical shape, a
2981        // pre-release build with a dotted identifier chain, a full-
2982        // metadata build, a large-magnitude triad, and the empty
2983        // string (which reaches this accessor unchanged before any
2984        // validate gate rejects it). Sibling to the peer
2985        // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
2986        // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
2987        // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
2988        // family — extended here onto the first M2 slot scalar-value
2989        // axis. Any silent detour on the accessor (a `.to_string()`
2990        // + retained ownership shape, a canonicalization pass, a
2991        // trim-whitespace on the return path) surfaces as a byte-
2992        // inequality failure here rather than as a downstream error-
2993        // diagnostic drift.
2994        let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
2995        for from in cases {
2996            let e = entry(from, vec![]);
2997            assert_eq!(
2998                e.prior_versao(),
2999                from,
3000                "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3001            );
3002            assert_eq!(
3003                e.prior_versao().len(),
3004                from.len(),
3005                "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3006            );
3007        }
3008    }
3009
3010    #[test]
3011    fn prior_versao_borrows_from_from_storage() {
3012        // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3013        // a borrow into `self.from`'s heap allocation, never a fresh
3014        // owned copy. Guards against a future silent detour where
3015        // the accessor materializes a `Cow<'_, str>` / `String` /
3016        // `Rc<str>` intermediate — the return path stays zero-cost
3017        // even under a refactor that reshapes the storage. Sibling
3018        // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3019        // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3020        // (4a32abf) pins — extended onto the M2 slot's first
3021        // scalar-value axis.
3022        let e = entry("0.1.0", vec![]);
3023        assert!(
3024            std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3025            "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3026        );
3027    }
3028
3029    #[test]
3030    fn validate_parses_prior_versao_through_lifted_accessor() {
3031        // Coherence pin between the accessor and the SemVer-2 parse
3032        // gate: every `:upgrade-from :from` value the validator
3033        // accepts (resp. rejects) must be identical to what
3034        // `Version::parse(entry.prior_versao())` accepts (resp.
3035        // rejects) — the two must remain in lockstep across the
3036        // shape lattice so `validate_upgrade_from`'s
3037        // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3038        // assertion holds by construction. If a future extension of
3039        // `prior_versao` reshapes the return (a canonicalization
3040        // pass, a leading/trailing whitespace trim, an empty-to-
3041        // "0.0.0" fallback) it would either loosen the validator
3042        // (silently accepting shapes the parser rejects) or
3043        // tighten the parser's re-parse (silently panicking on
3044        // shapes the validator accepts) — this pin catches either
3045        // shift at caixa-core build time.
3046        let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3047        for from in accepted {
3048            let e = entry(from, vec![]);
3049            e.validate().unwrap_or_else(|err| {
3050                panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3051            });
3052            semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3053                panic!(
3054                    "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3055                     got {err:?}",
3056                );
3057            });
3058        }
3059        let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3060        for from in rejected {
3061            let e = entry(from, vec![]);
3062            assert!(
3063                matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3064                "validate() must reject {from:?} that Version::parse rejects",
3065            );
3066            assert!(
3067                semver::Version::parse(e.prior_versao()).is_err(),
3068                "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3069            );
3070        }
3071    }
3072
3073    #[test]
3074    fn validate_rejects_empty_module() {
3075        // Per-arm coverage: every Module-bearing variant surfaces the
3076        // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3077        // so the author can grep their caixa.lisp for `(:load-module
3078        // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3079        // edit — same self-locating shape `BehaviorError::EmptyPath`
3080        // (b0c8389) carries on the peer M2 typed slot.
3081        let cases: &[(UpgradeInstruction, &'static str)] = &[
3082            (
3083                UpgradeInstruction::LoadModule {
3084                    module: String::new(),
3085                },
3086                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3087            ),
3088            (
3089                UpgradeInstruction::SoftPurge {
3090                    module: String::new(),
3091                },
3092                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3093            ),
3094            (
3095                UpgradeInstruction::Purge {
3096                    module: String::new(),
3097                },
3098                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3099            ),
3100        ];
3101        for (instr, expected_kind) in cases {
3102            assert_eq!(
3103                instr.validate().unwrap_err(),
3104                UpgradeError::ModuleEmpty {
3105                    kind: expected_kind
3106                },
3107                "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3108            );
3109        }
3110    }
3111
3112    #[test]
3113    fn validate_rejects_non_dns_1123_module() {
3114        // Every appup `:module` reference is a caixa name (the
3115        // wasm-engine resolves it through the same ComputeUnit
3116        // registry the operator manages), so the value-shape gate
3117        // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3118        // the canonical authoring footguns — uppercase letters, `_`
3119        // separator, embedded `.`, leading/trailing `-`, an embedded
3120        // whitespace byte, the >63-byte UUID-shaped slug — across
3121        // every Module-bearing variant; each must surface as
3122        // `ModuleInvalid { kind, module, reason }` carrying the
3123        // offending value verbatim and the parser-shaped reason.
3124        type Build = fn(String) -> UpgradeInstruction;
3125        let footguns: &[&str] = &[
3126            "Hello-Rio",
3127            "hello_rio",
3128            "hello.rio",
3129            "-hello",
3130            "hello-",
3131            "hello rio",
3132            &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3133        ];
3134        let variants: &[(Build, &'static str)] = &[
3135            (
3136                |m| UpgradeInstruction::LoadModule { module: m },
3137                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3138            ),
3139            (
3140                |m| UpgradeInstruction::SoftPurge { module: m },
3141                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3142            ),
3143            (
3144                |m| UpgradeInstruction::Purge { module: m },
3145                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3146            ),
3147        ];
3148        for (build, expected_kind) in variants {
3149            for module in footguns {
3150                let instr = build((*module).to_string());
3151                let err = instr.validate().unwrap_err();
3152                match err {
3153                    UpgradeError::ModuleInvalid {
3154                        kind,
3155                        module: m,
3156                        reason,
3157                    } => {
3158                        assert_eq!(
3159                            kind, *expected_kind,
3160                            ":module footgun on {instr:?} must tag the lisp-form"
3161                        );
3162                        assert_eq!(
3163                            m, *module,
3164                            "ModuleInvalid must carry the offending value verbatim"
3165                        );
3166                        assert!(
3167                            !reason.is_empty(),
3168                            "ModuleInvalid reason must name the specific violation \
3169                             (the predicate's parser-shaped wording from \
3170                             `is_dns_1123_label`), got empty"
3171                        );
3172                    }
3173                    other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3174                }
3175            }
3176        }
3177    }
3178
3179    #[test]
3180    fn validate_accepts_canonical_module_names() {
3181        // Positive control: every documented authoring shape — bare
3182        // identifier, with hyphens, with digits, the
3183        // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3184        // references — passes the gate. Drift here = a future
3185        // tighten that rejects any of these surfaces as a
3186        // test-failure at the predicate boundary, not piecemeal
3187        // across per-instruction call sites.
3188        let canonical: &[&str] = &[
3189            "hello-rio",
3190            "hello-rio-old",
3191            "cache",
3192            "cache-v2",
3193            "x",
3194            "a1",
3195            "0a",
3196            "abc-123-def",
3197        ];
3198        for module in canonical {
3199            UpgradeInstruction::LoadModule {
3200                module: (*module).to_string(),
3201            }
3202            .validate()
3203            .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3204            UpgradeInstruction::SoftPurge {
3205                module: (*module).to_string(),
3206            }
3207            .validate()
3208            .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3209            UpgradeInstruction::Purge {
3210                module: (*module).to_string(),
3211            }
3212            .validate()
3213            .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3214        }
3215    }
3216
3217    #[test]
3218    fn validate_empty_takes_precedence_over_invalid() {
3219        // Empty input is rejected via the narrower `ModuleEmpty`
3220        // diagnostic before the DNS-1123 predicate is consulted, so
3221        // a future tighten that adds another stage between the two
3222        // doesn't accidentally reorder the diagnostic precedence.
3223        // Mirrors the empty-first cascade on every peer DNS-1123
3224        // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3225        // `SupervisorSpec::validate`'s child-name arm).
3226        let err = UpgradeInstruction::LoadModule {
3227            module: String::new(),
3228        }
3229        .validate()
3230        .unwrap_err();
3231        assert_eq!(
3232            err,
3233            UpgradeError::ModuleEmpty {
3234                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3235            }
3236        );
3237    }
3238
3239    #[test]
3240    fn validate_rejects_empty_script() {
3241        let i = UpgradeInstruction::StateChange {
3242            script: PathBuf::new(),
3243        };
3244        assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3245    }
3246
3247    #[test]
3248    fn validate_rejects_absolute_script() {
3249        let i = UpgradeInstruction::StateChange {
3250            script: PathBuf::from("/etc/migrations.lisp"),
3251        };
3252        assert!(matches!(
3253            i.validate().unwrap_err(),
3254            UpgradeError::AbsoluteScript { .. }
3255        ));
3256    }
3257
3258    #[test]
3259    fn validate_rejects_parent_escape_script() {
3260        let i = UpgradeInstruction::StateChange {
3261            script: PathBuf::from("../sibling/migrations.lisp"),
3262        };
3263        assert!(matches!(
3264            i.validate().unwrap_err(),
3265            UpgradeError::ParentEscapeScript { .. }
3266        ));
3267        // mid-path `..` is also caught
3268        let i2 = UpgradeInstruction::StateChange {
3269            script: PathBuf::from("lib/../../escaped.lisp"),
3270        };
3271        assert!(matches!(
3272            i2.validate().unwrap_err(),
3273            UpgradeError::ParentEscapeScript { .. }
3274        ));
3275    }
3276
3277    // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3278    // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3279    // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3280    // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3281    // consumer; the file-type contract is identical, so the per-axis
3282    // test grid is mirrored leg-for-leg.
3283
3284    #[test]
3285    fn validate_rejects_no_extension_script() {
3286        // Fail-before-pass-after: the canonical "I declared the
3287        // migration script but forgot the `.lisp` extension"
3288        // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3289        // The wasm-engine's `tatara_lisp::read` consumer needs a
3290        // file-type contract beyond the structural-shape gate; a
3291        // no-extension path past `is_sandboxed_relative_path` would
3292        // surface a parser-shaped diagnostic at hot-upgrade migration
3293        // time far from the source caixa.lisp.
3294        for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3295            let i = UpgradeInstruction::StateChange {
3296                script: PathBuf::from(relpath),
3297            };
3298            let err = i.validate().unwrap_err();
3299            assert!(
3300                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3301                         if s == Path::new(relpath)),
3302                "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3303                 carrying the offending path verbatim, got {err:?}"
3304            );
3305        }
3306    }
3307
3308    #[test]
3309    fn validate_rejects_non_lisp_extension_script() {
3310        // Wrong-extension sweep across common authoring footguns: the
3311        // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3312        // drag in from the workspace tree, the `.rs` shape that an
3313        // IDE auto-complete might propose, the `.lisp.bak` shape an
3314        // editor might leave behind, and the `.lispx` near-miss that
3315        // a typo would produce. Each must surface as
3316        // `NonLispExtensionScript` carrying the offending path
3317        // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3318        // rejects all of these at hot-upgrade migration time, and
3319        // the gate lifts that contract to validate time. Mirrors the
3320        // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3321        // the `:behavior :on-*` axis leg-for-leg — same downstream
3322        // consumer, same accepted set, same per-axis test grid.
3323        let footguns: &[&str] = &[
3324            "lib/migrations.rs",
3325            "lib/migrations.txt",
3326            "lib/migrations.md",
3327            "lib/migrations.json",
3328            "lib/migrations.yaml",
3329            "lib/migrations.toml",
3330            "lib/migrations.lisp.bak",
3331            "lib/migrations.lispx",
3332            "lib/migrations.lis",
3333        ];
3334        for relpath in footguns {
3335            let i = UpgradeInstruction::StateChange {
3336                script: PathBuf::from(relpath),
3337            };
3338            let err = i.validate().unwrap_err();
3339            assert!(
3340                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3341                         if s == Path::new(relpath)),
3342                "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3343                 carrying the offending path verbatim, got {err:?}"
3344            );
3345        }
3346    }
3347
3348    #[test]
3349    fn validate_rejects_uppercase_lisp_extension_script() {
3350        // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3351        // case-folded shapes a case-insensitive volume's existence
3352        // check would match the on-disk file — but the
3353        // canonical-form codec emits lowercase `.lisp` verbatim, so
3354        // a case-folded shape mismatches the round-trip-stable
3355        // canonical form (THEORY.md §V.2.7 render-determinism).
3356        // Same case-sensitive discipline the byte-size / duration
3357        // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3358        // and every other shape-gate predicate in `render.rs` (label
3359        // / scheme / unit boundaries). Mirrors the peer
3360        // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3361        for relpath in [
3362            "lib/migrations.LISP",
3363            "lib/migrations.Lisp",
3364            "lib/migrations.LiSp",
3365            "lib/migrations.lISP",
3366        ] {
3367            let i = UpgradeInstruction::StateChange {
3368                script: PathBuf::from(relpath),
3369            };
3370            let err = i.validate().unwrap_err();
3371            assert!(
3372                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3373                         if s == Path::new(relpath)),
3374                "case-folded `.lisp` extension {relpath:?} must surface as \
3375                 NonLispExtensionScript (strict lowercase, canonical-form \
3376                 round-trip pin), got {err:?}"
3377            );
3378        }
3379    }
3380
3381    #[test]
3382    fn validate_accepts_canonical_lisp_extension_scripts() {
3383        // Positive-control sweep across every canonical in-tree
3384        // authoring shape: bare filename, standard `lib/`
3385        // subdirectory, deeply-nested migrations subdirectory,
3386        // explicit current-dir-relative prefix, mid-path `./`
3387        // segment, multi-dot stem (the version-suffix shape
3388        // `lib/migrations/v.0.1.lisp` an author might use to encode
3389        // the migration's `:from` version into the filename). Drift
3390        // here = a future tightening that rejects any of these
3391        // surfaces as a test-failure at the per-axis validator
3392        // boundary, not piecemeal across renderer / layout-checker
3393        // call sites. Mirrors the peer `BehaviorSpec` positive-set
3394        // sweep (c97815a).
3395        let canonical: &[&str] = &[
3396            "lib/migrations.lisp",
3397            "lib/migrations/v01-to-v02.lisp",
3398            "migrations.lisp",
3399            "a.lisp",
3400            "./lib/migrations.lisp",
3401            "lib/./migrations.lisp",
3402            "lib/migrations/v.0.1.lisp",
3403        ];
3404        for relpath in canonical {
3405            UpgradeInstruction::StateChange {
3406                script: PathBuf::from(relpath),
3407            }
3408            .validate()
3409            .unwrap_or_else(|e| {
3410                panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3411            });
3412        }
3413    }
3414
3415    #[test]
3416    fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3417        // Cross-arm precedence pin: a script that is *both*
3418        // sandbox-escaping (Empty / Absolute / ParentEscape) and
3419        // non-`.lisp` must surface the more-fundamental
3420        // sandbox-shape diagnostic first — the canonical fix
3421        // collapses both into "pin a relative `.lisp` path under the
3422        // caixa root", and the `.lisp` remediation would be
3423        // misleading when the offending path can never resolve under
3424        // the caixa root anyway. Mirrors the peer
3425        // `BehaviorError` cross-arm precedence (c97815a) and the
3426        // sibling `LimitsError`
3427        // (`MemoryZero` → `MemoryBelowWasm32Page` →
3428        // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3429        // smallest-scope-arm-fires-last posture.
3430        let i_empty = UpgradeInstruction::StateChange {
3431            script: PathBuf::new(),
3432        };
3433        assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3434        let i_abs = UpgradeInstruction::StateChange {
3435            script: PathBuf::from("/etc/migrations.txt"),
3436        };
3437        assert!(
3438            matches!(
3439                i_abs.validate().unwrap_err(),
3440                UpgradeError::AbsoluteScript { .. }
3441            ),
3442            "absolute + non-`.lisp` must surface AbsoluteScript first"
3443        );
3444        let i_esc = UpgradeInstruction::StateChange {
3445            script: PathBuf::from("../sibling/migrations.rs"),
3446        };
3447        assert!(
3448            matches!(
3449                i_esc.validate().unwrap_err(),
3450                UpgradeError::ParentEscapeScript { .. }
3451            ),
3452            "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3453        );
3454    }
3455
3456    #[test]
3457    fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3458        // Diagnostic-shape pin: the surfaced error message names the
3459        // offending path verbatim (so the author can grep their
3460        // caixa.lisp for the literal value), the `.lisp` extension
3461        // is named in the remediation, and the downstream consumer
3462        // (`tatara_lisp::read` at hot-upgrade migration time) is
3463        // named so the author can trace the contract back to its
3464        // source. Same self-locating shape every per-axis variant
3465        // carries (`BehaviorError::NonLispExtension`, c97815a;
3466        // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3467        let bad = PathBuf::from("lib/migrations.txt");
3468        let err = UpgradeInstruction::StateChange {
3469            script: bad.clone(),
3470        }
3471        .validate()
3472        .unwrap_err();
3473        let msg = err.to_string();
3474        assert!(
3475            msg.contains("lib/migrations.txt"),
3476            "diagnostic must name the offending path verbatim, got {msg:?}"
3477        );
3478        assert!(
3479            msg.contains(".lisp"),
3480            "diagnostic must name the expected `.lisp` extension, got {msg:?}"
3481        );
3482        assert!(
3483            msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
3484            "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
3485        );
3486        match err {
3487            UpgradeError::NonLispExtensionScript { script } => {
3488                assert_eq!(
3489                    script, bad,
3490                    "variant must carry the offending path verbatim"
3491                );
3492            }
3493            other => panic!("expected NonLispExtensionScript, got {other:?}"),
3494        }
3495    }
3496
3497    #[test]
3498    fn declared_path_only_for_state_change() {
3499        let load = UpgradeInstruction::LoadModule { module: "x".into() };
3500        assert!(load.declared_path().is_none());
3501        let mig = UpgradeInstruction::StateChange {
3502            script: PathBuf::from("lib/m.lisp"),
3503        };
3504        assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
3505    }
3506
3507    #[test]
3508    fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
3509        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3510        // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
3511        // predicate: [`UpgradeInstruction::Restart`] is the only variant
3512        // that satisfies `.is_restart()`; every module-bearing arm
3513        // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
3514        // `StateChange` arm all return `false`. This pin makes the
3515        // partition invariant load-bearing at caixa-core test time so a
3516        // future derive regression (a hole that returns `false` for
3517        // `Restart` too, or a byte-collision that flips a second variant
3518        // to `true`) trips here rather than laundering the arm at
3519        // [`Self::validate_restart_exclusive`]'s paired positive /
3520        // negated filter sites (a hole flips restart-count to 0 →
3521        // vacuous OK; a collision flips restart-count > 1 → false
3522        // `RestartNotExclusive` on an entry the author declared without
3523        // any `(:restart)`). Peer of the sibling
3524        // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
3525        // pin on the M0 `CaixaKind` axis.
3526        let cases: &[(UpgradeInstruction, bool)] = &[
3527            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3528            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3529            (UpgradeInstruction::Purge { module: "c".into() }, false),
3530            (
3531                UpgradeInstruction::StateChange {
3532                    script: PathBuf::from("lib/m.lisp"),
3533                },
3534                false,
3535            ),
3536            (UpgradeInstruction::Restart, true),
3537        ];
3538        for (variant, expected) in cases {
3539            assert_eq!(
3540                variant.is_restart(),
3541                *expected,
3542                "UpgradeInstruction::{variant:?}.is_restart() must \
3543                 return {expected} (partition invariant on the \
3544                 IsVariant-derived arm-discriminator predicate)"
3545            );
3546        }
3547    }
3548
3549    #[test]
3550    fn validate_restart_exclusive_routes_through_is_restart_predicate() {
3551        // Byte-identity pin on the paired positive / negated
3552        // `.is_restart()` filters at
3553        // [`Self::validate_restart_exclusive`] against the pre-lift
3554        // `matches!(i, UpgradeInstruction::Restart)` /
3555        // `!matches!(i, UpgradeInstruction::Restart)` predicates every
3556        // consumer of the gate previously coupled to inline. Asserts
3557        // the two projections agree byte-for-byte on every arm of the
3558        // enum, so a future derive regression that flipped either
3559        // predicate's arm-set would surface here at caixa-core test
3560        // time rather than at
3561        // [`Self::validate_restart_exclusive`]'s per-entry restart-
3562        // count / other-kinds tabulation far from the derive site.
3563        // Same peer-shape pin every sibling
3564        // `IsVariant`-derive-routed gate carries on the substrate's
3565        // closed-set typed-enum surface.
3566        let cases: Vec<UpgradeInstruction> = vec![
3567            UpgradeInstruction::LoadModule { module: "a".into() },
3568            UpgradeInstruction::SoftPurge { module: "b".into() },
3569            UpgradeInstruction::Purge { module: "c".into() },
3570            UpgradeInstruction::StateChange {
3571                script: PathBuf::from("lib/m.lisp"),
3572            },
3573            UpgradeInstruction::Restart,
3574        ];
3575        for instr in &cases {
3576            let via_predicate = instr.is_restart();
3577            let via_matches = matches!(instr, UpgradeInstruction::Restart);
3578            assert_eq!(
3579                via_predicate, via_matches,
3580                "UpgradeInstruction::{instr:?}: is_restart() must \
3581                 byte-equal matches!(_, UpgradeInstruction::Restart) — \
3582                 the pre-lift open-coded pattern and the \
3583                 IsVariant-derived predicate are the same axis, \
3584                 one typed dispatch"
3585            );
3586        }
3587    }
3588
3589    #[test]
3590    fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
3591        // The fail-before-pass-after pin on the lifted
3592        // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
3593        // arm-discriminator predicate:
3594        // [`UpgradeInstruction::SoftPurge`] and
3595        // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
3596        // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
3597        // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
3598        // on the paired two-phase-load half,
3599        // [`UpgradeInstruction::StateChange`] on the
3600        // `gen_server:code_change/3`-analog migration axis,
3601        // [`UpgradeInstruction::Restart`] on the OTP terminal-
3602        // fallback shape) returns `false`. This pin makes the
3603        // partition invariant load-bearing at caixa-core test time
3604        // so a future accessor regression (a hole that returns
3605        // `false` for `SoftPurge` or `Purge`, or a byte-collision
3606        // that flips `LoadModule` / `StateChange` / `Restart` to
3607        // `true`) trips here rather than laundering the arm at the
3608        // three within-entry cross-instruction cleanup-facing gates
3609        // ([`UpgradeFromEntry::validate_purge_ordering`],
3610        // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
3611        // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
3612        // hole would silently accept a cleanup-shaped entry the
3613        // three gates should refuse; a collision would fire a
3614        // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
3615        // `DuplicateCleanup` refusal on a well-shaped
3616        // [`UpgradeInstruction::LoadModule`] / `StateChange` /
3617        // `Restart` arm the three gates should pass through. Peer
3618        // of the sibling
3619        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3620        // pin on the single-arm terminal-fallback partition —
3621        // extended here from the single-arm case onto the two-arm
3622        // cleanup-family union case.
3623        let cases: &[(UpgradeInstruction, bool)] = &[
3624            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3625            (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
3626            (UpgradeInstruction::Purge { module: "c".into() }, true),
3627            (
3628                UpgradeInstruction::StateChange {
3629                    script: PathBuf::from("lib/m.lisp"),
3630                },
3631                false,
3632            ),
3633            (UpgradeInstruction::Restart, false),
3634        ];
3635        for (variant, expected) in cases {
3636            assert_eq!(
3637                variant.is_cleanup(),
3638                *expected,
3639                "UpgradeInstruction::{variant:?}.is_cleanup() must \
3640                 return {expected} (partition invariant on the \
3641                 lifted OTP-appup two-arm cleanup-family arm-\
3642                 discriminator predicate)"
3643            );
3644        }
3645    }
3646
3647    #[test]
3648    fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
3649        // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
3650        // composition against the two [`gen_platform::IsVariant`]-
3651        // derive-generated per-variant classifiers it routes through
3652        // — the accessor's one body must byte-equal
3653        // `self.is_soft_purge() || self.is_purge()` across every arm
3654        // of the closed-set enum, so a future silent detour that
3655        // reintroduced a raw `matches!` pattern or that stopped
3656        // composing through the derive-generated per-variant
3657        // predicates (an accidental `self.is_soft_purge()` on its
3658        // own — silently dropping the `Purge` arm; an accidental
3659        // `self.is_purge() || self.is_state_change()` — silently
3660        // folding the migration arm into the cleanup family; a
3661        // typo `&&` for the union `||` — silently classifying no
3662        // arm as cleanup) trips here at caixa-core test time
3663        // rather than laundering the arm at the three within-entry
3664        // cross-instruction cleanup-facing gates. Same peer-shape
3665        // pin the sibling
3666        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3667        // carries on the paired terminal-fallback axis.
3668        let cases: Vec<UpgradeInstruction> = vec![
3669            UpgradeInstruction::LoadModule { module: "a".into() },
3670            UpgradeInstruction::SoftPurge { module: "b".into() },
3671            UpgradeInstruction::Purge { module: "c".into() },
3672            UpgradeInstruction::StateChange {
3673                script: PathBuf::from("lib/m.lisp"),
3674            },
3675            UpgradeInstruction::Restart,
3676        ];
3677        for instr in &cases {
3678            let via_predicate = instr.is_cleanup();
3679            let via_composition = instr.is_soft_purge() || instr.is_purge();
3680            assert_eq!(
3681                via_predicate, via_composition,
3682                "UpgradeInstruction::{instr:?}: is_cleanup() must \
3683                 byte-equal is_soft_purge() || is_purge() — the \
3684                 lifted union predicate and its per-variant \
3685                 composition are the same axis, one typed dispatch"
3686            );
3687        }
3688    }
3689
3690    #[test]
3691    fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
3692        // Composition-pin the load-bearing invariant every consumer
3693        // that routes through `is_cleanup()` + `declared_module()`
3694        // relies on: any [`UpgradeInstruction`] value whose
3695        // `.is_cleanup()` returns `true` must have a `Some(_)`
3696        // `.declared_module()`. This makes the three within-entry
3697        // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
3698        // implies declared_module() is Some")` structurally
3699        // infallible at build time — a future refactor that added
3700        // a cleanup-shaped variant carrying no `:module` would trip
3701        // here rather than panic at
3702        // [`UpgradeFromEntry::validate_purge_ordering`] /
3703        // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
3704        // [`UpgradeFromEntry::validate_cleanup_singularity`] at
3705        // runtime on the offending author's caixa.lisp.
3706        let cases: Vec<UpgradeInstruction> = vec![
3707            UpgradeInstruction::LoadModule { module: "a".into() },
3708            UpgradeInstruction::SoftPurge { module: "b".into() },
3709            UpgradeInstruction::Purge { module: "c".into() },
3710            UpgradeInstruction::StateChange {
3711                script: PathBuf::from("lib/m.lisp"),
3712            },
3713            UpgradeInstruction::Restart,
3714        ];
3715        for instr in &cases {
3716            if instr.is_cleanup() {
3717                assert!(
3718                    instr.declared_module().is_some(),
3719                    "UpgradeInstruction::{instr:?}: is_cleanup() \
3720                     must imply declared_module().is_some() — the \
3721                     three within-entry cross-instruction cleanup-\
3722                     facing gates rely on this invariant to route \
3723                     the cleanup-target :module scalar through the \
3724                     sibling declared_module accessor without a \
3725                     pattern-bound `module` binding"
3726                );
3727            }
3728        }
3729    }
3730
3731    #[test]
3732    fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
3733        // Composition-pin the load-bearing invariant
3734        // [`UpgradeFromEntry::validate_load_singularity`] relies on
3735        // when routing the per-instruction load-family arm-discriminator
3736        // through the sibling
3737        // [`UpgradeInstruction::is_load_module`] +
3738        // [`UpgradeInstruction::declared_module`] accessor pair: any
3739        // [`UpgradeInstruction`] value whose `.is_load_module()`
3740        // returns `true` must have a `Some(_)` `.declared_module()`.
3741        // This makes the gate's `.expect("is_load_module() implies
3742        // declared_module() is Some")` structurally infallible at
3743        // build time — a future refactor that added a load-shaped
3744        // variant carrying no `:module` would trip here rather than
3745        // panic at [`UpgradeFromEntry::validate_load_singularity`]
3746        // at runtime on the offending author's caixa.lisp. Sibling
3747        // of the peer
3748        // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3749        // composition pin on the two-arm cleanup-family axis — same
3750        // "predicate implies accessor" discipline extended onto the
3751        // single-arm load-family axis, closes the load-vs-cleanup
3752        // pair on the substrate primitive's typed dispatch discipline.
3753        let cases: Vec<UpgradeInstruction> = vec![
3754            UpgradeInstruction::LoadModule { module: "a".into() },
3755            UpgradeInstruction::SoftPurge { module: "b".into() },
3756            UpgradeInstruction::Purge { module: "c".into() },
3757            UpgradeInstruction::StateChange {
3758                script: PathBuf::from("lib/m.lisp"),
3759            },
3760            UpgradeInstruction::Restart,
3761        ];
3762        for instr in &cases {
3763            if instr.is_load_module() {
3764                assert!(
3765                    instr.declared_module().is_some(),
3766                    "UpgradeInstruction::{instr:?}: is_load_module() \
3767                     must imply declared_module().is_some() — the \
3768                     within-entry load-singularity gate relies on this \
3769                     invariant to route the load-target :module scalar \
3770                     through the sibling declared_module accessor \
3771                     without a pattern-bound `module` binding"
3772                );
3773            }
3774        }
3775    }
3776
3777    #[test]
3778    fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
3779     {
3780        // Byte-identity pin on the
3781        // [`UpgradeFromEntry::validate_load_singularity`] load-family
3782        // dispatch against the pre-lift
3783        // `match instr { UpgradeInstruction::LoadModule { module } =>
3784        // module.as_str(), _ => continue }` open-coded pattern-match
3785        // the site previously carried. Asserts the two projections
3786        // agree byte-for-byte on every arm of the enum — the
3787        // arm-discriminator via `is_load_module()` and the `:module`
3788        // scalar via `declared_module()` — so a future derive
3789        // regression that flipped the predicate's arm-set (a hole
3790        // returning `false` for [`UpgradeInstruction::LoadModule`], a
3791        // byte-collision flipping a second variant to `true`) or an
3792        // accessor extension that promoted an additional variant onto
3793        // the `String`-carrying axis would trip here at caixa-core
3794        // test time rather than laundering the arm at the gate's
3795        // per-entry load-singularity scan far from the derive site.
3796        // Peer of the sibling
3797        // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
3798        // byte-identity pin on the paired ordering-side load-family
3799        // sticky-latch dispatch (both consumers now agree on one
3800        // typed dispatch for the load-family axis) and the peer
3801        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
3802        // pin on the migration-family script-projection axis — the
3803        // three within-entry per-instruction-class singularity gates
3804        // now share one byte-identity pin apiece against their
3805        // respective substrate-primitive typed dispatches.
3806        //
3807        // Three-arm projective coverage:
3808        //   (a) `LoadModule` modules project through
3809        //       `declared_module()` byte-equal to the raw
3810        //       `module.as_str()` field access;
3811        //   (b) a duplicate-`LoadModule` input trips the gate on the
3812        //       second occurrence with `DuplicateLoadModule` carrying
3813        //       the offending module verbatim;
3814        //   (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
3815        //       `StateChange` / `Restart`) leaves the gate vacuous
3816        //       with `Ok(())` — the `!instr.is_load_module()`
3817        //       `continue` fall-through pins.
3818        //
3819        // Fail-before-pass-after verified locally: swapping the
3820        // production `if !instr.is_load_module() { continue; } let
3821        // module = instr.declared_module().expect(…);` back to `let
3822        // module = match instr { UpgradeInstruction::LoadModule
3823        // { module } => module.as_str(), _ => continue, };` keeps
3824        // arms (a)-(c) passing but silently detaches the gate from
3825        // the accessor's typed dispatch — any future
3826        // `is_load_module` / `declared_module` extension (a hole in
3827        // either predicate, a promotion of an additional variant
3828        // onto the `String`-carrying axis, an operator-side
3829        // pre-parsed caixa-name cache the accessor materializes)
3830        // would then silently disagree between this gate's raw
3831        // pattern-match and the peer per-`UpgradeInstruction`
3832        // consumers that route through the accessor pair.
3833
3834        // (a) LoadModule projection byte-equal via
3835        //     is_load_module() + declared_module().
3836        let lm = UpgradeInstruction::LoadModule {
3837            module: "hello-rio".into(),
3838        };
3839        assert!(
3840            lm.is_load_module(),
3841            "LoadModule must satisfy is_load_module() — the gate's \
3842             load-family arm-discriminator relies on this partition"
3843        );
3844        assert_eq!(
3845            lm.declared_module(),
3846            Some("hello-rio"),
3847            "declared_module() must project the LoadModule :module \
3848             byte-equal to the raw field access — accessor divergence \
3849             would silently detach the gate from the projection every \
3850             peer per-`UpgradeInstruction` consumer routes through"
3851        );
3852
3853        // (b) Duplicate-LoadModule input trips the gate.
3854        let dup = entry(
3855            "0.1.0",
3856            vec![
3857                UpgradeInstruction::LoadModule { module: "x".into() },
3858                UpgradeInstruction::LoadModule { module: "x".into() },
3859            ],
3860        );
3861        assert_eq!(
3862            dup.validate_load_singularity(),
3863            Err(UpgradeError::DuplicateLoadModule {
3864                from: "0.1.0".into(),
3865                module: "x".into(),
3866            }),
3867            "duplicate LoadModule modules within one entry must fire \
3868             DuplicateLoadModule byte-identical to the pre-lift \
3869             pattern-match shape"
3870        );
3871
3872        // (c) Non-LoadModule-only input leaves the gate vacuous.
3873        let no_load = entry(
3874            "0.1.0",
3875            vec![
3876                UpgradeInstruction::StateChange {
3877                    script: PathBuf::from("lib/m.lisp"),
3878                },
3879                UpgradeInstruction::Restart,
3880            ],
3881        );
3882        assert_eq!(
3883            no_load.validate_load_singularity(),
3884            Ok(()),
3885            "non-LoadModule-only entries must leave the load-\
3886             singularity gate vacuous — the `!is_load_module()` \
3887             continue fall-through pins"
3888        );
3889    }
3890
3891    #[test]
3892    fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
3893        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3894        // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
3895        // predicate: [`UpgradeInstruction::LoadModule`] is the only
3896        // variant that satisfies `.is_load_module()`; every cleanup arm
3897        // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
3898        // and the terminal-fallback arm (`Restart`) all return `false`.
3899        // This pin makes the partition invariant load-bearing at
3900        // caixa-core test time so a future derive regression (a hole
3901        // that returns `false` for `LoadModule` too, or a byte-collision
3902        // that flips a second variant to `true`) trips here rather than
3903        // laundering the arm at
3904        // [`Self::validate_purge_ordering`]'s load-family sticky-latch
3905        // dispatch — a hole would silently keep `loaded = false` through
3906        // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
3907        // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
3908        // a collision would flip `loaded = true` on a well-shaped
3909        // cleanup-only entry and silently swallow the load-less
3910        // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
3911        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3912        // and
3913        // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
3914        // pins on the paired terminal-fallback and cleanup-family
3915        // arm-discriminator axes — closes the last unlifted `matches!`-
3916        // based arm-discriminator axis on the OTP-appup closed-set
3917        // typed enum.
3918        let cases: &[(UpgradeInstruction, bool)] = &[
3919            (UpgradeInstruction::LoadModule { module: "a".into() }, true),
3920            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3921            (UpgradeInstruction::Purge { module: "c".into() }, false),
3922            (
3923                UpgradeInstruction::StateChange {
3924                    script: PathBuf::from("lib/m.lisp"),
3925                },
3926                false,
3927            ),
3928            (UpgradeInstruction::Restart, false),
3929        ];
3930        for (variant, expected) in cases {
3931            assert_eq!(
3932                variant.is_load_module(),
3933                *expected,
3934                "UpgradeInstruction::{variant:?}.is_load_module() must \
3935                 return {expected} (partition invariant on the \
3936                 IsVariant-derived arm-discriminator predicate)"
3937            );
3938        }
3939    }
3940
3941    #[test]
3942    fn validate_purge_ordering_routes_through_is_load_module_predicate() {
3943        // Byte-identity pin on the [`Self::validate_purge_ordering`]
3944        // load-family sticky-latch dispatch against the pre-lift
3945        // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
3946        // predicate the site previously open-coded. Asserts the two
3947        // projections agree byte-for-byte on every arm of the enum, so
3948        // a future derive regression that flipped the predicate's
3949        // arm-set would surface here at caixa-core test time rather
3950        // than at [`Self::validate_purge_ordering`]'s per-entry
3951        // load-before-cleanup ordering scan far from the derive site.
3952        // Same peer-shape pin the sibling
3953        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3954        // carries on the paired terminal-fallback axis and the
3955        // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
3956        // carries on the two-arm cleanup-family axis — the third and
3957        // final byte-identity pin closes the substrate primitive's
3958        // arm-discriminator dispatch discipline on the OTP-appup
3959        // closed-set typed enum.
3960        let cases: Vec<UpgradeInstruction> = vec![
3961            UpgradeInstruction::LoadModule { module: "a".into() },
3962            UpgradeInstruction::SoftPurge { module: "b".into() },
3963            UpgradeInstruction::Purge { module: "c".into() },
3964            UpgradeInstruction::StateChange {
3965                script: PathBuf::from("lib/m.lisp"),
3966            },
3967            UpgradeInstruction::Restart,
3968        ];
3969        for instr in &cases {
3970            let via_predicate = instr.is_load_module();
3971            let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
3972            assert_eq!(
3973                via_predicate, via_matches,
3974                "UpgradeInstruction::{instr:?}: is_load_module() must \
3975                 byte-equal matches!(_, UpgradeInstruction::LoadModule \
3976                 {{ .. }}) — the pre-lift open-coded pattern and the \
3977                 IsVariant-derived predicate are the same axis, one \
3978                 typed dispatch"
3979            );
3980        }
3981    }
3982
3983    #[test]
3984    fn declared_module_only_for_module_bearing_variants() {
3985        // Pinned partition of the `UpgradeInstruction` closed-set
3986        // variant space against the sibling of the peer
3987        // `declared_path` accessor: every OTP-appup module-bearing
3988        // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
3989        // `:module` string byte-for-byte through the lifted
3990        // `declared_module` accessor; every non-module-bearing variant
3991        // (`StateChange` on the peer `:script`-carrying axis;
3992        // `Restart` on the OTP terminal-fallback data-less axis)
3993        // returns `None`. Mirrors the peer
3994        // `declared_path_only_for_state_change` pin — the pair now
3995        // closes both scalar-carrying axes on the enum on one lifted
3996        // `Option<&…>` accessor apiece.
3997        let load = UpgradeInstruction::LoadModule {
3998            module: "hello-rio".into(),
3999        };
4000        assert_eq!(load.declared_module(), Some("hello-rio"));
4001        let soft = UpgradeInstruction::SoftPurge {
4002            module: "hello-rio-old".into(),
4003        };
4004        assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4005        let hard = UpgradeInstruction::Purge {
4006            module: "hello-rio-ancient".into(),
4007        };
4008        assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4009        let mig = UpgradeInstruction::StateChange {
4010            script: PathBuf::from("lib/m.lisp"),
4011        };
4012        assert!(mig.declared_module().is_none());
4013        assert!(UpgradeInstruction::Restart.declared_module().is_none());
4014    }
4015
4016    #[test]
4017    fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4018        // Byte-identity pin on the two-accessor partition: every
4019        // `UpgradeInstruction` variant returns `Some` from *exactly
4020        // one* of {`declared_module`, `declared_path`} (the two
4021        // module-bearing / script-carrying axes) or from *neither*
4022        // (the OTP terminal-fallback `Restart` shape). No variant
4023        // returns `Some` from both — the two axes are disjoint by
4024        // construction, and this pin closes the disjointness at the
4025        // test surface so a future variant that leaks a scalar across
4026        // both axes fails at build time. Mirrors the peer
4027        // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4028        // discipline on the `BehaviorSpec` per-slot family.
4029        let cases: Vec<UpgradeInstruction> = vec![
4030            UpgradeInstruction::LoadModule { module: "a".into() },
4031            UpgradeInstruction::SoftPurge { module: "b".into() },
4032            UpgradeInstruction::Purge { module: "c".into() },
4033            UpgradeInstruction::StateChange {
4034                script: PathBuf::from("lib/m.lisp"),
4035            },
4036            UpgradeInstruction::Restart,
4037        ];
4038        for instr in &cases {
4039            let has_module = instr.declared_module().is_some();
4040            let has_path = instr.declared_path().is_some();
4041            assert!(
4042                !(has_module && has_path),
4043                "no variant may declare both a module and a path — offending: {instr:?}"
4044            );
4045            match instr {
4046                UpgradeInstruction::LoadModule { .. }
4047                | UpgradeInstruction::SoftPurge { .. }
4048                | UpgradeInstruction::Purge { .. } => {
4049                    assert!(has_module && !has_path, "module axis: {instr:?}");
4050                }
4051                UpgradeInstruction::StateChange { .. } => {
4052                    assert!(!has_module && has_path, "script axis: {instr:?}");
4053                }
4054                UpgradeInstruction::Restart => {
4055                    assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4056                }
4057            }
4058        }
4059    }
4060
4061    #[test]
4062    fn entry_with_chain_of_versions() {
4063        // Middle entry pairs a `:load-module` with the trailing
4064        // `:soft-purge` so it satisfies the within-entry purge-ordering
4065        // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4066        // preceding `:load-module`, mirroring the state-change-ordering
4067        // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4068        // test is *cross-entry* `:from` values; the within-entry shape
4069        // is incidental — keeping it canonical (`:load-module` before
4070        // `:soft-purge`) leaves the chain assertion load-bearing.
4071        let entries = vec![
4072            entry(
4073                "0.1.0",
4074                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4075            ),
4076            entry(
4077                "0.1.5",
4078                vec![
4079                    UpgradeInstruction::LoadModule { module: "x".into() },
4080                    UpgradeInstruction::SoftPurge {
4081                        module: "x-old".into(),
4082                    },
4083                ],
4084            ),
4085            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4086        ];
4087        for e in &entries {
4088            e.validate().unwrap();
4089        }
4090        let json = serde_json::to_string(&entries).unwrap();
4091        let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4092        assert_eq!(entries, back);
4093    }
4094
4095    #[test]
4096    fn empty_instructions_list_is_valid() {
4097        let e = entry("0.1.0", vec![]);
4098        e.validate().unwrap();
4099    }
4100
4101    #[test]
4102    fn json_uses_kebab_case_kind_tags() {
4103        let i = UpgradeInstruction::SoftPurge {
4104            module: "x-old".into(),
4105        };
4106        let json = serde_json::to_string(&i).unwrap();
4107        assert!(json.contains("\"kind\":\"soft-purge\""));
4108        let i2 = UpgradeInstruction::StateChange {
4109            script: PathBuf::from("m.lisp"),
4110        };
4111        let json2 = serde_json::to_string(&i2).unwrap();
4112        assert!(json2.contains("\"kind\":\"state-change\""));
4113    }
4114
4115    // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4116
4117    #[test]
4118    fn validate_upgrade_from_accepts_disjoint_versions() {
4119        // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4120        // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4121        // (and `entry_with_chain_of_versions` above) passes the cross-
4122        // entry gate. Different `:from` per entry is the intended
4123        // shape; the gate must not regress this baseline. Middle entry
4124        // pairs `:load-module` with `:soft-purge` to satisfy the
4125        // within-entry purge-ordering gate (see
4126        // `entry_with_chain_of_versions` for the same shape).
4127        let entries = vec![
4128            entry(
4129                "0.1.0",
4130                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4131            ),
4132            entry(
4133                "0.1.5",
4134                vec![
4135                    UpgradeInstruction::LoadModule { module: "x".into() },
4136                    UpgradeInstruction::SoftPurge {
4137                        module: "x-old".into(),
4138                    },
4139                ],
4140            ),
4141            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4142        ];
4143        validate_upgrade_from(&entries).unwrap();
4144    }
4145
4146    #[test]
4147    fn validate_upgrade_from_accepts_empty_list() {
4148        // Absent `:upgrade-from` (the bare `feira init` shape) — the
4149        // gate must trivially pass an empty list. Mirrors the per-axis
4150        // "empty list passes" positive control on every peer typed-
4151        // graph gate (`validate_membros` empty list, `validate_placement`
4152        // requires non-empty clusters but only after a `Placement`
4153        // exists, etc.).
4154        validate_upgrade_from(&[]).unwrap();
4155    }
4156
4157    #[test]
4158    fn validate_upgrade_from_rejects_duplicate_from() {
4159        // Fail-before-pass-after pin: two entries with the same parsed-
4160        // semver `:from` are an ambiguous edge in the typed upgrade
4161        // graph (OTP appup picks at most one matching block per running
4162        // version; with two matching blocks the operator picks either
4163        // set non-deterministically — author intent is one path per
4164        // prior version). Same set-not-multiset discipline as
4165        // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4166        // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4167        // `:entrada :paths` (eb3456d) — now extended onto the fifth
4168        // typed-graph axis.
4169        let entries = vec![
4170            entry(
4171                "0.1.0",
4172                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4173            ),
4174            entry(
4175                "0.1.0",
4176                vec![
4177                    UpgradeInstruction::LoadModule { module: "x".into() },
4178                    UpgradeInstruction::SoftPurge {
4179                        module: "x-old".into(),
4180                    },
4181                ],
4182            ),
4183        ];
4184        let err = validate_upgrade_from(&entries).unwrap_err();
4185        assert_eq!(
4186            err,
4187            UpgradeError::DuplicateFrom {
4188                from: "0.1.0".into()
4189            },
4190            "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4191             offending value verbatim"
4192        );
4193    }
4194
4195    #[test]
4196    fn validate_upgrade_from_treats_pre_release_as_distinct() {
4197        // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4198        // equal under semver (pre-release version is part of the
4199        // identity), so they're distinct upgrade paths and must not
4200        // collide. A future tightening that collapses pre-release into
4201        // the release version surfaces here.
4202        let entries = vec![
4203            entry("1.0.0", vec![UpgradeInstruction::Restart]),
4204            entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4205        ];
4206        validate_upgrade_from(&entries).unwrap();
4207    }
4208
4209    #[test]
4210    fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4211        // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4212        // compares build metadata (it derives equality across all
4213        // fields including `pre` + `build`), so `1.0.0+build1` and
4214        // `1.0.0+build2` are *not* duplicates from the gate's
4215        // perspective — the operator may treat the build-metadata
4216        // suffix as a tiebreaker even though the semver spec says
4217        // build metadata is ignored for precedence
4218        // (https://semver.org/#spec-item-10). Pin the conservative
4219        // behavior here so a future switch to a build-metadata-
4220        // stripping comparator surfaces as a test failure first; that
4221        // change would require coordinating with the wasm-operator's
4222        // `:from`-match dispatch step, which is the load-bearing
4223        // semantic we'd be mirroring.
4224        let entries = vec![
4225            entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4226            entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4227        ];
4228        validate_upgrade_from(&entries).unwrap();
4229    }
4230
4231    #[test]
4232    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4233        // Order pin: a malformed `:from` on the second entry surfaces
4234        // its `FromInvalid` diagnostic, not a (less-useful)
4235        // `DuplicateFrom`. The per-entry shape pass runs *inline*
4236        // before the duplicate-key insert — parallel to
4237        // `child_versao_invalid_fires_before_duplicate_check`
4238        // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4239        // (9888b13). Without this pin a future shortcut that runs the
4240        // cross-entry gate first would surface a duplicate diagnostic
4241        // on a string that isn't even parsable as a version.
4242        let entries = vec![
4243            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4244            entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4245        ];
4246        let err = validate_upgrade_from(&entries).unwrap_err();
4247        assert!(
4248            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4249            "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4250        );
4251    }
4252
4253    #[test]
4254    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4255        // Symmetric arm: a malformed shape on the *first* entry of a
4256        // duplicate pair surfaces its per-entry diagnostic too (not
4257        // the duplicate diagnostic that would otherwise fire on the
4258        // second entry). Pinned separately so a future shortcut that
4259        // walks the duplicate-check ahead of the per-entry pass for the
4260        // first entry only — easy regression to introduce — surfaces
4261        // here.
4262        let entries = vec![
4263            entry(
4264                "0.1.0",
4265                vec![UpgradeInstruction::LoadModule {
4266                    module: String::new(),
4267                }],
4268            ),
4269            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4270        ];
4271        let err = validate_upgrade_from(&entries).unwrap_err();
4272        assert_eq!(
4273            err,
4274            UpgradeError::ModuleEmpty {
4275                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4276            },
4277            "malformed instruction on the first entry of a duplicate pair must surface its \
4278             per-entry diagnostic before the duplicate gate fires, got {err:?}"
4279        );
4280    }
4281
4282    #[test]
4283    fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4284        // Diagnostic-shape pin: when three entries carry the same
4285        // `:from`, the gate reports the *first* collision (the second
4286        // entry) and stops — the third entry's duplicate is masked by
4287        // the first surfaced one. Mirrors
4288        // `validate_duplicate_child_diagnostic_names_first_collision`
4289        // (dbf50a9) on the supervisor axis.
4290        let entries = vec![
4291            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4292            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4293            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4294        ];
4295        let err = validate_upgrade_from(&entries).unwrap_err();
4296        assert_eq!(
4297            err,
4298            UpgradeError::DuplicateFrom {
4299                from: "0.1.0".into()
4300            }
4301        );
4302    }
4303
4304    #[test]
4305    fn validate_upgrade_from_single_entry_never_duplicates() {
4306        // Boundary control: a list of one entry can never produce a
4307        // duplicate, regardless of `:from` value (any single-element
4308        // set is trivially without duplicates). Pin this so a future
4309        // off-by-one in the seen-set insert doesn't accidentally flag
4310        // a single entry as duplicating itself.
4311        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4312        validate_upgrade_from(&entries).unwrap();
4313    }
4314
4315    // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4316
4317    #[test]
4318    fn versao_gate_accepts_strict_upgrade() {
4319        // Positive control: the canonical "chain prior versions →
4320        // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4321        // `:from` strictly less than the current `:versao` under
4322        // SemVer-2 precedence. The gate must not regress this baseline.
4323        let entries = vec![
4324            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4325            entry("0.1.5", vec![UpgradeInstruction::Restart]),
4326            entry("0.1.9", vec![UpgradeInstruction::Restart]),
4327        ];
4328        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4329    }
4330
4331    #[test]
4332    fn versao_gate_accepts_empty_entries() {
4333        // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4334        // the gate is a no-op when the entries list is empty. Mirrors
4335        // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4336        validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4337    }
4338
4339    #[test]
4340    fn versao_gate_rejects_equal_from() {
4341        // Self-upgrade no-op: declaring `:from "0.2.0"` while
4342        // `:versao "0.2.0"` means "upgrade from myself to myself" —
4343        // the operator's dispatch either skips silently or
4344        // trivially "succeeds" with no observable state change.
4345        // Reject as the canonical "I forgot to bump :versao when
4346        // adding this entry" footgun.
4347        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4348        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4349        assert_eq!(
4350            err,
4351            UpgradeError::FromNotBeforeVersao {
4352                from: "0.2.0".into(),
4353                versao: "0.2.0".into(),
4354            },
4355            ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4356             values verbatim, got {err:?}"
4357        );
4358    }
4359
4360    #[test]
4361    fn versao_gate_rejects_downgrade_from() {
4362        // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4363        // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4364        // the operator's `:from`-match dispatch can never reach (it
4365        // never runs a version >= the current one). Reject as the
4366        // canonical "I copy-pasted from the next minor version and
4367        // forgot to bump :versao" footgun.
4368        let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4369        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4370        assert_eq!(
4371            err,
4372            UpgradeError::FromNotBeforeVersao {
4373                from: "0.3.0".into(),
4374                versao: "0.2.0".into(),
4375            }
4376        );
4377    }
4378
4379    #[test]
4380    fn versao_gate_accepts_prerelease_before_release() {
4381        // SemVer §11 precedence: pre-release versions are *less than*
4382        // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4383        // FROM an RC TO the GA release is the canonical authoring
4384        // shape — must pass. A regression that collapses pre-release
4385        // into the release version (treating them as equal) surfaces
4386        // here as a false-positive rejection.
4387        let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4388        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4389    }
4390
4391    #[test]
4392    fn versao_gate_rejects_release_after_prerelease() {
4393        // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4394        // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4395        // the typical "I'm on an RC of a release that already
4396        // shipped" footgun. The gate names both values verbatim
4397        // so the author can grep for either side and fix in one
4398        // edit.
4399        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4400        let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4401        assert_eq!(
4402            err,
4403            UpgradeError::FromNotBeforeVersao {
4404                from: "0.2.0".into(),
4405                versao: "0.2.0-rc.1".into(),
4406            }
4407        );
4408    }
4409
4410    #[test]
4411    fn versao_gate_rejects_build_metadata_only_difference() {
4412        // SemVer §11 explicitly excludes build metadata from
4413        // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4414        // *equal* under [`semver::Version::cmp`]. From the
4415        // operator's `:from`-match dispatch perspective this is a
4416        // self-upgrade no-op (no semantic transition between the
4417        // two), so the gate rejects it — *unlike* the peer
4418        // duplicate-`:from` gate which uses derived `PartialEq` and
4419        // treats build-metadata variants as distinct dispatch keys.
4420        // The two gates' different equality notions are deliberate:
4421        // duplicate-check is conservative (preserves operator-side
4422        // tiebreaking surface), precedence-check is permissive
4423        // (matches operator-side dispatch semantic).
4424        let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4425        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4426        assert_eq!(
4427            err,
4428            UpgradeError::FromNotBeforeVersao {
4429                from: "0.2.0+build.1".into(),
4430                versao: "0.2.0".into(),
4431            }
4432        );
4433    }
4434
4435    #[test]
4436    fn versao_gate_silently_passes_on_unparseable_versao() {
4437        // Defensive arm: a malformed `:versao` (gated by the
4438        // narrower `ManifestError::VersaoInvalid` surface at the
4439        // load-bearing call site) must not regress into a
4440        // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4441        // the precedence error over an unparseable `:versao` would
4442        // mask the more actionable root cause (the author meant to
4443        // type `"0.2.0"`, not `"v0.2.0"`).
4444        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4445        validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4446    }
4447
4448    #[test]
4449    fn versao_gate_silently_passes_on_unparseable_from() {
4450        // Symmetric defensive arm: a malformed `:from` is gated by
4451        // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4452        // upstream at the LayoutInvariants call site. Surfacing the
4453        // precedence error over an unparseable `:from` from this
4454        // gate alone would mask the narrower `FromInvalid`
4455        // diagnostic that's expected to lead — same fall-through
4456        // posture as the unparseable-`:versao` arm above. The
4457        // wiring in `LayoutInvariants::verify` runs
4458        // `validate_upgrade_from` *before* this gate, so in practice
4459        // an unparseable `:from` surfaces as `FromInvalid` first
4460        // and this gate is never reached on that input.
4461        let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4462        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4463    }
4464
4465    #[test]
4466    fn versao_gate_reports_first_offending_entry() {
4467        // Determinism pin: with multiple offending entries the gate
4468        // surfaces the *first* one in declaration order — same
4469        // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4470        // on the peer gate. Walks the entries in order; first
4471        // failing `:from >= :versao` short-circuits.
4472        let entries = vec![
4473            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4474            entry("0.3.0", vec![UpgradeInstruction::Restart]),
4475            entry("0.4.0", vec![UpgradeInstruction::Restart]),
4476        ];
4477        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4478        assert_eq!(
4479            err,
4480            UpgradeError::FromNotBeforeVersao {
4481                from: "0.3.0".into(),
4482                versao: "0.2.0".into(),
4483            },
4484            "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
4485        );
4486    }
4487
4488    // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
4489
4490    #[test]
4491    fn validate_rejects_restart_mixed_with_load_module() {
4492        // The "I'll try the typed path *then* restart anyway" footgun:
4493        // an instructions list with `(:restart)` plus `(:load-module …)`
4494        // is dead code in both directions (succeed → restart discards
4495        // the work that just succeeded, defeating the typed sequence's
4496        // whole point; fail → restart never reached because the entry
4497        // already failed). The gate names the offending entry's `:from`
4498        // verbatim plus the kebab-case lisp-form of every non-`:restart`
4499        // peer so the author can grep their caixa.lisp for either side
4500        // and fix in one edit.
4501        let e = entry(
4502            "0.1.0",
4503            vec![
4504                UpgradeInstruction::LoadModule {
4505                    module: "hello-rio".into(),
4506                },
4507                UpgradeInstruction::Restart,
4508            ],
4509        );
4510        let err = e.validate().unwrap_err();
4511        assert_eq!(
4512            err,
4513            UpgradeError::RestartNotExclusive {
4514                from: "0.1.0".into(),
4515                restart_count: 1,
4516                other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
4517            },
4518            "restart + load-module mix must surface as RestartNotExclusive naming the \
4519             offending `:from` + the non-:restart kinds verbatim, got {err:?}"
4520        );
4521    }
4522
4523    #[test]
4524    fn validate_rejects_restart_mixed_with_full_typed_sequence() {
4525        // Sweep the typed-sequence universe — every non-`:restart`
4526        // variant alongside `:restart` — and assert every typed
4527        // instruction's lisp-form appears in `other_kinds` in
4528        // declaration order. The author should be able to grep for
4529        // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
4530        // `:purge`) and resolve in one pass. Drift in the `lisp_form`
4531        // mapping surfaces here.
4532        let e = entry(
4533            "0.1.0",
4534            vec![
4535                UpgradeInstruction::LoadModule {
4536                    module: "hello-rio".into(),
4537                },
4538                UpgradeInstruction::StateChange {
4539                    script: PathBuf::from("lib/m.lisp"),
4540                },
4541                UpgradeInstruction::SoftPurge {
4542                    module: "hello-rio-old".into(),
4543                },
4544                UpgradeInstruction::Purge {
4545                    module: "hello-rio-old".into(),
4546                },
4547                UpgradeInstruction::Restart,
4548            ],
4549        );
4550        let err = e.validate().unwrap_err();
4551        assert_eq!(
4552            err,
4553            UpgradeError::RestartNotExclusive {
4554                from: "0.1.0".into(),
4555                restart_count: 1,
4556                other_kinds: vec![
4557                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
4558                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
4559                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4560                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4561                ],
4562            },
4563        );
4564    }
4565
4566    #[test]
4567    fn validate_rejects_restart_duplicated() {
4568        // `((:restart) (:restart))` — multiple Restart variants in one
4569        // entry. The fallback is a single semantic (restart the pod;
4570        // the new version comes up fresh); repeating it is at best
4571        // redundant, at worst suggests the author thought the second
4572        // would re-trigger after the first. The gate reports
4573        // `restart_count: 2` so the diagnostic surfaces the duplication
4574        // mode unambiguously even when `other_kinds` is empty.
4575        let e = entry(
4576            "0.1.0",
4577            vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
4578        );
4579        let err = e.validate().unwrap_err();
4580        assert_eq!(
4581            err,
4582            UpgradeError::RestartNotExclusive {
4583                from: "0.1.0".into(),
4584                restart_count: 2,
4585                other_kinds: vec![],
4586            },
4587        );
4588    }
4589
4590    #[test]
4591    fn validate_accepts_sole_restart() {
4592        // Positive control: the canonical "this prior version's typed
4593        // upgrade is impossible — restart" authoring shape from the
4594        // UpgradeInstruction::Restart doc comment. `((:restart))` alone
4595        // is the entry's whole instructions list and the only valid
4596        // Restart-bearing shape.
4597        let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
4598        e.validate().unwrap();
4599    }
4600
4601    #[test]
4602    fn validate_accepts_typed_sequence_without_restart() {
4603        // Positive control: the canonical typed hot-upgrade authoring
4604        // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
4605        // `:state-change` → `:soft-purge`. Absent `:restart` is the
4606        // only shape that lets the sequence run to completion under
4607        // the wasm-operator's `:from`-match dispatch. Drift here =
4608        // a future tighten that rejects any canonical typed-only shape
4609        // surfaces as a regression at this gate.
4610        let e = entry(
4611            "0.1.0",
4612            vec![
4613                UpgradeInstruction::LoadModule {
4614                    module: "hello-rio".into(),
4615                },
4616                UpgradeInstruction::StateChange {
4617                    script: PathBuf::from("lib/m.lisp"),
4618                },
4619                UpgradeInstruction::SoftPurge {
4620                    module: "hello-rio-old".into(),
4621                },
4622            ],
4623        );
4624        e.validate().unwrap();
4625    }
4626
4627    // ── within-entry state-change-ordering invariant ───────────────────
4628
4629    #[test]
4630    fn validate_rejects_state_change_without_load() {
4631        // Fail-before-pass-after pin: a `:state-change` migrates state
4632        // into the newly-loaded code (gen_server:code_change/3 analog),
4633        // so an entry that runs it with no preceding `:load-module`
4634        // migrates state into code that was never loaded. The operator
4635        // runs instructions in declared order, so this is a build error,
4636        // not a runtime surprise (CAIXA-SDLC §III).
4637        let e = entry(
4638            "0.1.0",
4639            vec![UpgradeInstruction::StateChange {
4640                script: PathBuf::from("lib/m.lisp"),
4641            }],
4642        );
4643        let err = e.validate().unwrap_err();
4644        assert_eq!(
4645            err,
4646            UpgradeError::StateChangeWithoutPriorLoad {
4647                from: "0.1.0".into(),
4648                script: PathBuf::from("lib/m.lisp"),
4649            },
4650            "a `:state-change` with no preceding `:load-module` must surface as \
4651             StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
4652        );
4653    }
4654
4655    #[test]
4656    fn validate_rejects_state_change_before_load() {
4657        // Right-instructions-wrong-order: the load is present but runs
4658        // *after* the migration. Because the operator executes in
4659        // declared order, the migration runs before the new code is
4660        // resident — the same incoherence as the missing-load case.
4661        let e = entry(
4662            "0.1.0",
4663            vec![
4664                UpgradeInstruction::StateChange {
4665                    script: PathBuf::from("lib/m.lisp"),
4666                },
4667                UpgradeInstruction::LoadModule {
4668                    module: "hello-rio".into(),
4669                },
4670            ],
4671        );
4672        let err = e.validate().unwrap_err();
4673        assert!(
4674            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4675            "a `:state-change` ahead of its `:load-module` must surface as \
4676             StateChangeWithoutPriorLoad, got {err:?}"
4677        );
4678    }
4679
4680    #[test]
4681    fn validate_accepts_state_change_after_load() {
4682        // Positive control: the canonical `(:load-module …)
4683        // (:state-change …)` order validates. The load need not name
4684        // the same module the migration targets (StateChange carries a
4685        // script, not a module ref), so any preceding `:load-module`
4686        // satisfies "new code is resident before its migration runs".
4687        let e = entry(
4688            "0.1.0",
4689            vec![
4690                UpgradeInstruction::LoadModule {
4691                    module: "hello-rio".into(),
4692                },
4693                UpgradeInstruction::StateChange {
4694                    script: PathBuf::from("lib/m.lisp"),
4695                },
4696            ],
4697        );
4698        e.validate().unwrap();
4699    }
4700
4701    #[test]
4702    fn validate_accepts_multiple_state_changes_after_one_load() {
4703        // A single leading `:load-module` covers every subsequent
4704        // `:state-change` — the `loaded` latch stays set once the new
4705        // code is resident.
4706        let e = entry(
4707            "0.1.0",
4708            vec![
4709                UpgradeInstruction::LoadModule {
4710                    module: "hello-rio".into(),
4711                },
4712                UpgradeInstruction::StateChange {
4713                    script: PathBuf::from("lib/m1.lisp"),
4714                },
4715                UpgradeInstruction::StateChange {
4716                    script: PathBuf::from("lib/m2.lisp"),
4717                },
4718            ],
4719        );
4720        e.validate().unwrap();
4721    }
4722
4723    #[test]
4724    fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
4725     {
4726        // Byte-identity pin on the
4727        // [`UpgradeFromEntry::validate_state_change_ordering`] load →
4728        // migrate ordering dispatch against the pre-lift
4729        // `match instr { UpgradeInstruction::LoadModule { .. } =>
4730        // loaded = true, UpgradeInstruction::StateChange { script } if
4731        // !loaded => …, _ => {} }` open-coded pattern-match the site
4732        // previously carried. Asserts the two projections agree
4733        // byte-for-byte on every arm of the enum — the load-family
4734        // arm-discriminator via `is_load_module()` and the migration-
4735        // family `:script` scalar via `declared_path()` — so a future
4736        // derive regression that flipped the predicate's arm-set (a
4737        // hole returning `false` for [`UpgradeInstruction::LoadModule`],
4738        // a byte-collision flipping a second variant to `true`) or an
4739        // accessor extension that promoted an additional variant onto
4740        // the `PathBuf`-carrying axis would trip here at caixa-core
4741        // test time rather than laundering the arm at the gate's
4742        // per-entry ordering scan far from the derive site.
4743        //
4744        // Peer of the sibling
4745        // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
4746        // (c9ce91d) pin on the peer within-entry per-instruction-class
4747        // singularity gate's load-family + `String`-carrying dispatch,
4748        // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4749        // (580d0f1) pin on the paired load → cleanup ordering gate's
4750        // load-family sticky-latch dispatch, and the
4751        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4752        // pin on the peer within-entry per-instruction-class singularity
4753        // gate's migration-family script-projection dispatch — closes
4754        // the last unlifted `match`-shaped per-arm-hand-rolled load-
4755        // family arm-discriminator + migration-family script-projection
4756        // pair inside `impl UpgradeFromEntry`. The four within-entry
4757        // ordering / singularity gates now share one byte-identity pin
4758        // apiece against their respective substrate-primitive typed
4759        // dispatches on the OTP-appup closed-set enum.
4760        //
4761        // Three-arm projective coverage:
4762        //   (a) `LoadModule` satisfies `is_load_module()`, so the
4763        //       sticky-latch advances byte-equal to the pre-lift
4764        //       `UpgradeInstruction::LoadModule { .. }` arm; every
4765        //       other variant leaves the latch untouched;
4766        //   (b) a `((:state-change …))`-only entry (no preceding load)
4767        //       trips the gate on the first `StateChange` with
4768        //       `StateChangeWithoutPriorLoad` carrying the offending
4769        //       script verbatim — the migration-family script surfaces
4770        //       through `declared_path()` byte-equal to the raw
4771        //       `StateChange { script }` pattern-bound field;
4772        //   (c) a `((:load-module …) (:state-change …))` entry leaves
4773        //       the gate vacuous with `Ok(())` — the `loaded = true`
4774        //       latch on the first arm satisfies the `!loaded` guard
4775        //       negation on the second, so the `declared_path()`
4776        //       `Some(script)` fall-through does not fire — and a
4777        //       non-`StateChange`-non-`LoadModule` sequence
4778        //       (`SoftPurge` / `Purge` / `Restart` alone) also leaves
4779        //       the gate vacuous because `declared_path()` is `None`
4780        //       on all three of those arms.
4781        //
4782        // Fail-before-pass-after verified locally: swapping the
4783        // production `if instr.is_load_module() { loaded = true; }
4784        // else if !loaded && let Some(script) = instr.declared_path()
4785        // { … }` back to `match instr { UpgradeInstruction::LoadModule
4786        // { .. } => loaded = true, UpgradeInstruction::StateChange
4787        // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
4788        // passing but silently detaches the gate from the accessor's
4789        // typed dispatch — any future `is_load_module` / `declared_path`
4790        // extension (a hole in either predicate, a promotion of an
4791        // additional variant onto either axis, an operator-side
4792        // pre-resolved-path cache the accessor materializes) would
4793        // then silently disagree between this gate's raw pattern-match
4794        // and the peer per-`UpgradeInstruction` consumers that route
4795        // through the accessor pair.
4796
4797        // (a) is_load_module() partitions the arm-set byte-equal to
4798        //     the pre-lift `matches!(_, UpgradeInstruction::LoadModule
4799        //     { .. })` and declared_path() surfaces the StateChange
4800        //     `:script` byte-equal to the raw field access.
4801        let lm = UpgradeInstruction::LoadModule {
4802            module: "hello-rio".into(),
4803        };
4804        assert!(
4805            lm.is_load_module(),
4806            "LoadModule must satisfy is_load_module() — the gate's \
4807             load-family sticky-latch relies on this partition"
4808        );
4809        assert!(
4810            lm.declared_path().is_none(),
4811            "LoadModule must not carry a declared_path — the gate's \
4812             else-if migration-family arm must not fire on load arms"
4813        );
4814        let sc = UpgradeInstruction::StateChange {
4815            script: PathBuf::from("lib/m.lisp"),
4816        };
4817        assert!(
4818            !sc.is_load_module(),
4819            "StateChange must not satisfy is_load_module() — the gate's \
4820             sticky-latch must not advance on migration arms"
4821        );
4822        assert_eq!(
4823            sc.declared_path().map(std::path::PathBuf::as_path),
4824            Some(PathBuf::from("lib/m.lisp").as_path()),
4825            "declared_path() must project the StateChange :script \
4826             byte-equal to the raw field access — accessor divergence \
4827             would silently detach the gate from the projection every \
4828             peer per-`UpgradeInstruction` consumer routes through"
4829        );
4830
4831        // (b) A `((:state-change …))`-only entry trips
4832        //     StateChangeWithoutPriorLoad byte-identical to the
4833        //     pre-lift match-pattern shape.
4834        let no_prior_load = entry(
4835            "0.1.0",
4836            vec![UpgradeInstruction::StateChange {
4837                script: PathBuf::from("lib/m.lisp"),
4838            }],
4839        );
4840        assert_eq!(
4841            no_prior_load.validate_state_change_ordering(),
4842            Err(UpgradeError::StateChangeWithoutPriorLoad {
4843                from: "0.1.0".into(),
4844                script: PathBuf::from("lib/m.lisp"),
4845            }),
4846            "a `:state-change` with no preceding `:load-module` must fire \
4847             StateChangeWithoutPriorLoad carrying the offending script \
4848             verbatim through the declared_path() accessor"
4849        );
4850
4851        // (c) `((:load-module …) (:state-change …))` leaves the gate
4852        //     vacuous; so does a non-StateChange-non-LoadModule
4853        //     sequence (SoftPurge / Purge / Restart alone).
4854        let load_before_migrate = entry(
4855            "0.1.0",
4856            vec![
4857                UpgradeInstruction::LoadModule {
4858                    module: "hello-rio".into(),
4859                },
4860                UpgradeInstruction::StateChange {
4861                    script: PathBuf::from("lib/m.lisp"),
4862                },
4863            ],
4864        );
4865        assert_eq!(
4866            load_before_migrate.validate_state_change_ordering(),
4867            Ok(()),
4868            "load-before-migrate entries must leave the ordering gate \
4869             vacuous — the `loaded = true` sticky-latch on the first arm \
4870             satisfies the `!loaded` guard negation on the else-if arm"
4871        );
4872        for instr in [
4873            UpgradeInstruction::SoftPurge {
4874                module: "x-old".into(),
4875            },
4876            UpgradeInstruction::Purge {
4877                module: "x-old".into(),
4878            },
4879            UpgradeInstruction::Restart,
4880        ] {
4881            let e = entry("0.1.0", vec![instr.clone()]);
4882            assert_eq!(
4883                e.validate_state_change_ordering(),
4884                Ok(()),
4885                "non-StateChange-non-LoadModule sequence ({instr:?}) must \
4886                 leave the ordering gate vacuous — declared_path() is None \
4887                 on every non-StateChange arm, so the else-if migration-\
4888                 family arm never fires"
4889            );
4890        }
4891    }
4892
4893    #[test]
4894    fn validate_state_change_ordering_fires_after_restart_exclusive() {
4895        // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
4896        // shape is *both* state-change-without-load and restart-mixed.
4897        // The more-fundamental `RestartNotExclusive` must win (a valid
4898        // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
4899        // entry should reach the ordering gate). Guards the call order
4900        // in `validate` against silent reordering.
4901        let e = entry(
4902            "0.1.0",
4903            vec![
4904                UpgradeInstruction::StateChange {
4905                    script: PathBuf::from("lib/m.lisp"),
4906                },
4907                UpgradeInstruction::Restart,
4908            ],
4909        );
4910        let err = e.validate().unwrap_err();
4911        assert!(
4912            matches!(err, UpgradeError::RestartNotExclusive { .. }),
4913            "restart-mixed must surface before the ordering gate, got {err:?}"
4914        );
4915    }
4916
4917    // ── within-entry purge-ordering invariant ──────────────────────────
4918
4919    #[test]
4920    fn validate_rejects_soft_purge_without_load() {
4921        // Fail-before-pass-after pin: `:soft-purge` drains the *old*
4922        // module after the new one is resident (OTP's two-phase code
4923        // load — code:load_module/1 then code:soft_purge/1), so an
4924        // entry that runs it with no preceding `:load-module` drains
4925        // the live module with no replacement. The operator runs
4926        // instructions in declared order, so this is a build error,
4927        // not a runtime surprise (CAIXA-SDLC §III).
4928        let e = entry(
4929            "0.1.0",
4930            vec![UpgradeInstruction::SoftPurge {
4931                module: "x-old".into(),
4932            }],
4933        );
4934        let err = e.validate().unwrap_err();
4935        assert_eq!(
4936            err,
4937            UpgradeError::PurgeWithoutPriorLoad {
4938                from: "0.1.0".into(),
4939                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4940                module: "x-old".into(),
4941            },
4942            "a `:soft-purge` with no preceding `:load-module` must surface as \
4943             PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
4944        );
4945    }
4946
4947    #[test]
4948    fn validate_rejects_purge_without_load() {
4949        // Per-arm coverage: `:purge` (immediate discard, no drain) is
4950        // the more catastrophic peer of `:soft-purge`; same gate, same
4951        // shape, kind-tag differs so the author can grep their
4952        // caixa.lisp for the offending `(:purge …)` form.
4953        let e = entry(
4954            "0.1.0",
4955            vec![UpgradeInstruction::Purge {
4956                module: "x-old".into(),
4957            }],
4958        );
4959        let err = e.validate().unwrap_err();
4960        assert_eq!(
4961            err,
4962            UpgradeError::PurgeWithoutPriorLoad {
4963                from: "0.1.0".into(),
4964                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4965                module: "x-old".into(),
4966            },
4967        );
4968    }
4969
4970    #[test]
4971    fn validate_rejects_soft_purge_before_load() {
4972        // Right-instructions-wrong-order: the load is present but runs
4973        // *after* the purge. Because the operator executes in declared
4974        // order, the cleanup drains the old code before the new code
4975        // is resident — same incoherence as the missing-load case,
4976        // leaving a window during which neither version is available.
4977        let e = entry(
4978            "0.1.0",
4979            vec![
4980                UpgradeInstruction::SoftPurge {
4981                    module: "x-old".into(),
4982                },
4983                UpgradeInstruction::LoadModule { module: "x".into() },
4984            ],
4985        );
4986        let err = e.validate().unwrap_err();
4987        assert!(
4988            matches!(
4989                err,
4990                UpgradeError::PurgeWithoutPriorLoad {
4991                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4992                    ..
4993                }
4994            ),
4995            "a `:soft-purge` ahead of its `:load-module` must surface as \
4996             PurgeWithoutPriorLoad, got {err:?}"
4997        );
4998    }
4999
5000    #[test]
5001    fn validate_rejects_purge_before_load() {
5002        // Symmetric arm on the `:purge` variant — the kind tag
5003        // distinguishes the diagnostic so the author lands on the
5004        // offending form directly.
5005        let e = entry(
5006            "0.1.0",
5007            vec![
5008                UpgradeInstruction::Purge {
5009                    module: "x-old".into(),
5010                },
5011                UpgradeInstruction::LoadModule { module: "x".into() },
5012            ],
5013        );
5014        let err = e.validate().unwrap_err();
5015        assert!(
5016            matches!(
5017                err,
5018                UpgradeError::PurgeWithoutPriorLoad {
5019                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5020                    ..
5021                }
5022            ),
5023            "a `:purge` ahead of its `:load-module` must surface as \
5024             PurgeWithoutPriorLoad, got {err:?}"
5025        );
5026    }
5027
5028    #[test]
5029    fn validate_accepts_soft_purge_after_load() {
5030        // Positive control: the canonical `(:load-module …)
5031        // (:soft-purge …)` order validates. The load need not name the
5032        // same module the purge targets — the cleanup typically targets
5033        // the *old* module name (e.g. `"x-old"`) and the load brings up
5034        // the *new* one (`"x"`); the gate only requires that *some*
5035        // `:load-module` precedes the purge, so the new code is resident
5036        // before the old one is drained.
5037        let e = entry(
5038            "0.1.0",
5039            vec![
5040                UpgradeInstruction::LoadModule { module: "x".into() },
5041                UpgradeInstruction::SoftPurge {
5042                    module: "x-old".into(),
5043                },
5044            ],
5045        );
5046        e.validate().unwrap();
5047    }
5048
5049    #[test]
5050    fn validate_accepts_multiple_purges_after_one_load() {
5051        // A single leading `:load-module` covers every subsequent
5052        // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5053        // the new code is resident. Same shape as
5054        // `validate_accepts_multiple_state_changes_after_one_load` on
5055        // the peer ordering gate.
5056        let e = entry(
5057            "0.1.0",
5058            vec![
5059                UpgradeInstruction::LoadModule { module: "x".into() },
5060                UpgradeInstruction::SoftPurge {
5061                    module: "x-old".into(),
5062                },
5063                UpgradeInstruction::Purge {
5064                    module: "x-oldest".into(),
5065                },
5066            ],
5067        );
5068        e.validate().unwrap();
5069    }
5070
5071    #[test]
5072    fn validate_purge_ordering_fires_after_state_change_ordering() {
5073        // Diagnostic-precedence pin: an entry like `((:state-change …)
5074        // (:soft-purge …))` is *both* state-change-without-load and
5075        // purge-without-load. The state-change gate must win — it's
5076        // the load-bearing semantic on this ordering contract, and
5077        // surfacing the purge diagnostic first would mask the more-
5078        // fundamental migration-against-stale-code defect. Guards the
5079        // call order in `validate` against silent reordering.
5080        let e = entry(
5081            "0.1.0",
5082            vec![
5083                UpgradeInstruction::StateChange {
5084                    script: PathBuf::from("lib/m.lisp"),
5085                },
5086                UpgradeInstruction::SoftPurge {
5087                    module: "x-old".into(),
5088                },
5089            ],
5090        );
5091        let err = e.validate().unwrap_err();
5092        assert!(
5093            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5094            "state-change-without-load must surface before purge-without-load, got {err:?}"
5095        );
5096    }
5097
5098    #[test]
5099    fn validate_purge_ordering_fires_after_per_instr_shape() {
5100        // Order pin: a malformed `:module` value on a `:soft-purge` (an
5101        // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5102        // diagnostic *before* the within-entry purge-ordering gate fires.
5103        // The per-instruction shape pass walks the list inline before
5104        // the ordering checks, so the narrower self-locating diagnostic
5105        // surfaces first — mirrors the empty-first cascade on every peer
5106        // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5107        // per_instr_shape` pin on the sibling ordering gate.
5108        let e = entry(
5109            "0.1.0",
5110            vec![UpgradeInstruction::SoftPurge {
5111                module: String::new(),
5112            }],
5113        );
5114        let err = e.validate().unwrap_err();
5115        assert_eq!(
5116            err,
5117            UpgradeError::ModuleEmpty {
5118                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5119            },
5120            "malformed instruction must surface its kind-tagged diagnostic before the \
5121             purge-ordering gate fires, got {err:?}"
5122        );
5123    }
5124
5125    #[test]
5126    fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5127        // The whole-list entry-point surfaces the per-entry ordering
5128        // error (mirrors
5129        // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5130        // the gate is reachable from the LayoutInvariants call site, not
5131        // only from a direct `entry.validate()`.
5132        let entries = vec![entry(
5133            "0.1.0",
5134            vec![UpgradeInstruction::Purge {
5135                module: "x-old".into(),
5136            }],
5137        )];
5138        let err = validate_upgrade_from(&entries).unwrap_err();
5139        assert!(
5140            matches!(
5141                err,
5142                UpgradeError::PurgeWithoutPriorLoad {
5143                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5144                    ..
5145                }
5146            ),
5147            "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5148        );
5149    }
5150
5151    #[test]
5152    fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5153        // The whole-list entry-point surfaces the per-entry ordering
5154        // error (mirrors `validate_restart_exclusive_threads_through_…`):
5155        // the gate is reachable from the LayoutInvariants call site, not
5156        // only from a direct `entry.validate()`.
5157        let entries = vec![entry(
5158            "0.1.0",
5159            vec![UpgradeInstruction::StateChange {
5160                script: PathBuf::from("lib/m.lisp"),
5161            }],
5162        )];
5163        let err = validate_upgrade_from(&entries).unwrap_err();
5164        assert!(
5165            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5166            "validate_upgrade_from must thread the ordering error, got {err:?}"
5167        );
5168    }
5169
5170    // ── within-entry cleanup-singularity invariant ─────────────────────
5171
5172    #[test]
5173    fn validate_rejects_duplicate_soft_purge_for_same_module() {
5174        // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5175        // its target module (code:soft_purge/1 analog); after the
5176        // first the module is gone, so a second `:soft-purge` of the
5177        // same module is at best a no-op and at worst undefined
5178        // (depending on the operator's handling of a non-resident-
5179        // module purge). Author one cleanup per module.
5180        let e = entry(
5181            "0.1.0",
5182            vec![
5183                UpgradeInstruction::LoadModule { module: "x".into() },
5184                UpgradeInstruction::SoftPurge {
5185                    module: "x-old".into(),
5186                },
5187                UpgradeInstruction::SoftPurge {
5188                    module: "x-old".into(),
5189                },
5190            ],
5191        );
5192        let err = e.validate().unwrap_err();
5193        assert_eq!(
5194            err,
5195            UpgradeError::DuplicateCleanup {
5196                from: "0.1.0".into(),
5197                module: "x-old".into(),
5198                kinds: vec![
5199                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5200                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5201                ],
5202            },
5203            "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5204             module + both kinds in declaration order, got {err:?}"
5205        );
5206    }
5207
5208    #[test]
5209    fn validate_rejects_duplicate_purge_for_same_module() {
5210        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5211        // the more catastrophic peer of `:soft-purge`; same gate, same
5212        // shape, kind-tag distinguishes so the author can grep their
5213        // caixa.lisp for the offending `(:purge …)` form.
5214        let e = entry(
5215            "0.1.0",
5216            vec![
5217                UpgradeInstruction::LoadModule { module: "x".into() },
5218                UpgradeInstruction::Purge {
5219                    module: "x-old".into(),
5220                },
5221                UpgradeInstruction::Purge {
5222                    module: "x-old".into(),
5223                },
5224            ],
5225        );
5226        let err = e.validate().unwrap_err();
5227        assert_eq!(
5228            err,
5229            UpgradeError::DuplicateCleanup {
5230                from: "0.1.0".into(),
5231                module: "x-old".into(),
5232                kinds: vec![
5233                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5234                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5235                ],
5236            },
5237        );
5238    }
5239
5240    #[test]
5241    fn validate_rejects_soft_purge_then_purge_for_same_module() {
5242        // Soft-then-hard footgun: the author wrote "drain, and if
5243        // drain doesn't clean up, force-discard", but the operator
5244        // runs declared instructions unconditionally — the `:purge`
5245        // fires whether the `:soft-purge` already discarded the
5246        // module or not, so the imagined fallback semantic is
5247        // missing. Fallback on cleanup failure is the operator's
5248        // job, not authored into the entry. Both kinds carry in
5249        // declaration order so the author can grep for either side
5250        // and pick one.
5251        let e = entry(
5252            "0.1.0",
5253            vec![
5254                UpgradeInstruction::LoadModule { module: "x".into() },
5255                UpgradeInstruction::SoftPurge {
5256                    module: "x-old".into(),
5257                },
5258                UpgradeInstruction::Purge {
5259                    module: "x-old".into(),
5260                },
5261            ],
5262        );
5263        let err = e.validate().unwrap_err();
5264        assert_eq!(
5265            err,
5266            UpgradeError::DuplicateCleanup {
5267                from: "0.1.0".into(),
5268                module: "x-old".into(),
5269                kinds: vec![
5270                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5271                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5272                ],
5273            },
5274        );
5275    }
5276
5277    #[test]
5278    fn validate_rejects_purge_then_soft_purge_for_same_module() {
5279        // Reversed-ordering arm: `:purge` discards immediately; the
5280        // trailing `:soft-purge` has no module to drain. The kinds
5281        // list reflects declaration order so the diagnostic locates
5282        // both forms in the source.
5283        let e = entry(
5284            "0.1.0",
5285            vec![
5286                UpgradeInstruction::LoadModule { module: "x".into() },
5287                UpgradeInstruction::Purge {
5288                    module: "x-old".into(),
5289                },
5290                UpgradeInstruction::SoftPurge {
5291                    module: "x-old".into(),
5292                },
5293            ],
5294        );
5295        let err = e.validate().unwrap_err();
5296        assert_eq!(
5297            err,
5298            UpgradeError::DuplicateCleanup {
5299                from: "0.1.0".into(),
5300                module: "x-old".into(),
5301                kinds: vec![
5302                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5303                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5304                ],
5305            },
5306        );
5307    }
5308
5309    #[test]
5310    fn validate_accepts_distinct_cleanup_modules() {
5311        // Positive control: `:soft-purge` and `:purge` on *different*
5312        // modules pass the gate. Mirrors
5313        // `validate_accepts_multiple_purges_after_one_load` — the
5314        // cleanup-singularity gate is keyed on (module), not on
5315        // (kind, module) pair, so distinct old-version names render
5316        // distinct cleanup targets and don't collide. Sweep both
5317        // same-class (two `:soft-purge` distinct modules) and cross-
5318        // class (`:soft-purge` then `:purge` distinct modules) so a
5319        // future tighten to a kind-only key (which would over-fire on
5320        // distinct modules) surfaces here.
5321        let two_soft = entry(
5322            "0.1.0",
5323            vec![
5324                UpgradeInstruction::LoadModule { module: "x".into() },
5325                UpgradeInstruction::SoftPurge {
5326                    module: "x-old".into(),
5327                },
5328                UpgradeInstruction::SoftPurge {
5329                    module: "x-older".into(),
5330                },
5331            ],
5332        );
5333        two_soft.validate().unwrap();
5334        let mixed = entry(
5335            "0.1.0",
5336            vec![
5337                UpgradeInstruction::LoadModule { module: "x".into() },
5338                UpgradeInstruction::SoftPurge {
5339                    module: "x-old".into(),
5340                },
5341                UpgradeInstruction::Purge {
5342                    module: "x-oldest".into(),
5343                },
5344            ],
5345        );
5346        mixed.validate().unwrap();
5347    }
5348
5349    #[test]
5350    fn validate_accepts_single_cleanup_per_module() {
5351        // Boundary control: a list with exactly one `:soft-purge` and
5352        // one `:purge` (distinct modules, the canonical "drain one,
5353        // hard-discard the other" shape) is the gate's identity
5354        // element. Pin so a future off-by-one in the duplicate-detection
5355        // scan doesn't accidentally flag a single occurrence as
5356        // duplicating itself — mirrors
5357        // `validate_upgrade_from_single_entry_never_duplicates` on
5358        // the peer cross-entry duplicate axis.
5359        let e = entry(
5360            "0.1.0",
5361            vec![
5362                UpgradeInstruction::LoadModule { module: "x".into() },
5363                UpgradeInstruction::SoftPurge {
5364                    module: "x-old".into(),
5365                },
5366                UpgradeInstruction::Purge {
5367                    module: "y-old".into(),
5368                },
5369            ],
5370        );
5371        e.validate().unwrap();
5372    }
5373
5374    #[test]
5375    fn validate_cleanup_singularity_fires_after_purge_ordering() {
5376        // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5377        // (:soft-purge "x"))` is *both* purge-without-load and
5378        // duplicate-cleanup. The more-fundamental ordering gate must
5379        // win — the missing-load defect is load-bearing (the canonical
5380        // OTP shape requires the new code be resident before any
5381        // cleanup runs), and surfacing the duplicate diagnostic first
5382        // would mask the no-replacement-window defect the ordering
5383        // gate exists to close. Guards the call order in `validate`
5384        // against silent reordering. Same posture as
5385        // `validate_purge_ordering_fires_after_state_change_ordering`
5386        // on the sibling ordering gate.
5387        let e = entry(
5388            "0.1.0",
5389            vec![
5390                UpgradeInstruction::SoftPurge {
5391                    module: "x-old".into(),
5392                },
5393                UpgradeInstruction::SoftPurge {
5394                    module: "x-old".into(),
5395                },
5396            ],
5397        );
5398        let err = e.validate().unwrap_err();
5399        assert!(
5400            matches!(
5401                err,
5402                UpgradeError::PurgeWithoutPriorLoad {
5403                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5404                    ..
5405                }
5406            ),
5407            "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5408        );
5409    }
5410
5411    #[test]
5412    fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5413        // Order pin: a malformed `:module` value on a `:soft-purge`
5414        // (an empty string) surfaces its narrower kind-tagged
5415        // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5416        // singularity gate fires. The per-instruction shape pass walks
5417        // the list inline before the singularity check, so the
5418        // narrower self-locating diagnostic surfaces first — mirrors
5419        // the empty-first cascade on every peer DNS-1123 gate and the
5420        // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5421        // the sibling ordering gate.
5422        //
5423        // Two empty-string `:soft-purge` would *otherwise* duplicate
5424        // (both modules are the same empty string), so this pin
5425        // double-locks the precedence: the per-instr shape gate must
5426        // win on the first malformed instruction before the duplicate
5427        // scan even reaches the second.
5428        let e = entry(
5429            "0.1.0",
5430            vec![
5431                UpgradeInstruction::LoadModule { module: "x".into() },
5432                UpgradeInstruction::SoftPurge {
5433                    module: String::new(),
5434                },
5435                UpgradeInstruction::SoftPurge {
5436                    module: String::new(),
5437                },
5438            ],
5439        );
5440        let err = e.validate().unwrap_err();
5441        assert_eq!(
5442            err,
5443            UpgradeError::ModuleEmpty {
5444                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5445            },
5446            "malformed instruction must surface its kind-tagged diagnostic before the \
5447             cleanup-singularity gate fires, got {err:?}"
5448        );
5449    }
5450
5451    #[test]
5452    fn validate_cleanup_singularity_reports_first_collision() {
5453        // Determinism pin: with three cleanups of the same module the
5454        // gate reports the *first* collision (the second occurrence)
5455        // and stops — the third's duplicate is masked by the first
5456        // surfaced one. Mirrors
5457        // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5458        // on the peer cross-entry duplicate axis.
5459        let e = entry(
5460            "0.1.0",
5461            vec![
5462                UpgradeInstruction::LoadModule { module: "x".into() },
5463                UpgradeInstruction::SoftPurge {
5464                    module: "x-old".into(),
5465                },
5466                UpgradeInstruction::SoftPurge {
5467                    module: "x-old".into(),
5468                },
5469                UpgradeInstruction::Purge {
5470                    module: "x-old".into(),
5471                },
5472            ],
5473        );
5474        let err = e.validate().unwrap_err();
5475        assert_eq!(
5476            err,
5477            UpgradeError::DuplicateCleanup {
5478                from: "0.1.0".into(),
5479                module: "x-old".into(),
5480                kinds: vec![
5481                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5482                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5483                ],
5484            },
5485            "the first colliding pair must surface, not the later `:purge` collision"
5486        );
5487    }
5488
5489    #[test]
5490    fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
5491        // The whole-list entry-point surfaces the per-entry singularity
5492        // error (mirrors
5493        // `validate_purge_ordering_threads_through_validate_upgrade_from`):
5494        // the gate is reachable from the LayoutInvariants call site,
5495        // not only from a direct `entry.validate()`.
5496        let entries = vec![entry(
5497            "0.1.0",
5498            vec![
5499                UpgradeInstruction::LoadModule { module: "x".into() },
5500                UpgradeInstruction::SoftPurge {
5501                    module: "x-old".into(),
5502                },
5503                UpgradeInstruction::Purge {
5504                    module: "x-old".into(),
5505                },
5506            ],
5507        )];
5508        let err = validate_upgrade_from(&entries).unwrap_err();
5509        assert!(
5510            matches!(err, UpgradeError::DuplicateCleanup { .. }),
5511            "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
5512        );
5513    }
5514
5515    #[test]
5516    fn validate_rejects_duplicate_load_module_for_same_module() {
5517        // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
5518        // §II.4): each module is loaded exactly once per upgrade entry,
5519        // the operator's dispatch table reads the module name to bind
5520        // the wasm component, and a second `(:load-module "x")` re-reads
5521        // the same module name and re-binds the same component — a
5522        // no-op the second time. systools-generated `.relup` files emit
5523        // at most one `load_module` per module per upgrade step for
5524        // this reason. Author one `(:load-module "x")` per old module.
5525        let e = entry(
5526            "0.1.0",
5527            vec![
5528                UpgradeInstruction::LoadModule { module: "x".into() },
5529                UpgradeInstruction::LoadModule { module: "x".into() },
5530            ],
5531        );
5532        let err = e.validate().unwrap_err();
5533        assert_eq!(
5534            err,
5535            UpgradeError::DuplicateLoadModule {
5536                from: "0.1.0".into(),
5537                module: "x".into(),
5538            },
5539            "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
5540             the module, got {err:?}"
5541        );
5542    }
5543
5544    #[test]
5545    fn validate_accepts_distinct_load_modules() {
5546        // Positive control: `:load-module` instructions on *different*
5547        // modules pass the gate. Mirrors
5548        // `validate_accepts_distinct_cleanup_modules` on the sibling
5549        // singularity axis — the load-singularity gate is keyed on
5550        // (module), so distinct module names render distinct load
5551        // targets and don't collide. Sweep both the bare two-load shape
5552        // and the canonical load-pair-with-cleanup shape so a future
5553        // tighten that over-fires on distinct loads surfaces here.
5554        let two_loads = entry(
5555            "0.1.0",
5556            vec![
5557                UpgradeInstruction::LoadModule { module: "x".into() },
5558                UpgradeInstruction::LoadModule { module: "y".into() },
5559            ],
5560        );
5561        two_loads.validate().unwrap();
5562        let with_cleanup = entry(
5563            "0.1.0",
5564            vec![
5565                UpgradeInstruction::LoadModule { module: "x".into() },
5566                UpgradeInstruction::LoadModule { module: "y".into() },
5567                UpgradeInstruction::SoftPurge {
5568                    module: "x-old".into(),
5569                },
5570                UpgradeInstruction::SoftPurge {
5571                    module: "y-old".into(),
5572                },
5573            ],
5574        );
5575        with_cleanup.validate().unwrap();
5576    }
5577
5578    #[test]
5579    fn validate_accepts_single_load_per_module() {
5580        // Boundary control: a list with exactly one `:load-module`
5581        // followed by the canonical `:state-change` + `:soft-purge`
5582        // sequence (the module-doc OTP shape) is the gate's identity
5583        // element. Pin so a future off-by-one in the duplicate-
5584        // detection scan doesn't accidentally flag a single occurrence
5585        // as duplicating itself — mirrors
5586        // `validate_accepts_single_cleanup_per_module` on the sibling
5587        // singularity axis.
5588        let e = entry(
5589            "0.1.0",
5590            vec![
5591                UpgradeInstruction::LoadModule { module: "x".into() },
5592                UpgradeInstruction::StateChange {
5593                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5594                },
5595                UpgradeInstruction::SoftPurge {
5596                    module: "x-old".into(),
5597                },
5598            ],
5599        );
5600        e.validate().unwrap();
5601    }
5602
5603    #[test]
5604    fn validate_load_singularity_fires_after_state_change_ordering() {
5605        // Diagnostic-precedence pin: an entry like `((:state-change
5606        // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
5607        // state-change-without-load and duplicate-load. The more-
5608        // fundamental ordering gate must win — the missing-load defect
5609        // is load-bearing (the migration runs against unloaded code),
5610        // and surfacing the duplicate diagnostic first would mask the
5611        // migrate-into-unloaded-code defect the ordering gate exists
5612        // to close. Guards the call order in `validate` against silent
5613        // reordering. Same posture as
5614        // `validate_cleanup_singularity_fires_after_purge_ordering`
5615        // on the sibling singularity gate.
5616        let e = entry(
5617            "0.1.0",
5618            vec![
5619                UpgradeInstruction::StateChange {
5620                    script: PathBuf::from("lib/m.lisp"),
5621                },
5622                UpgradeInstruction::LoadModule { module: "x".into() },
5623                UpgradeInstruction::LoadModule { module: "x".into() },
5624            ],
5625        );
5626        let err = e.validate().unwrap_err();
5627        assert!(
5628            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5629            "state-change-without-load must surface before duplicate-load, got {err:?}"
5630        );
5631    }
5632
5633    #[test]
5634    fn validate_load_singularity_fires_after_purge_ordering() {
5635        // Diagnostic-precedence pin: an entry like `((:soft-purge
5636        // "x-old") (:load-module "x") (:load-module "x"))` is *both*
5637        // purge-without-load and duplicate-load. The more-fundamental
5638        // ordering gate must win — the missing-load defect is load-
5639        // bearing (the cleanup runs against no-replacement-window),
5640        // and surfacing the duplicate diagnostic first would mask the
5641        // drain-to-nothing defect the ordering gate exists to close.
5642        // Sibling of
5643        // `validate_cleanup_singularity_fires_after_purge_ordering` on
5644        // the load-singularity axis.
5645        let e = entry(
5646            "0.1.0",
5647            vec![
5648                UpgradeInstruction::SoftPurge {
5649                    module: "x-old".into(),
5650                },
5651                UpgradeInstruction::LoadModule { module: "x".into() },
5652                UpgradeInstruction::LoadModule { module: "x".into() },
5653            ],
5654        );
5655        let err = e.validate().unwrap_err();
5656        assert!(
5657            matches!(
5658                err,
5659                UpgradeError::PurgeWithoutPriorLoad {
5660                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5661                    ..
5662                }
5663            ),
5664            "purge-without-load must surface before duplicate-load, got {err:?}"
5665        );
5666    }
5667
5668    #[test]
5669    fn validate_load_singularity_fires_after_per_instr_shape() {
5670        // Order pin: a malformed `:module` value on a `:load-module`
5671        // (an empty string) surfaces its narrower kind-tagged
5672        // `ModuleEmpty` diagnostic *before* the within-entry load-
5673        // singularity gate fires. The per-instruction shape pass walks
5674        // the list inline before the singularity check, so the
5675        // narrower self-locating diagnostic surfaces first — mirrors
5676        // the empty-first cascade on every peer DNS-1123 gate and the
5677        // `validate_cleanup_singularity_fires_after_per_instr_shape`
5678        // pin on the sibling singularity gate.
5679        //
5680        // Two empty-string `:load-module` would *otherwise* duplicate
5681        // (both modules are the same empty string), so this pin
5682        // double-locks the precedence: the per-instr shape gate must
5683        // win on the first malformed instruction before the duplicate
5684        // scan even reaches the second.
5685        let e = entry(
5686            "0.1.0",
5687            vec![
5688                UpgradeInstruction::LoadModule {
5689                    module: String::new(),
5690                },
5691                UpgradeInstruction::LoadModule {
5692                    module: String::new(),
5693                },
5694            ],
5695        );
5696        let err = e.validate().unwrap_err();
5697        assert_eq!(
5698            err,
5699            UpgradeError::ModuleEmpty {
5700                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5701            },
5702            "malformed instruction must surface its kind-tagged diagnostic before the \
5703             load-singularity gate fires, got {err:?}"
5704        );
5705    }
5706
5707    #[test]
5708    fn validate_load_singularity_fires_before_cleanup_singularity() {
5709        // Diagnostic-precedence pin: an entry that violates *both*
5710        // singularities — duplicate load on "x" *and* duplicate cleanup
5711        // on "y-old" — must surface the load-side diagnostic first.
5712        // The load axis precedes the cleanup axis in the canonical OTP
5713        // sequence (`code:load_module/1` then `code:soft_purge/1`) and
5714        // in [`UpgradeInstruction`] declaration order (LoadModule
5715        // before SoftPurge/Purge), so the load-side singularity is the
5716        // load-bearing diagnostic when both fire — the cleanup-side
5717        // duplicate is meaningless either way without a coherent load.
5718        // Guards the call order in `validate`: `validate_load_singularity`
5719        // runs before `validate_cleanup_singularity`.
5720        let e = entry(
5721            "0.1.0",
5722            vec![
5723                UpgradeInstruction::LoadModule { module: "x".into() },
5724                UpgradeInstruction::LoadModule { module: "x".into() },
5725                UpgradeInstruction::SoftPurge {
5726                    module: "y-old".into(),
5727                },
5728                UpgradeInstruction::SoftPurge {
5729                    module: "y-old".into(),
5730                },
5731            ],
5732        );
5733        let err = e.validate().unwrap_err();
5734        assert_eq!(
5735            err,
5736            UpgradeError::DuplicateLoadModule {
5737                from: "0.1.0".into(),
5738                module: "x".into(),
5739            },
5740            "duplicate-load must surface before duplicate-cleanup, got {err:?}"
5741        );
5742    }
5743
5744    #[test]
5745    fn validate_load_singularity_reports_first_collision() {
5746        // Determinism pin: with three loads of the same module the gate
5747        // reports the *first* collision (the second occurrence) and
5748        // stops — the third's duplicate is masked by the first surfaced
5749        // one. Mirrors
5750        // `validate_cleanup_singularity_reports_first_collision` on the
5751        // sibling singularity axis and every peer duplicate gate's
5752        // first-collision discipline.
5753        let e = entry(
5754            "0.1.0",
5755            vec![
5756                UpgradeInstruction::LoadModule { module: "x".into() },
5757                UpgradeInstruction::LoadModule { module: "x".into() },
5758                UpgradeInstruction::LoadModule { module: "x".into() },
5759            ],
5760        );
5761        let err = e.validate().unwrap_err();
5762        assert_eq!(
5763            err,
5764            UpgradeError::DuplicateLoadModule {
5765                from: "0.1.0".into(),
5766                module: "x".into(),
5767            },
5768            "the first colliding occurrence must surface, not the later third-load collision"
5769        );
5770    }
5771
5772    #[test]
5773    fn validate_load_singularity_threads_through_validate_upgrade_from() {
5774        // The whole-list entry-point surfaces the per-entry singularity
5775        // error (mirrors
5776        // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
5777        // the gate is reachable from the LayoutInvariants call site,
5778        // not only from a direct `entry.validate()`.
5779        let entries = vec![entry(
5780            "0.1.0",
5781            vec![
5782                UpgradeInstruction::LoadModule { module: "x".into() },
5783                UpgradeInstruction::LoadModule { module: "x".into() },
5784            ],
5785        )];
5786        let err = validate_upgrade_from(&entries).unwrap_err();
5787        assert!(
5788            matches!(err, UpgradeError::DuplicateLoadModule { .. }),
5789            "validate_upgrade_from must thread the load-singularity error, got {err:?}"
5790        );
5791    }
5792
5793    // ── within-entry state-change-singularity invariant ────────────────
5794
5795    #[test]
5796    fn validate_rejects_duplicate_state_change_for_same_script() {
5797        // `StateChange` is the `gen_server:code_change/3` analog
5798        // (INSPIRATIONS §II.4): the script folds the prior-version
5799        // state shape into the current-version shape — a one-shot
5800        // transition, not a step that composes with itself. OTP's
5801        // release_handler invokes `code_change/3` exactly once per
5802        // upgrade per gen_server; systools-generated `.relup` files
5803        // emit at most one `code_change` per gen_server per upgrade
5804        // step for this reason. A second `(:state-change "m.lisp")`
5805        // re-runs the same fold on the already-migrated state — at
5806        // best a no-op and at worst silent state corruption from
5807        // double-applied non-idempotent transforms (`add column`,
5808        // `increment counter`, `rename field`). Author one
5809        // `(:state-change "m.lisp")` per migration script per entry.
5810        let e = entry(
5811            "0.1.0",
5812            vec![
5813                UpgradeInstruction::LoadModule { module: "x".into() },
5814                UpgradeInstruction::StateChange {
5815                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5816                },
5817                UpgradeInstruction::StateChange {
5818                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5819                },
5820            ],
5821        );
5822        let err = e.validate().unwrap_err();
5823        assert_eq!(
5824            err,
5825            UpgradeError::DuplicateStateChange {
5826                from: "0.1.0".into(),
5827                script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5828            },
5829            "two `:state-change` of the same script must surface as DuplicateStateChange naming \
5830             the script, got {err:?}"
5831        );
5832    }
5833
5834    #[test]
5835    fn validate_accepts_distinct_state_change_scripts() {
5836        // Positive control: `:state-change` instructions on *different*
5837        // scripts pass the gate. Mirrors
5838        // `validate_accepts_distinct_cleanup_modules` /
5839        // `validate_accepts_distinct_load_modules` on the sibling
5840        // singularity axes — the state-change-singularity gate is keyed
5841        // on the script PathBuf, so distinct scripts render distinct
5842        // migration targets and don't collide. Sweep both the bare two-
5843        // migration shape and the canonical load-pair-with-cleanup shape
5844        // so a future tighten that over-fires on distinct scripts
5845        // surfaces here. This positive control is the gate-level peer of
5846        // `validate_accepts_multiple_state_changes_after_one_load` (the
5847        // ordering-gate positive control on distinct scripts), pinned
5848        // here independently so a future refactor that decouples the
5849        // gates can't accidentally drop coverage on either.
5850        let two_migrations = entry(
5851            "0.1.0",
5852            vec![
5853                UpgradeInstruction::LoadModule { module: "x".into() },
5854                UpgradeInstruction::StateChange {
5855                    script: PathBuf::from("lib/m1.lisp"),
5856                },
5857                UpgradeInstruction::StateChange {
5858                    script: PathBuf::from("lib/m2.lisp"),
5859                },
5860            ],
5861        );
5862        two_migrations.validate().unwrap();
5863        let with_cleanup = entry(
5864            "0.1.0",
5865            vec![
5866                UpgradeInstruction::LoadModule { module: "x".into() },
5867                UpgradeInstruction::StateChange {
5868                    script: PathBuf::from("lib/m1.lisp"),
5869                },
5870                UpgradeInstruction::StateChange {
5871                    script: PathBuf::from("lib/m2.lisp"),
5872                },
5873                UpgradeInstruction::SoftPurge {
5874                    module: "x-old".into(),
5875                },
5876            ],
5877        );
5878        with_cleanup.validate().unwrap();
5879    }
5880
5881    #[test]
5882    fn validate_accepts_single_state_change_per_script() {
5883        // Boundary control: a list with exactly one `:state-change`
5884        // wrapped by the canonical `:load-module` + `:soft-purge`
5885        // sequence (the module-doc OTP shape) is the gate's identity
5886        // element. Pin so a future off-by-one in the duplicate-
5887        // detection scan doesn't accidentally flag a single occurrence
5888        // as duplicating itself — mirrors
5889        // `validate_accepts_single_load_per_module` /
5890        // `validate_accepts_single_cleanup_per_module` on the sibling
5891        // singularity axes.
5892        let e = entry(
5893            "0.1.0",
5894            vec![
5895                UpgradeInstruction::LoadModule { module: "x".into() },
5896                UpgradeInstruction::StateChange {
5897                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5898                },
5899                UpgradeInstruction::SoftPurge {
5900                    module: "x-old".into(),
5901                },
5902            ],
5903        );
5904        e.validate().unwrap();
5905    }
5906
5907    #[test]
5908    fn validate_state_change_singularity_fires_after_state_change_ordering() {
5909        // Diagnostic-precedence pin: an entry like `((:state-change
5910        // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
5911        // without-load and duplicate-state-change. The more-fundamental
5912        // ordering gate must win — the missing-load defect is load-
5913        // bearing (the migration runs against unloaded code), and
5914        // surfacing the duplicate diagnostic first would mask the
5915        // migrate-into-unloaded-code defect the ordering gate exists to
5916        // close. Guards the call order in `validate` against silent
5917        // reordering. Same posture as
5918        // `validate_load_singularity_fires_after_state_change_ordering`
5919        // on the sibling singularity gate.
5920        //
5921        // Two same-script `:state-change` would *otherwise* duplicate
5922        // (both scripts collide on the very first `:state-change`-
5923        // without-load encountered), so this pin double-locks the
5924        // precedence: the ordering gate must win on the first un-loaded
5925        // `:state-change` before the singularity scan even reaches the
5926        // second.
5927        let e = entry(
5928            "0.1.0",
5929            vec![
5930                UpgradeInstruction::StateChange {
5931                    script: PathBuf::from("lib/m.lisp"),
5932                },
5933                UpgradeInstruction::StateChange {
5934                    script: PathBuf::from("lib/m.lisp"),
5935                },
5936            ],
5937        );
5938        let err = e.validate().unwrap_err();
5939        assert!(
5940            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5941            "state-change-without-load must surface before duplicate-state-change, got {err:?}"
5942        );
5943    }
5944
5945    #[test]
5946    fn validate_state_change_singularity_fires_after_purge_ordering() {
5947        // Diagnostic-precedence pin: an entry like `((:soft-purge
5948        // "x-old") (:load-module "x") (:state-change "m.lisp")
5949        // (:state-change "m.lisp"))` is *both* purge-without-load and
5950        // duplicate-state-change. The more-fundamental ordering gate
5951        // must win — the missing-load defect (a cleanup that drains the
5952        // only resident version to nothing) is load-bearing, and
5953        // surfacing the duplicate diagnostic first would mask the
5954        // drain-to-nothing defect the ordering gate exists to close.
5955        // Sibling of `validate_load_singularity_fires_after_purge_ordering`
5956        // on the state-change-singularity axis.
5957        let e = entry(
5958            "0.1.0",
5959            vec![
5960                UpgradeInstruction::SoftPurge {
5961                    module: "x-old".into(),
5962                },
5963                UpgradeInstruction::LoadModule { module: "x".into() },
5964                UpgradeInstruction::StateChange {
5965                    script: PathBuf::from("lib/m.lisp"),
5966                },
5967                UpgradeInstruction::StateChange {
5968                    script: PathBuf::from("lib/m.lisp"),
5969                },
5970            ],
5971        );
5972        let err = e.validate().unwrap_err();
5973        assert!(
5974            matches!(
5975                err,
5976                UpgradeError::PurgeWithoutPriorLoad {
5977                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5978                    ..
5979                }
5980            ),
5981            "purge-without-load must surface before duplicate-state-change, got {err:?}"
5982        );
5983    }
5984
5985    #[test]
5986    fn validate_state_change_singularity_fires_after_per_instr_shape() {
5987        // Order pin: a malformed `:script` value on a `:state-change`
5988        // (an empty path) surfaces its narrower `EmptyScript` diagnostic
5989        // *before* the within-entry state-change-singularity gate fires.
5990        // The per-instruction shape pass walks the list inline before
5991        // the singularity check, so the narrower self-locating
5992        // diagnostic surfaces first — mirrors the empty-first cascade on
5993        // every peer path-shape gate and the
5994        // `validate_load_singularity_fires_after_per_instr_shape` /
5995        // `validate_cleanup_singularity_fires_after_per_instr_shape`
5996        // pins on the sibling singularity gates.
5997        //
5998        // Two empty-path `:state-change` would *otherwise* duplicate
5999        // (both scripts are the same empty PathBuf), so this pin double-
6000        // locks the precedence: the per-instr shape gate must win on the
6001        // first malformed instruction before the duplicate scan even
6002        // reaches the second.
6003        let e = entry(
6004            "0.1.0",
6005            vec![
6006                UpgradeInstruction::LoadModule { module: "x".into() },
6007                UpgradeInstruction::StateChange {
6008                    script: PathBuf::new(),
6009                },
6010                UpgradeInstruction::StateChange {
6011                    script: PathBuf::new(),
6012                },
6013            ],
6014        );
6015        let err = e.validate().unwrap_err();
6016        assert_eq!(
6017            err,
6018            UpgradeError::EmptyScript,
6019            "malformed instruction must surface its narrower diagnostic before the \
6020             state-change-singularity gate fires, got {err:?}"
6021        );
6022    }
6023
6024    #[test]
6025    fn validate_state_change_singularity_fires_after_load_singularity() {
6026        // Diagnostic-precedence pin: an entry that violates *both*
6027        // singularities — duplicate load on "x" *and* duplicate
6028        // state-change on "m.lisp" — must surface the load-side
6029        // diagnostic first. The load axis precedes the migration axis
6030        // in the canonical OTP sequence (`code:load_module/1` then
6031        // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6032        // declaration order (LoadModule before StateChange), so the
6033        // load-side singularity is the load-bearing diagnostic when
6034        // both fire — the migration-side duplicate is meaningless
6035        // either way without a coherent load. Guards the call order in
6036        // `validate`: `validate_load_singularity` runs before
6037        // `validate_state_change_singularity`.
6038        let e = entry(
6039            "0.1.0",
6040            vec![
6041                UpgradeInstruction::LoadModule { module: "x".into() },
6042                UpgradeInstruction::LoadModule { module: "x".into() },
6043                UpgradeInstruction::StateChange {
6044                    script: PathBuf::from("lib/m.lisp"),
6045                },
6046                UpgradeInstruction::StateChange {
6047                    script: PathBuf::from("lib/m.lisp"),
6048                },
6049            ],
6050        );
6051        let err = e.validate().unwrap_err();
6052        assert_eq!(
6053            err,
6054            UpgradeError::DuplicateLoadModule {
6055                from: "0.1.0".into(),
6056                module: "x".into(),
6057            },
6058            "duplicate-load must surface before duplicate-state-change, got {err:?}"
6059        );
6060    }
6061
6062    #[test]
6063    fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6064        // Diagnostic-precedence pin: an entry that violates *both*
6065        // singularities — duplicate state-change on "m.lisp" *and*
6066        // duplicate cleanup on "y-old" — must surface the migration-
6067        // side diagnostic first. The migration axis precedes the
6068        // cleanup axis in the canonical OTP sequence
6069        // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6070        // [`UpgradeInstruction`] declaration order (StateChange before
6071        // SoftPurge/Purge), so the migration-side singularity is the
6072        // load-bearing diagnostic when both fire — the cleanup-side
6073        // duplicate is irrelevant once the migration has corrupted
6074        // state by double-applying. Guards the call order in
6075        // `validate`: `validate_state_change_singularity` runs before
6076        // `validate_cleanup_singularity`.
6077        let e = entry(
6078            "0.1.0",
6079            vec![
6080                UpgradeInstruction::LoadModule { module: "x".into() },
6081                UpgradeInstruction::StateChange {
6082                    script: PathBuf::from("lib/m.lisp"),
6083                },
6084                UpgradeInstruction::StateChange {
6085                    script: PathBuf::from("lib/m.lisp"),
6086                },
6087                UpgradeInstruction::SoftPurge {
6088                    module: "y-old".into(),
6089                },
6090                UpgradeInstruction::SoftPurge {
6091                    module: "y-old".into(),
6092                },
6093            ],
6094        );
6095        let err = e.validate().unwrap_err();
6096        assert_eq!(
6097            err,
6098            UpgradeError::DuplicateStateChange {
6099                from: "0.1.0".into(),
6100                script: PathBuf::from("lib/m.lisp"),
6101            },
6102            "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6103        );
6104    }
6105
6106    #[test]
6107    fn validate_state_change_singularity_reports_first_collision() {
6108        // Determinism pin: with three state-changes on the same script
6109        // the gate reports the *first* collision (the second
6110        // occurrence) and stops — the third's duplicate is masked by
6111        // the first surfaced one. Mirrors
6112        // `validate_load_singularity_reports_first_collision` /
6113        // `validate_cleanup_singularity_reports_first_collision` on the
6114        // sibling singularity axes and every peer duplicate gate's
6115        // first-collision discipline.
6116        let e = entry(
6117            "0.1.0",
6118            vec![
6119                UpgradeInstruction::LoadModule { module: "x".into() },
6120                UpgradeInstruction::StateChange {
6121                    script: PathBuf::from("lib/m.lisp"),
6122                },
6123                UpgradeInstruction::StateChange {
6124                    script: PathBuf::from("lib/m.lisp"),
6125                },
6126                UpgradeInstruction::StateChange {
6127                    script: PathBuf::from("lib/m.lisp"),
6128                },
6129            ],
6130        );
6131        let err = e.validate().unwrap_err();
6132        assert_eq!(
6133            err,
6134            UpgradeError::DuplicateStateChange {
6135                from: "0.1.0".into(),
6136                script: PathBuf::from("lib/m.lisp"),
6137            },
6138            "the first colliding occurrence must surface, not the later third-migration collision"
6139        );
6140    }
6141
6142    #[test]
6143    fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6144        // The whole-list entry-point surfaces the per-entry singularity
6145        // error (mirrors
6146        // `validate_load_singularity_threads_through_validate_upgrade_from`
6147        // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6148        // the gate is reachable from the LayoutInvariants call site,
6149        // not only from a direct `entry.validate()`.
6150        let entries = vec![entry(
6151            "0.1.0",
6152            vec![
6153                UpgradeInstruction::LoadModule { module: "x".into() },
6154                UpgradeInstruction::StateChange {
6155                    script: PathBuf::from("lib/m.lisp"),
6156                },
6157                UpgradeInstruction::StateChange {
6158                    script: PathBuf::from("lib/m.lisp"),
6159                },
6160            ],
6161        )];
6162        let err = validate_upgrade_from(&entries).unwrap_err();
6163        assert!(
6164            matches!(err, UpgradeError::DuplicateStateChange { .. }),
6165            "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6166        );
6167    }
6168
6169    #[test]
6170    fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6171        // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6172        // per-instruction `StateChange`-arm script-path projection must
6173        // route through the sibling lifted
6174        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6175        // accessor, not the raw
6176        // `match instr { UpgradeInstruction::StateChange { script } =>
6177        // script.as_path(), _ => continue }` open-coded pattern-match
6178        // the gate previously carried.
6179        //
6180        // Structurally: the gate's projection accept-set is the union
6181        // of every [`UpgradeInstruction`] variant for which
6182        // `declared_path().is_some()` — today exactly
6183        // [`UpgradeInstruction::StateChange`] per the sibling
6184        // `declared_path_only_for_state_change` pin, so a
6185        // duplicate-scripts input trips `DuplicateStateChange` and a
6186        // non-`StateChange` input (module-bearing / terminal) leaves
6187        // `seen` empty and the gate returns `Ok(())` byte-identical to
6188        // the pattern-match shape.
6189        //
6190        // Byte-equal today (`declared_path` returns `Some(script)` iff
6191        // `StateChange`, byte-for-byte from the variant's own storage);
6192        // the pin catches any future accessor extension that promotes
6193        // an additional variant onto the `PathBuf`-carrying axis — the
6194        // gate then fires on duplicate scripts from that variant too,
6195        // and the singularity discipline the sibling
6196        // `validate_load_singularity` / `validate_cleanup_singularity`
6197        // gates share on the `String`-carrying axis's per-variant
6198        // consumers extends to the promoted variant by construction.
6199        //
6200        // Peer of the sibling four per-`UpgradeInstruction` consumers
6201        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6202        // sandbox-path fan-out, the layout-side per-`StateChange`
6203        // script-existence fan-out at
6204        // `caixa-core/src/layout.rs:1017`, the cross-slot
6205        // [`validate_upgrade_from_against_behavior`] gate's per-
6206        // `StateChange` detection loop, the peer
6207        // [`UpgradeInstruction::declared_module`] `String`-axis
6208        // per-variant unifier) — this gate now shares one typed
6209        // dispatch on the substrate primitive's `PathBuf`-carrying
6210        // axis with those consumers, so a future rebrand on the axis
6211        // migrates as a single caixa-core edit rather than a
6212        // coordinated rewrite of five call sites.
6213        //
6214        // Three-arm projective coverage:
6215        //   (a) `StateChange` scripts project through `declared_path()`
6216        //       byte-equal to the raw `script.as_path()` field access;
6217        //   (b) a duplicate-`StateChange` input trips the gate on the
6218        //       second occurrence with `DuplicateStateChange` carrying
6219        //       the offending script verbatim;
6220        //   (c) a non-`StateChange`-only input (`LoadModule` /
6221        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
6222        //       vacuous with `Ok(())` — the `declared_path().is_none()`
6223        //       arm's `continue` fall-through pins.
6224        //
6225        // Fail-before-pass-after verified locally: swapping the
6226        // production `let Some(script) = instr.declared_path() else {
6227        // continue };` back to `let script = match instr {
6228        // UpgradeInstruction::StateChange { script } =>
6229        // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6230        // passing but silently detaches the gate from the accessor's
6231        // typed dispatch — any future `declared_path` extension
6232        // (promotion of an additional variant onto the axis, an
6233        // operator-side pre-resolved-path cache the accessor
6234        // materializes) would then silently disagree between this
6235        // gate's raw pattern-match and the peer four sibling consumers
6236        // that route through the accessor.
6237        use std::path::PathBuf;
6238
6239        // (a) StateChange projection byte-equal via declared_path.
6240        let sc = UpgradeInstruction::StateChange {
6241            script: PathBuf::from("lib/m.lisp"),
6242        };
6243        assert_eq!(
6244            sc.declared_path().map(std::path::PathBuf::as_path),
6245            Some(PathBuf::from("lib/m.lisp").as_path()),
6246            "declared_path() must project the StateChange :script byte-equal to the raw \
6247             field access — accessor divergence would silently detach the gate from the \
6248             projection every peer per-`UpgradeInstruction` consumer routes through"
6249        );
6250
6251        // (b) Duplicate-StateChange input trips the gate.
6252        let dup = entry(
6253            "0.1.0",
6254            vec![
6255                UpgradeInstruction::LoadModule { module: "x".into() },
6256                UpgradeInstruction::StateChange {
6257                    script: PathBuf::from("lib/m.lisp"),
6258                },
6259                UpgradeInstruction::StateChange {
6260                    script: PathBuf::from("lib/m.lisp"),
6261                },
6262            ],
6263        );
6264        assert_eq!(
6265            dup.validate_state_change_singularity(),
6266            Err(UpgradeError::DuplicateStateChange {
6267                from: "0.1.0".into(),
6268                script: PathBuf::from("lib/m.lisp"),
6269            }),
6270            "duplicate StateChange scripts must trip the gate on the second occurrence \
6271             through the declared_path accessor's Some(script) arm"
6272        );
6273
6274        // (c) Non-StateChange-only inputs leave the gate vacuous.
6275        for instrs in [
6276            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6277            vec![
6278                UpgradeInstruction::LoadModule { module: "x".into() },
6279                UpgradeInstruction::SoftPurge {
6280                    module: "x-old".into(),
6281                },
6282            ],
6283            vec![
6284                UpgradeInstruction::LoadModule { module: "x".into() },
6285                UpgradeInstruction::Purge {
6286                    module: "x-old".into(),
6287                },
6288            ],
6289            vec![UpgradeInstruction::Restart],
6290        ] {
6291            for instr in &instrs {
6292                assert!(
6293                    instr.declared_path().is_none(),
6294                    "non-StateChange variants must project None through declared_path — \
6295                     accessor divergence would let this gate silently fire on a duplicate \
6296                     module reference far from any :state-change site"
6297                );
6298            }
6299            let e = entry("0.1.0", instrs);
6300            assert_eq!(
6301                e.validate_state_change_singularity(),
6302                Ok(()),
6303                "the state-change-singularity gate must return Ok(()) on an entry whose \
6304                 instructions all project None through declared_path — the accessor's \
6305                 continue arm the pattern-match's `_ => continue` previously carried"
6306            );
6307        }
6308    }
6309
6310    // ── within-entry state-change-before-cleanup ordering invariant ──
6311
6312    #[test]
6313    fn validate_rejects_state_change_after_soft_purge() {
6314        // Fail-before-pass-after pin: `:state-change` is the
6315        // gen_server:code_change/3 analog and folds the prior-version
6316        // state shape into the current shape; `:soft-purge` drains the
6317        // prior code. The operator runs instructions in declared order,
6318        // so a `:soft-purge` ahead of a `:state-change` drains the
6319        // prior module before the migration callback runs against the
6320        // state it held — the canonical OTP error mode
6321        // "`code_change/3` invoked on a purged module" the
6322        // release_handler closes by always ordering the migration
6323        // before the cleanup.
6324        let e = entry(
6325            "0.1.0",
6326            vec![
6327                UpgradeInstruction::LoadModule { module: "x".into() },
6328                UpgradeInstruction::SoftPurge {
6329                    module: "x-old".into(),
6330                },
6331                UpgradeInstruction::StateChange {
6332                    script: PathBuf::from("lib/m.lisp"),
6333                },
6334            ],
6335        );
6336        let err = e.validate().unwrap_err();
6337        assert_eq!(
6338            err,
6339            UpgradeError::StateChangeAfterCleanup {
6340                from: "0.1.0".into(),
6341                script: PathBuf::from("lib/m.lisp"),
6342                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6343                prior_cleanup_module: "x-old".into(),
6344            },
6345            "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6346             naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6347        );
6348    }
6349
6350    #[test]
6351    fn validate_rejects_state_change_after_purge() {
6352        // Per-arm coverage: `:purge` (immediate discard, no drain) is
6353        // the more catastrophic peer of `:soft-purge` on the cleanup
6354        // axis; same gate, same shape, the `prior_cleanup_kind` field
6355        // distinguishes the diagnostic so the author can grep their
6356        // caixa.lisp for the offending `(:purge …)` form.
6357        let e = entry(
6358            "0.1.0",
6359            vec![
6360                UpgradeInstruction::LoadModule { module: "x".into() },
6361                UpgradeInstruction::Purge {
6362                    module: "x-old".into(),
6363                },
6364                UpgradeInstruction::StateChange {
6365                    script: PathBuf::from("lib/m.lisp"),
6366                },
6367            ],
6368        );
6369        let err = e.validate().unwrap_err();
6370        assert_eq!(
6371            err,
6372            UpgradeError::StateChangeAfterCleanup {
6373                from: "0.1.0".into(),
6374                script: PathBuf::from("lib/m.lisp"),
6375                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6376                prior_cleanup_module: "x-old".into(),
6377            },
6378            "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6379             `prior_cleanup_kind: \":purge\"`, got {err:?}"
6380        );
6381    }
6382
6383    #[test]
6384    fn validate_accepts_state_change_before_cleanup() {
6385        // Positive control: the canonical `(:load-module …)
6386        // (:state-change …) (:soft-purge …)` order validates — the
6387        // exact shape the module doc example and `validate_accepts_
6388        // well_formed` already pin, restated here on the new gate's
6389        // identity element so a future shortcut that runs the
6390        // singularity gates first doesn't silently mask a regression
6391        // here.
6392        let e = entry(
6393            "0.1.0",
6394            vec![
6395                UpgradeInstruction::LoadModule { module: "x".into() },
6396                UpgradeInstruction::StateChange {
6397                    script: PathBuf::from("lib/m.lisp"),
6398                },
6399                UpgradeInstruction::SoftPurge {
6400                    module: "x-old".into(),
6401                },
6402            ],
6403        );
6404        e.validate().unwrap();
6405    }
6406
6407    #[test]
6408    fn validate_accepts_cleanup_without_state_change() {
6409        // Empty-set identity: an entry that carries no `:state-change`
6410        // at all has nothing to order against the cleanup, so the gate
6411        // passes regardless of how the cleanups are placed (after the
6412        // single required `:load-module`). Mirrors the
6413        // `validate_accepts_multiple_purges_after_one_load` positive
6414        // control on the peer purge-ordering gate; metadata-only
6415        // upgrades with cleanup-but-no-migration land here.
6416        let e = entry(
6417            "0.1.0",
6418            vec![
6419                UpgradeInstruction::LoadModule { module: "x".into() },
6420                UpgradeInstruction::SoftPurge {
6421                    module: "x-old".into(),
6422                },
6423                UpgradeInstruction::Purge {
6424                    module: "x-oldest".into(),
6425                },
6426            ],
6427        );
6428        e.validate().unwrap();
6429    }
6430
6431    #[test]
6432    fn validate_accepts_state_change_without_cleanup() {
6433        // Empty-set identity on the dual axis: an entry that carries no
6434        // cleanup at all has nothing to order against the state-change,
6435        // so the gate passes — additive-upgrade shapes (load new code,
6436        // migrate state, leave old code resident for in-flight callers
6437        // to drain naturally) land here.
6438        let e = entry(
6439            "0.1.0",
6440            vec![
6441                UpgradeInstruction::LoadModule { module: "x".into() },
6442                UpgradeInstruction::StateChange {
6443                    script: PathBuf::from("lib/m.lisp"),
6444                },
6445            ],
6446        );
6447        e.validate().unwrap();
6448    }
6449
6450    #[test]
6451    fn validate_accepts_multiple_state_changes_before_cleanup() {
6452        // Coverage: every state-change must precede every cleanup, not
6453        // just the first. A chain `(load) (sc) (sc) (sp)` is the
6454        // canonical "two distinct migration scripts on a chained
6455        // upgrade" shape (one module's schema *and* another's
6456        // projection per the DuplicateStateChange diagnostic), and
6457        // it must pass when each state-change has distinct script
6458        // paths. Pinned here so a future shortcut that only checks
6459        // the first state-change doesn't silently accept a
6460        // `(load) (sc-1) (sp) (sc-2)` regression.
6461        let e = entry(
6462            "0.1.0",
6463            vec![
6464                UpgradeInstruction::LoadModule { module: "x".into() },
6465                UpgradeInstruction::StateChange {
6466                    script: PathBuf::from("lib/m1.lisp"),
6467                },
6468                UpgradeInstruction::StateChange {
6469                    script: PathBuf::from("lib/m2.lisp"),
6470                },
6471                UpgradeInstruction::SoftPurge {
6472                    module: "x-old".into(),
6473                },
6474            ],
6475        );
6476        e.validate().unwrap();
6477    }
6478
6479    #[test]
6480    fn validate_rejects_state_change_sandwiched_between_cleanups() {
6481        // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
6482        // (sp-2)` violates the gate because the state-change runs
6483        // after the first cleanup. The reported `prior_cleanup_*`
6484        // names the *first* cleanup (the load-bearing one), not the
6485        // last — mirrors every peer first-collision diagnostic
6486        // posture on this module (`validate_state_change_ordering`,
6487        // `validate_purge_ordering`, `validate_load_singularity`,
6488        // `validate_state_change_singularity`,
6489        // `validate_cleanup_singularity` all report the first
6490        // colliding instruction, not the last).
6491        let e = entry(
6492            "0.1.0",
6493            vec![
6494                UpgradeInstruction::LoadModule { module: "x".into() },
6495                UpgradeInstruction::SoftPurge {
6496                    module: "x-old".into(),
6497                },
6498                UpgradeInstruction::StateChange {
6499                    script: PathBuf::from("lib/m.lisp"),
6500                },
6501                UpgradeInstruction::Purge {
6502                    module: "y-old".into(),
6503                },
6504            ],
6505        );
6506        let err = e.validate().unwrap_err();
6507        assert_eq!(
6508            err,
6509            UpgradeError::StateChangeAfterCleanup {
6510                from: "0.1.0".into(),
6511                script: PathBuf::from("lib/m.lisp"),
6512                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6513                prior_cleanup_module: "x-old".into(),
6514            },
6515            "the first cleanup the state-change follows must surface (not the trailing one), \
6516             got {err:?}"
6517        );
6518    }
6519
6520    #[test]
6521    fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
6522        // Diagnostic-precedence pin: an entry like `((:soft-purge
6523        // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
6524        // *both* purge-without-load (the cleanup runs before the
6525        // load) and state-change-after-cleanup (the state-change
6526        // runs after the cleanup). The more-fundamental ordering
6527        // gate must win — the missing-load defect (a cleanup that
6528        // drains the only resident version to nothing) is load-
6529        // bearing, and surfacing the state-change-after-cleanup
6530        // diagnostic first would mask the drain-to-nothing defect
6531        // the peer purge-ordering gate exists to close. Guards the
6532        // call order in `validate` against silent reordering. Same
6533        // posture as `validate_purge_ordering_fires_after_state_
6534        // change_ordering` on the sibling ordering gate.
6535        //
6536        // Pin specifically uses the load-after-cleanup shape (rather
6537        // than load-less) so the state-change-ordering gate (which
6538        // would otherwise fire first on a `((:soft-purge …)
6539        // (:state-change …))` shape with no leading load) is
6540        // sidestepped: with the load present after the cleanup,
6541        // state-change-ordering passes (its `loaded` latch is set
6542        // before the state-change is encountered) but purge-ordering
6543        // still fails (the cleanup precedes the load). That isolates
6544        // the precedence between purge-ordering and this gate
6545        // cleanly.
6546        let e = entry(
6547            "0.1.0",
6548            vec![
6549                UpgradeInstruction::SoftPurge {
6550                    module: "x-old".into(),
6551                },
6552                UpgradeInstruction::LoadModule { module: "x".into() },
6553                UpgradeInstruction::StateChange {
6554                    script: PathBuf::from("lib/m.lisp"),
6555                },
6556            ],
6557        );
6558        let err = e.validate().unwrap_err();
6559        assert!(
6560            matches!(
6561                err,
6562                UpgradeError::PurgeWithoutPriorLoad {
6563                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6564                    ..
6565                }
6566            ),
6567            "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
6568        );
6569    }
6570
6571    #[test]
6572    fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
6573        // Diagnostic-precedence pin: an entry like `((:state-change
6574        // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
6575        // load (because no `:load-module` precedes the state-change)
6576        // but *not* state-change-after-cleanup (the state-change
6577        // precedes the cleanup textually). The state-change-ordering
6578        // gate must surface first regardless — the missing-load
6579        // defect on the migration axis is the load-bearing semantic
6580        // and surfacing a different ordering diagnostic would mask
6581        // the migration-against-stale-code defect. Guards the call
6582        // order in `validate` against silent reordering on a shape
6583        // that fires only the state-change-ordering gate (not this
6584        // one), pinning that the state-change-ordering gate wins
6585        // ahead of this gate's chance to look at the list.
6586        let e = entry(
6587            "0.1.0",
6588            vec![
6589                UpgradeInstruction::StateChange {
6590                    script: PathBuf::from("lib/m.lisp"),
6591                },
6592                UpgradeInstruction::SoftPurge {
6593                    module: "x-old".into(),
6594                },
6595            ],
6596        );
6597        let err = e.validate().unwrap_err();
6598        assert!(
6599            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6600            "state-change-without-load must surface before purge-without-load (the canonical \
6601             validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
6602        );
6603    }
6604
6605    #[test]
6606    fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
6607        // Order pin: a malformed `:script` value on a `:state-change`
6608        // (an empty path) surfaces its narrower `EmptyScript`
6609        // diagnostic *before* the within-entry state-change-before-
6610        // cleanup gate fires. The per-instruction shape pass walks
6611        // the list inline before the ordering check, so the narrower
6612        // self-locating diagnostic surfaces first — mirrors the
6613        // empty-first cascade on every peer path-shape gate and the
6614        // `validate_purge_ordering_fires_after_per_instr_shape` pin
6615        // on the sibling ordering gate.
6616        let e = entry(
6617            "0.1.0",
6618            vec![
6619                UpgradeInstruction::LoadModule { module: "x".into() },
6620                UpgradeInstruction::SoftPurge {
6621                    module: "x-old".into(),
6622                },
6623                UpgradeInstruction::StateChange {
6624                    script: PathBuf::new(),
6625                },
6626            ],
6627        );
6628        let err = e.validate().unwrap_err();
6629        assert_eq!(
6630            err,
6631            UpgradeError::EmptyScript,
6632            "malformed instruction must surface its narrower diagnostic before the \
6633             state-change-before-cleanup gate fires, got {err:?}"
6634        );
6635    }
6636
6637    #[test]
6638    fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
6639        // Diagnostic-precedence pin: an entry like `((:load-module
6640        // "x") (:soft-purge "x-old") (:state-change "m.lisp")
6641        // (:state-change "m.lisp"))` violates *both* this ordering
6642        // gate (the first state-change follows the cleanup) and the
6643        // state-change-singularity gate (the same script appears
6644        // twice). The ordering gate must win — the canonical
6645        // "ordering before singularity" precedence the peer
6646        // `validate_state_change_ordering` / `validate_purge_
6647        // ordering` gates already establish over their own singularity
6648        // gates, applied uniformly across the OTP canonical-sequence
6649        // ordering axis here. Guards the call order in `validate`:
6650        // `validate_state_change_before_cleanup` runs before the
6651        // per-instruction-class singularity gates.
6652        let e = entry(
6653            "0.1.0",
6654            vec![
6655                UpgradeInstruction::LoadModule { module: "x".into() },
6656                UpgradeInstruction::SoftPurge {
6657                    module: "x-old".into(),
6658                },
6659                UpgradeInstruction::StateChange {
6660                    script: PathBuf::from("lib/m.lisp"),
6661                },
6662                UpgradeInstruction::StateChange {
6663                    script: PathBuf::from("lib/m.lisp"),
6664                },
6665            ],
6666        );
6667        let err = e.validate().unwrap_err();
6668        assert!(
6669            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6670            "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
6671        );
6672    }
6673
6674    #[test]
6675    fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
6676        // The whole-list entry-point surfaces the per-entry ordering
6677        // error (mirrors `validate_purge_ordering_threads_through_
6678        // validate_upgrade_from` and every peer wiring pin): the gate
6679        // is reachable from the LayoutInvariants call site, not only
6680        // from a direct `entry.validate()`.
6681        let entries = vec![entry(
6682            "0.1.0",
6683            vec![
6684                UpgradeInstruction::LoadModule { module: "x".into() },
6685                UpgradeInstruction::SoftPurge {
6686                    module: "x-old".into(),
6687                },
6688                UpgradeInstruction::StateChange {
6689                    script: PathBuf::from("lib/m.lisp"),
6690                },
6691            ],
6692        )];
6693        let err = validate_upgrade_from(&entries).unwrap_err();
6694        assert!(
6695            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6696            "validate_upgrade_from must thread the state-change-before-cleanup error, \
6697             got {err:?}"
6698        );
6699    }
6700
6701    #[test]
6702    fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
6703        // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
6704        // per-instruction `StateChange`-arm script-path projection must
6705        // route through the sibling lifted
6706        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6707        // accessor, not the raw
6708        // `if let UpgradeInstruction::StateChange { script } = instr`
6709        // open-coded pattern-match the gate previously carried inside
6710        // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
6711        //
6712        // Structurally: the gate's projection accept-set is the union
6713        // of every [`UpgradeInstruction`] variant for which
6714        // `declared_path().is_some()` — today exactly
6715        // [`UpgradeInstruction::StateChange`] per the sibling
6716        // `declared_path_only_for_state_change` pin, so a
6717        // state-change-after-cleanup input trips
6718        // `StateChangeAfterCleanup` and a non-`StateChange` input
6719        // (module-bearing / terminal) leaves the sticky-once latch
6720        // sweep quiet byte-identical to the pattern-match shape.
6721        //
6722        // Byte-equal today (`declared_path` returns `Some(script)` iff
6723        // `StateChange`, byte-for-byte from the variant's own storage);
6724        // the pin catches any future accessor extension that promotes
6725        // an additional variant onto the `PathBuf`-carrying axis — the
6726        // gate then fires on migrate-after-cleanup for that variant too,
6727        // and the migrate→cleanup ordering discipline the peer
6728        // [`validate_state_change_singularity`] /
6729        // [`validate_upgrade_from_against_behavior`] gates share on the
6730        // same axis extends to the promoted variant by construction.
6731        //
6732        // Peer of the sibling four per-`UpgradeInstruction` consumers
6733        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6734        // sandbox-path fan-out, the layout-side per-`StateChange`
6735        // script-existence fan-out at
6736        // `caixa-core/src/layout.rs:1058`, the within-entry
6737        // [`UpgradeFromEntry::validate_state_change_singularity`]
6738        // per-`StateChange` script-projection fan-out, the cross-slot
6739        // [`validate_upgrade_from_against_behavior`] per-`StateChange`
6740        // detection loop) — the fifth (and last unlifted inside
6741        // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
6742        // the `PathBuf`-carrying axis to now route through the accessor.
6743        // Same shape as the sibling
6744        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
6745        // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
6746        // pins extended onto the within-entry migrate→cleanup ordering
6747        // gate.
6748        //
6749        // Three-arm projective coverage:
6750        //   (a) `StateChange` scripts project through `declared_path()`
6751        //       byte-equal to the raw `script.clone()` field access
6752        //       the diagnostic previously carried;
6753        //   (b) a `:state-change`-after-cleanup input trips the gate
6754        //       with `StateChangeAfterCleanup` carrying the offending
6755        //       script + the prior cleanup's kind/module verbatim;
6756        //   (c) a non-`StateChange`-only input (`LoadModule` /
6757        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
6758        //       vacuous with `Ok(())` — the `declared_path().is_none()`
6759        //       arm's fall-through pins.
6760        //
6761        // Fail-before-pass-after verified structurally: swapping the
6762        // production
6763        //   `else if let Some(script) = instr.declared_path() && … { … }`
6764        // back to
6765        //   `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
6766        // keeps arms (a)-(c) passing but silently detaches this within-
6767        // entry ordering gate from the accessor's typed dispatch — any
6768        // future `declared_path` extension (promotion of an additional
6769        // variant onto the axis, an operator-side pre-resolved-path
6770        // cache the accessor materializes) would then silently disagree
6771        // between this gate's raw pattern-match and the peer four
6772        // sibling consumers that route through the accessor.
6773
6774        // (a) StateChange projection byte-equal via declared_path.
6775        let sc = UpgradeInstruction::StateChange {
6776            script: PathBuf::from("lib/m.lisp"),
6777        };
6778        assert_eq!(
6779            sc.declared_path().cloned(),
6780            Some(PathBuf::from("lib/m.lisp")),
6781            "declared_path() must project the StateChange :script byte-equal to the raw \
6782             field access — accessor divergence would silently detach this within-entry \
6783             migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
6784             consumer routes through"
6785        );
6786
6787        // (b) StateChange-after-cleanup trips the gate through the accessor.
6788        let after = entry(
6789            "0.1.0",
6790            vec![
6791                UpgradeInstruction::LoadModule { module: "x".into() },
6792                UpgradeInstruction::SoftPurge {
6793                    module: "x-old".into(),
6794                },
6795                UpgradeInstruction::StateChange {
6796                    script: PathBuf::from("lib/m.lisp"),
6797                },
6798            ],
6799        );
6800        assert_eq!(
6801            after.validate(),
6802            Err(UpgradeError::StateChangeAfterCleanup {
6803                from: "0.1.0".into(),
6804                script: PathBuf::from("lib/m.lisp"),
6805                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6806                prior_cleanup_module: "x-old".into(),
6807            }),
6808            "a :state-change following a cleanup must trip the gate through the declared_path \
6809             accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
6810             kind/module verbatim byte-identical to the pattern-match shape"
6811        );
6812
6813        // (c) Non-StateChange-only inputs leave the gate vacuous.
6814        for instrs in [
6815            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6816            vec![
6817                UpgradeInstruction::LoadModule { module: "x".into() },
6818                UpgradeInstruction::SoftPurge {
6819                    module: "x-old".into(),
6820                },
6821            ],
6822            vec![
6823                UpgradeInstruction::LoadModule { module: "x".into() },
6824                UpgradeInstruction::Purge {
6825                    module: "x-old".into(),
6826                },
6827            ],
6828            vec![UpgradeInstruction::Restart],
6829        ] {
6830            for instr in &instrs {
6831                assert!(
6832                    instr.declared_path().is_none(),
6833                    "non-StateChange variants must project None through declared_path — \
6834                     accessor divergence would let this within-entry ordering gate silently \
6835                     fire on a cleanup-only sequence far from any :state-change site"
6836                );
6837            }
6838            let e = entry("0.1.0", instrs);
6839            assert_eq!(
6840                e.validate(),
6841                Ok(()),
6842                "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
6843                 instructions all project None through declared_path — the accessor's \
6844                 None arm the pattern-match's implicit fall-through previously carried"
6845            );
6846        }
6847    }
6848
6849    #[test]
6850    fn validate_restart_order_independent() {
6851        // Position-agnostic: `(:restart)` leading or trailing the
6852        // mixed sequence surfaces the same RestartNotExclusive shape.
6853        // Mirrors OTP appup's order-insensitive
6854        // `restart_emulator | restart_new_emulator` terminal rule —
6855        // the position of the restart instruction in the script is
6856        // irrelevant; what matters is the script *contains* it
6857        // alongside other instructions at all. The gate must not
6858        // gain a false positive by depending on instruction ordering.
6859        let leading = entry(
6860            "0.1.0",
6861            vec![
6862                UpgradeInstruction::Restart,
6863                UpgradeInstruction::LoadModule { module: "x".into() },
6864            ],
6865        );
6866        let trailing = entry(
6867            "0.1.0",
6868            vec![
6869                UpgradeInstruction::LoadModule { module: "x".into() },
6870                UpgradeInstruction::Restart,
6871            ],
6872        );
6873        let middle = entry(
6874            "0.1.0",
6875            vec![
6876                UpgradeInstruction::LoadModule { module: "a".into() },
6877                UpgradeInstruction::Restart,
6878                UpgradeInstruction::SoftPurge {
6879                    module: "a-old".into(),
6880                },
6881            ],
6882        );
6883        for e in [&leading, &trailing, &middle] {
6884            assert!(
6885                matches!(
6886                    e.validate().unwrap_err(),
6887                    UpgradeError::RestartNotExclusive {
6888                        restart_count: 1,
6889                        ..
6890                    }
6891                ),
6892                "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
6893                 instruction order, got {:?}",
6894                e.validate()
6895            );
6896        }
6897    }
6898
6899    #[test]
6900    fn validate_restart_exclusive_fires_after_per_instr_shape() {
6901        // Order pin: a malformed `:module` value on a Module-bearing
6902        // instruction (an empty string) surfaces its narrower
6903        // kind-tagged `ModuleEmpty` diagnostic *before* the within-
6904        // entry restart-exclusivity gate fires. The per-instruction
6905        // shape pass walks the list inline before the restart-
6906        // exclusive check, so the narrower self-locating diagnostic
6907        // surfaces first — mirrors the empty-first cascade on every
6908        // peer DNS-1123 gate (`validate_module`,
6909        // `validate_membro_caixa`, `validate_placement_cluster`) and
6910        // the `*_invalid_fires_before_duplicate_check` arm-ordering
6911        // pins on every typed-graph axis. Without this pin a future
6912        // shortcut that runs the restart-exclusive check ahead of
6913        // per-instruction shape would surface a less-actionable
6914        // RestartNotExclusive over an instruction list that's also
6915        // malformed at the per-instruction layer.
6916        let e = entry(
6917            "0.1.0",
6918            vec![
6919                UpgradeInstruction::LoadModule {
6920                    module: String::new(),
6921                },
6922                UpgradeInstruction::Restart,
6923            ],
6924        );
6925        let err = e.validate().unwrap_err();
6926        assert_eq!(
6927            err,
6928            UpgradeError::ModuleEmpty {
6929                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
6930            },
6931            "malformed instruction must surface its kind-tagged diagnostic before the \
6932             restart-exclusivity gate fires, got {err:?}"
6933        );
6934    }
6935
6936    fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
6937        // Helper for the cross-slot composition gate's pass arm: a
6938        // BehaviorSpec carrying just the `:on-state-change` callback,
6939        // the runtime hook the per-version `(:state-change "…")`
6940        // instruction is delivered through during hot upgrade. Mirrors
6941        // the canonical authoring shape pinned in the module doc.
6942        crate::BehaviorSpec {
6943            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
6944            ..Default::default()
6945        }
6946    }
6947
6948    #[test]
6949    fn behavior_gate_rejects_state_change_without_any_behavior() {
6950        // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
6951        // caixa carries no `:behavior` at all surfaces the missing-
6952        // callback diagnostic naming the offending entry's `:from` +
6953        // script. The "I added the upgrade path but never declared
6954        // `:behavior`" footgun: `:behavior` is optional at the typed
6955        // root, the typed `:upgrade-from` slot validates on its own
6956        // merits, and the operator's hot-upgrade dispatch reaches for
6957        // a callback that doesn't exist.
6958        let entries = vec![entry(
6959            "0.1.0",
6960            vec![
6961                UpgradeInstruction::LoadModule { module: "x".into() },
6962                UpgradeInstruction::StateChange {
6963                    script: PathBuf::from("lib/m.lisp"),
6964                },
6965            ],
6966        )];
6967        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6968        assert_eq!(
6969            err,
6970            UpgradeError::StateChangeWithoutOnStateChangeCallback {
6971                from: "0.1.0".into(),
6972                script: PathBuf::from("lib/m.lisp"),
6973            },
6974        );
6975    }
6976
6977    #[test]
6978    fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
6979        // `:behavior` declared with *other* callbacks set
6980        // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
6981        // None still surfaces the missing-callback diagnostic — only
6982        // the `:on-state-change` axis matters for this gate. The
6983        // "I declared `:behavior` but missed the migration callback"
6984        // footgun: a caixa that registers its lifecycle hooks but
6985        // forgets the migration delivery path leaves the
6986        // `:state-change` instruction with no runtime hook to
6987        // dispatch through.
6988        let entries = vec![entry(
6989            "0.1.0",
6990            vec![
6991                UpgradeInstruction::LoadModule { module: "x".into() },
6992                UpgradeInstruction::StateChange {
6993                    script: PathBuf::from("lib/m.lisp"),
6994                },
6995            ],
6996        )];
6997        let b = crate::BehaviorSpec {
6998            on_init: Some(PathBuf::from("lib/init.lisp")),
6999            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7000            ..Default::default()
7001        };
7002        let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7003        assert_eq!(
7004            err,
7005            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7006                from: "0.1.0".into(),
7007                script: PathBuf::from("lib/m.lisp"),
7008            },
7009            "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7010             the missing migration hook"
7011        );
7012    }
7013
7014    #[test]
7015    fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7016        // The canonical composition shape: a per-version
7017        // `(:state-change "lib/m.lisp")` instruction paired with the
7018        // `:behavior :on-state-change "lib/migrations.lisp"` callback
7019        // it is delivered through at hot-upgrade time. Pins the gate's
7020        // pass arm — drift here = a future tighten that rejects the
7021        // canonical OTP-shape composition surfaces as a regression at
7022        // this positive-control pin.
7023        let entries = vec![entry(
7024            "0.1.0",
7025            vec![
7026                UpgradeInstruction::LoadModule { module: "x".into() },
7027                UpgradeInstruction::StateChange {
7028                    script: PathBuf::from("lib/m.lisp"),
7029                },
7030            ],
7031        )];
7032        let b = behavior_with_state_change_callback();
7033        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7034    }
7035
7036    #[test]
7037    fn behavior_gate_accepts_entries_without_any_state_change() {
7038        // Empty-set identity: entries carrying no `:state-change`
7039        // instruction at all (load + cleanup only — the metadata-only
7040        // upgrade shape the module doc names, "On any failure, the
7041        // current version stays load-bearing — a typed atomic
7042        // upgrade") leave the gate vacuous. The composition only
7043        // requires a callback when the per-version script exists; a
7044        // load + cleanup pair has no migration to deliver, so the
7045        // absence of `:on-state-change` is coherent.
7046        let entries = vec![entry(
7047            "0.1.0",
7048            vec![
7049                UpgradeInstruction::LoadModule { module: "x".into() },
7050                UpgradeInstruction::SoftPurge {
7051                    module: "x-old".into(),
7052                },
7053            ],
7054        )];
7055        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7056    }
7057
7058    #[test]
7059    fn behavior_gate_accepts_restart_only_entry() {
7060        // The terminal-fallback `((:restart))` shape carries no
7061        // `:state-change` — the operator restarts the pod and the
7062        // new version comes up fresh against its initial state, no
7063        // migration. Pinned alongside the metadata-only positive
7064        // control above as the second empty-state-change shape.
7065        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7066        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7067    }
7068
7069    #[test]
7070    fn behavior_gate_accepts_empty_entries_list() {
7071        // Empty `:upgrade-from` (a caixa with no declared upgrade
7072        // paths — the v0.1.0 caixa before any upgrade entries are
7073        // added) trivially passes the gate. Pinned so the gate
7074        // doesn't accidentally fire on a caixa that hasn't yet
7075        // declared any upgrades.
7076        let entries: Vec<UpgradeFromEntry> = vec![];
7077        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7078    }
7079
7080    #[test]
7081    fn behavior_gate_reports_first_state_change_in_first_entry() {
7082        // First-collision determinism: with multiple `:state-change`
7083        // instructions across multiple entries, the gate reports the
7084        // *first* one encountered in declaration order — the entry's
7085        // declaration order first, then the within-entry instruction
7086        // order. Mirrors every peer first-collision diagnostic posture
7087        // on this module (`validate_state_change_ordering`,
7088        // `validate_purge_ordering`, the singularity gates), so a
7089        // future shortcut that walks the list in reverse or returns
7090        // the last collision surfaces as a regression here.
7091        let entries = vec![
7092            entry(
7093                "0.1.0",
7094                vec![
7095                    UpgradeInstruction::LoadModule { module: "x".into() },
7096                    UpgradeInstruction::StateChange {
7097                        script: PathBuf::from("lib/m1.lisp"),
7098                    },
7099                    UpgradeInstruction::StateChange {
7100                        script: PathBuf::from("lib/m2.lisp"),
7101                    },
7102                ],
7103            ),
7104            entry(
7105                "0.1.5",
7106                vec![
7107                    UpgradeInstruction::LoadModule { module: "x".into() },
7108                    UpgradeInstruction::StateChange {
7109                        script: PathBuf::from("lib/m3.lisp"),
7110                    },
7111                ],
7112            ),
7113        ];
7114        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7115        assert_eq!(
7116            err,
7117            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7118                from: "0.1.0".into(),
7119                script: PathBuf::from("lib/m1.lisp"),
7120            },
7121            "the first :state-change in the first entry must surface, not later collisions"
7122        );
7123    }
7124
7125    #[test]
7126    fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7127        // Cross-entry pin: a first entry with no `:state-change` (just
7128        // a load + cleanup) leaves the gate's per-entry walk continuing
7129        // to the second entry, where the offending instruction lives.
7130        // The diagnostic names the *second* entry's `:from` because
7131        // that's where the missing-callback shape is exposed — pinned
7132        // so a shortcut that bails on the first entry without a
7133        // `:state-change` (rather than continuing) doesn't mask the
7134        // defect in a later entry.
7135        let entries = vec![
7136            entry(
7137                "0.1.0",
7138                vec![
7139                    UpgradeInstruction::LoadModule { module: "x".into() },
7140                    UpgradeInstruction::SoftPurge {
7141                        module: "x-old".into(),
7142                    },
7143                ],
7144            ),
7145            entry(
7146                "0.1.5",
7147                vec![
7148                    UpgradeInstruction::LoadModule { module: "x".into() },
7149                    UpgradeInstruction::StateChange {
7150                        script: PathBuf::from("lib/m.lisp"),
7151                    },
7152                ],
7153            ),
7154        ];
7155        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7156        assert_eq!(
7157            err,
7158            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7159                from: "0.1.5".into(),
7160                script: PathBuf::from("lib/m.lisp"),
7161            },
7162            "the offending entry's `:from` must surface even when an earlier entry carries no \
7163             :state-change"
7164        );
7165    }
7166
7167    #[test]
7168    fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7169        // Positive control: a multi-entry `:upgrade-from` (chained
7170        // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7171        // a `:state-change` passes when the callback is declared once
7172        // at the caixa root. The callback is a single per-caixa
7173        // runtime hook; one declaration covers every entry's
7174        // `:state-change`, mirroring OTP's
7175        // `release_handler:install_release/1` which dispatches every
7176        // appup's `code_change` instruction through the single
7177        // `gen_server:code_change/3` callback registered on the
7178        // module.
7179        let entries = vec![
7180            entry(
7181                "0.1.0",
7182                vec![
7183                    UpgradeInstruction::LoadModule { module: "x".into() },
7184                    UpgradeInstruction::StateChange {
7185                        script: PathBuf::from("lib/m1.lisp"),
7186                    },
7187                ],
7188            ),
7189            entry(
7190                "0.1.5",
7191                vec![
7192                    UpgradeInstruction::LoadModule { module: "x".into() },
7193                    UpgradeInstruction::StateChange {
7194                        script: PathBuf::from("lib/m2.lisp"),
7195                    },
7196                ],
7197            ),
7198        ];
7199        let b = behavior_with_state_change_callback();
7200        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7201    }
7202
7203    #[test]
7204    fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7205        // Symmetry pin: the gate's pass arm doesn't depend on the
7206        // entry actually carrying a `:state-change` — if no
7207        // `:state-change` is declared, the gate is vacuous regardless
7208        // of the callback (an `:on-state-change` declared without a
7209        // matching per-version script is fine, the callback is the
7210        // runtime default for any *future* migration the author hasn't
7211        // yet added). Pins that a caixa author can declare the
7212        // callback ahead of any migration without the gate
7213        // complaining.
7214        let entries = vec![entry(
7215            "0.1.0",
7216            vec![
7217                UpgradeInstruction::LoadModule { module: "x".into() },
7218                UpgradeInstruction::SoftPurge {
7219                    module: "x-old".into(),
7220                },
7221            ],
7222        )];
7223        let b = behavior_with_state_change_callback();
7224        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7225    }
7226
7227    #[test]
7228    fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7229        // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7230        // per-instruction `StateChange`-arm script-path projection must
7231        // route through the sibling lifted
7232        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7233        // accessor, not the raw
7234        // `if let UpgradeInstruction::StateChange { script } = instr`
7235        // open-coded pattern-match the cross-slot gate previously
7236        // carried at caixa-core/src/upgrade.rs:1365.
7237        //
7238        // Structurally: the gate's projection accept-set is the union
7239        // of every [`UpgradeInstruction`] variant for which
7240        // `declared_path().is_some()` — today exactly
7241        // [`UpgradeInstruction::StateChange`] per the sibling
7242        // `declared_path_only_for_state_change` pin, so a
7243        // `:state-change`-carrying entry without an `:on-state-change`
7244        // callback trips `StateChangeWithoutOnStateChangeCallback` and
7245        // a non-`StateChange` entry (load-only / cleanup-only /
7246        // restart-only / empty-`:instructions`) leaves the per-entry
7247        // walk continuing past every non-projecting instruction
7248        // byte-identical to the pattern-match shape.
7249        //
7250        // Byte-equal today (`declared_path` returns `Some(script)` iff
7251        // `StateChange`, byte-for-byte from the variant's own storage);
7252        // the pin catches any future accessor extension that promotes
7253        // an additional variant onto the `PathBuf`-carrying axis — the
7254        // gate then fires on scripts from that variant too, and the
7255        // cross-slot composition discipline the sibling per-
7256        // `UpgradeInstruction` consumers share on the `PathBuf`-
7257        // carrying axis extends to the promoted variant by
7258        // construction.
7259        //
7260        // Peer of the sibling four per-`UpgradeInstruction` consumers
7261        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7262        // sandbox-path fan-out, the layout-side per-`StateChange`
7263        // script-existence fan-out at
7264        // `caixa-core/src/layout.rs:1058`, the within-entry
7265        // [`UpgradeFromEntry::validate_state_change_singularity`]
7266        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7267        // peer [`UpgradeInstruction::declared_module`] `String`-axis
7268        // per-variant unifier) — the fourth (and last) per-
7269        // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7270        // to now route through the accessor. Same shape as the
7271        // sibling
7272        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7273        // pin extended onto the cross-slot composition gate.
7274        //
7275        // Three-arm projective coverage:
7276        //   (a) `StateChange` scripts project through `declared_path()`
7277        //       byte-equal to the raw `script.clone()` field access
7278        //       the diagnostic previously carried;
7279        //   (b) a `:state-change`-carrying entry with `behavior: None`
7280        //       trips the gate with `StateChangeWithoutOnStateChangeCallback`
7281        //       carrying the offending script verbatim;
7282        //   (c) a non-`StateChange`-only entry (`LoadModule` /
7283        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7284        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7285        //       arm's fall-through pins.
7286        //
7287        // Fail-before-pass-after verified structurally: swapping the
7288        // production
7289        //   `if let Some(script) = instr.declared_path() { … }`
7290        // back to
7291        //   `if let UpgradeInstruction::StateChange { script } = instr { … }`
7292        // keeps arms (a)-(c) passing but silently detaches the gate
7293        // from the accessor's typed dispatch — any future
7294        // `declared_path` extension (promotion of an additional
7295        // variant onto the axis, an operator-side pre-resolved-path
7296        // cache the accessor materializes) would then silently
7297        // disagree between this cross-slot gate's raw pattern-match
7298        // and the peer four sibling consumers that route through the
7299        // accessor.
7300
7301        // (a) StateChange projection byte-equal via declared_path.
7302        let sc = UpgradeInstruction::StateChange {
7303            script: PathBuf::from("lib/m.lisp"),
7304        };
7305        assert_eq!(
7306            sc.declared_path().cloned(),
7307            Some(PathBuf::from("lib/m.lisp")),
7308            "declared_path() must project the StateChange :script byte-equal to the raw \
7309             field access — accessor divergence would silently detach this cross-slot \
7310             composition gate from the projection every peer per-`UpgradeInstruction` \
7311             consumer routes through"
7312        );
7313
7314        // (b) StateChange-carrying entry with behavior: None trips gate.
7315        let entries = vec![entry(
7316            "0.1.0",
7317            vec![
7318                UpgradeInstruction::LoadModule { module: "x".into() },
7319                UpgradeInstruction::StateChange {
7320                    script: PathBuf::from("lib/m.lisp"),
7321                },
7322            ],
7323        )];
7324        assert_eq!(
7325            validate_upgrade_from_against_behavior(&entries, None),
7326            Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7327                from: "0.1.0".into(),
7328                script: PathBuf::from("lib/m.lisp"),
7329            }),
7330            "a :state-change-carrying entry with behavior: None must trip the gate through \
7331             the declared_path accessor's Some(script) arm — carrying the offending script \
7332             verbatim byte-identical to the pattern-match shape"
7333        );
7334
7335        // (c) Non-StateChange-only inputs leave the gate vacuous.
7336        for instrs in [
7337            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7338            vec![
7339                UpgradeInstruction::LoadModule { module: "x".into() },
7340                UpgradeInstruction::SoftPurge {
7341                    module: "x-old".into(),
7342                },
7343            ],
7344            vec![
7345                UpgradeInstruction::LoadModule { module: "x".into() },
7346                UpgradeInstruction::Purge {
7347                    module: "x-old".into(),
7348                },
7349            ],
7350            vec![UpgradeInstruction::Restart],
7351        ] {
7352            for instr in &instrs {
7353                assert!(
7354                    instr.declared_path().is_none(),
7355                    "non-StateChange variants must project None through declared_path — \
7356                     accessor divergence would let this cross-slot composition gate silently \
7357                     fire on a module reference far from any :state-change site"
7358                );
7359            }
7360            let entries = vec![entry("0.1.0", instrs)];
7361            assert_eq!(
7362                validate_upgrade_from_against_behavior(&entries, None),
7363                Ok(()),
7364                "the cross-slot composition gate must return Ok(()) on an entry whose \
7365                 instructions all project None through declared_path — the accessor's \
7366                 None arm the pattern-match's implicit fall-through previously carried"
7367            );
7368        }
7369    }
7370
7371    #[test]
7372    fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7373        // Wiring pin: the within-entry restart-exclusivity gate fires
7374        // through [`validate_upgrade_from`] (which delegates to
7375        // [`UpgradeFromEntry::validate`] per entry) before the cross-
7376        // entry duplicate-`:from` gate would have a chance to run on
7377        // the malformed entry. Pinned here so a future refactor that
7378        // walks the cross-entry gate first doesn't accidentally
7379        // surface a DuplicateFrom over an entry that's also malformed
7380        // at the within-entry restart-exclusivity layer.
7381        let entries = vec![
7382            entry(
7383                "0.1.0",
7384                vec![
7385                    UpgradeInstruction::LoadModule { module: "x".into() },
7386                    UpgradeInstruction::Restart,
7387                ],
7388            ),
7389            entry("0.1.0", vec![UpgradeInstruction::Restart]),
7390        ];
7391        let err = validate_upgrade_from(&entries).unwrap_err();
7392        assert!(
7393            matches!(
7394                err,
7395                UpgradeError::RestartNotExclusive {
7396                    restart_count: 1,
7397                    ..
7398                }
7399            ),
7400            "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7401             duplicate-`:from` gate fires, got {err:?}"
7402        );
7403    }
7404
7405    // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7406
7407    #[test]
7408    fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7409        // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7410        // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7411        // name the exact camelCase JSON keys the `#[serde(rename_all =
7412        // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7413        // test-side probe across the caixa-core / caixa-flux renderer
7414        // test fixtures navigates into each element of the rendered
7415        // `:upgrade-from` overlay sequence by consulting one of these two
7416        // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7417        // and pin that each canonical byte-sequence appears verbatim in
7418        // the JSON — a future accidental `rename_all = "snake_case"` /
7419        // `"kebab-case"` / verbatim-field-name flip at the derive
7420        // attribute (any of which would silently break every test-side
7421        // probe that reaches for one of the two consts) surfaces here as
7422        // a build-time test failure at `upgrade.rs`, not as an apply-time
7423        // `.get(<stale-canonical-const>)` returning `None` far from the
7424        // derive-attr drift's commit. Same discipline the sibling
7425        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7426        // (d8b8b4f) and
7427        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7428        // (21fe462) pins established on the peer `:limits` / `:behavior`
7429        // sub-slot axes: one canonical byte-string per typed sub-key
7430        // axis, pinned to the load-bearing serde derivation at the type
7431        // itself.
7432        let e = UpgradeFromEntry {
7433            from: "0.1.0".into(),
7434            instructions: vec![UpgradeInstruction::LoadModule {
7435                module: "hello-rio".into(),
7436            }],
7437        };
7438        let json = serde_json::to_string(&e).unwrap();
7439        for key in [
7440            crate::render::M2_UPGRADE_FROM_KEY_FROM,
7441            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7442        ] {
7443            let quoted = format!("\"{key}\"");
7444            assert!(
7445                json.contains(&quoted),
7446                "serialized UpgradeFromEntry must carry the lifted \
7447                 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7448                 the JSON emission (got: {json})",
7449            );
7450        }
7451    }
7452
7453    #[test]
7454    fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7455        // Cross-axis drift-detection pin: a future collapse of the two
7456        // canonical sub-key byte-strings onto the same value (e.g. an
7457        // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7458        // to also read `"from"`) would silently reroute every test-side
7459        // probe on one axis onto the sibling axis's per-entry field and
7460        // pass every propagation-probe test that expected only the stale
7461        // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7462        // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7463        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7464        let all = [
7465            crate::render::M2_UPGRADE_FROM_KEY_FROM,
7466            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7467        ];
7468        for (i, a) in all.iter().enumerate() {
7469            for b in all.iter().skip(i + 1) {
7470                assert_ne!(
7471                    a, b,
7472                    "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
7473                     canonical byte-sequences — got `{a}` == `{b}`",
7474                );
7475            }
7476        }
7477    }
7478
7479    #[test]
7480    fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
7481        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7482        // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
7483        // tagged variant-discriminator key axis: the
7484        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
7485        // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
7486        // attribute on [`UpgradeInstruction`] emits, and every downstream
7487        // consumer that navigates the serialized instruction blob to
7488        // route by variant (the caixa-core reflection-vs-serde round-trip
7489        // check in `dispatcher_registration.rs` that probes
7490        // `v.get("kind")` against every variant's expected kebab-case
7491        // tag, the future M4 admission-webhook path, any wasm-operator
7492        // dispatch step consuming the serialized instruction blob) reads
7493        // through the same `&'static str`. Serialize every variant and
7494        // pin that the const's byte-sequence appears verbatim as the
7495        // tag-slot JSON key with the expected kebab-case value — a
7496        // future accidental `tag = "type"` / `tag = "op"` /
7497        // `tag = "instruction"` rebrand at the derive attribute (any of
7498        // which would silently break every consumer probe reaching for
7499        // the stale-tag-key const) surfaces here as a build-time test
7500        // failure at `upgrade.rs`, not as an apply-time
7501        // `.get(<stale-tag-key>)` returning `None` far from the derive-
7502        // attr drift's commit.
7503        //
7504        // Same "one canonical byte-string per typed axis" discipline the
7505        // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7506        // pin (36ffe65) established on the peer `:upgrade-from` per-entry
7507        // outer-container axis — this pin extends the discipline one
7508        // altitude deeper onto the per-instruction *tag* axis inside
7509        // each element of the `:instructions` list, completing the
7510        // typed coverage of the `:upgrade-from :instructions` dual
7511        // (key = "kind" + five variant-value tags): the five
7512        // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
7513        // per-variant kebab-case *values*; this pin pins the tag *key*
7514        // above them.
7515        let samples: [(UpgradeInstruction, &'static str); 5] = [
7516            (
7517                UpgradeInstruction::LoadModule {
7518                    module: "hello-rio".into(),
7519                },
7520                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
7521            ),
7522            (
7523                UpgradeInstruction::StateChange {
7524                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7525                },
7526                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
7527            ),
7528            (
7529                UpgradeInstruction::SoftPurge {
7530                    module: "hello-rio-old".into(),
7531                },
7532                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
7533            ),
7534            (
7535                UpgradeInstruction::Purge {
7536                    module: "hello-rio-old".into(),
7537                },
7538                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
7539            ),
7540            (
7541                UpgradeInstruction::Restart,
7542                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
7543            ),
7544        ];
7545        for (sample, expected_value) in &samples {
7546            let v: serde_json::Value = serde_json::to_value(sample).unwrap();
7547            let got = v
7548                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
7549                .and_then(|k| k.as_str());
7550            assert_eq!(
7551                got,
7552                Some(*expected_value),
7553                "serialized {sample:?} must carry the lifted \
7554                 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
7555                 ({:?}) verbatim as the tag-slot JSON key, holding the \
7556                 expected kebab-case value {expected_value:?} (got: {v})",
7557                crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
7558            );
7559        }
7560    }
7561
7562    #[test]
7563    fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
7564        // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
7565        // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7566        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7567        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7568        // whitespace / colons / dots) — the canonical shape a serde
7569        // internally-tagged discriminator key takes across every peer
7570        // enum in this crate. A future flip to a non-camelCase byte at
7571        // the const surfaces here at build time. Peer of
7572        // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
7573        // sibling per-entry outer-container axis.
7574        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7575        assert!(
7576            !key.is_empty(),
7577            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
7578        );
7579        let first = key.chars().next().unwrap();
7580        assert!(
7581            first.is_ascii_lowercase(),
7582            "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
7583             byte (got {key:?}, leads with {first:?})",
7584        );
7585        assert!(
7586            key.chars().all(|c| c.is_ascii_alphanumeric()),
7587            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
7588             — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7589        );
7590    }
7591
7592    #[test]
7593    fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
7594        // Cross-axis drift-detection pin: the tag-slot key
7595        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
7596        // disjoint from every per-variant data-field key the
7597        // internally-tagged serialization also emits (`"module"` for
7598        // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
7599        // future accidental rebrand that collapses `tag = "kind"` onto
7600        // one of the data-field names (e.g. `tag = "module"`) would
7601        // silently corrupt every serialized LoadModule blob (the
7602        // module string and the variant tag would collide on the same
7603        // JSON key) and every consumer probe would either misread the
7604        // tag or fail to distinguish variants. Pin the disjointness at
7605        // build time. Same cross-axis discipline the sibling
7606        // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
7607        // (36ffe65) established on the outer container's own
7608        // `from`/`instructions` pair.
7609        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7610        // Enumerate every per-variant data-field key across all five
7611        // variants of [`UpgradeInstruction`], routing through the two
7612        // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
7613        // that name the same per-variant data-field JSON keys the
7614        // `variant_fields` reflection in
7615        // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
7616        // per-variant struct-field rebrand (`module` → `component`,
7617        // `script` → `path`) lands as an edit to exactly one const and
7618        // reaches this disjointness pin by construction — the two axes
7619        // (tag-slot key on one side, per-variant data-field keys on the
7620        // other) share one source of truth per axis.
7621        for data_field in [
7622            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7623            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7624        ] {
7625            assert_ne!(
7626                key, data_field,
7627                "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
7628                 must be disjoint from every UpgradeInstruction per-variant \
7629                 data-field key — got tag-key {key:?} colliding with \
7630                 data-field {data_field:?}, which would silently corrupt \
7631                 the internally-tagged serialization",
7632            );
7633        }
7634    }
7635
7636    #[test]
7637    fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
7638        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7639        // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
7640        // data-field JSON key axis: the two
7641        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
7642        // `_SCRIPT`) name the exact per-variant field JSON keys the
7643        // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
7644        // [`UpgradeInstruction`] emits alongside the tag-slot key from the
7645        // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
7646        // const — the `module: String` struct-field on
7647        // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
7648        // struct-field on `StateChange` are promoted to sibling JSON keys
7649        // at the same nesting level as the tag by the internally-tagged
7650        // serialization, and every downstream consumer that navigates the
7651        // serialized instruction blob to reach the payload (the caixa-core
7652        // reflection round-trip in `dispatcher_registration.rs` that
7653        // consults `variant_fields`, the sibling disjointness pin below,
7654        // any future wasm-operator upgrade-dispatch step consuming the
7655        // serialized instruction blob to route the per-module load /
7656        // soft-purge / purge action or the per-script state-change action)
7657        // reads through the same `&'static str`. Serialize one Module-
7658        // bearing variant and one Script-bearing variant, then pin that
7659        // each const's byte-sequence appears verbatim in the JSON emission
7660        // — a future accidental struct-field rebrand (`module: String` →
7661        // `component: String`, `script: PathBuf` → `path: PathBuf`) at
7662        // either variant surfaces here as a build-time test failure at
7663        // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
7664        // returning `None` far from the field-name drift's commit.
7665        //
7666        // Same "one canonical byte-string per typed axis" discipline the
7667        // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
7668        // pin established on the peer tag-slot key axis on the same
7669        // enum — this pin extends the discipline onto the per-variant
7670        // data-field key axis, completing the `:upgrade-from :instructions`
7671        // variant-JSON dual (tag key + tag values + per-variant field keys)
7672        // fully into caixa-core.
7673        let module_sample = UpgradeInstruction::LoadModule {
7674            module: "hello-rio".into(),
7675        };
7676        let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
7677        assert_eq!(
7678            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
7679                .and_then(|k| k.as_str()),
7680            Some("hello-rio"),
7681            "serialized {module_sample:?} must carry the lifted \
7682             M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
7683             ({:?}) verbatim as the data-field JSON key holding the \
7684             module string (got: {v})",
7685            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7686        );
7687
7688        let script_sample = UpgradeInstruction::StateChange {
7689            script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7690        };
7691        let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
7692        assert_eq!(
7693            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
7694                .and_then(|k| k.as_str()),
7695            Some("lib/migrations/v01-to-v02.lisp"),
7696            "serialized {script_sample:?} must carry the lifted \
7697             M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
7698             ({:?}) verbatim as the data-field JSON key holding the \
7699             script path (got: {v})",
7700            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7701        );
7702    }
7703
7704    #[test]
7705    fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
7706        // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
7707        // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7708        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7709        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7710        // whitespace / colons / dots) — the canonical shape a Rust
7711        // struct-field name promoted to a JSON key by serde takes on this
7712        // internally-tagged variant surface, matching the sibling
7713        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
7714        // shape. A future flip to a non-camelCase byte at either const
7715        // (an accidental `rename_all` regime interleave, or a struct-
7716        // field flip like `module` → `module_name`) surfaces here at
7717        // build time. Peer of
7718        // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
7719        // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
7720        // the sibling wire-key axes.
7721        for key in [
7722            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7723            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7724        ] {
7725            assert!(
7726                !key.is_empty(),
7727                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
7728            );
7729            let first = key.chars().next().unwrap();
7730            assert!(
7731                first.is_ascii_lowercase(),
7732                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
7733                 byte (got {key:?}, leads with {first:?})",
7734            );
7735            assert!(
7736                key.chars().all(|c| c.is_ascii_alphanumeric()),
7737                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
7738                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7739            );
7740        }
7741    }
7742
7743    #[test]
7744    fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
7745        // Cross-axis drift-detection pin: a future collapse of the two
7746        // canonical per-variant data-field byte-strings onto the same
7747        // value (e.g. an accidental copy-paste flip of
7748        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
7749        // `"module"`) would silently reroute every test-side probe on one
7750        // variant's payload onto the sibling variant's payload and pass
7751        // every propagation-probe test that expected only the stale
7752        // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
7753        // on the sibling per-entry outer-container axis, and of
7754        // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
7755        // on the sibling tag-slot key ↔ per-variant data-field key axis.
7756        let all = [
7757            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7758            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7759        ];
7760        for (i, a) in all.iter().enumerate() {
7761            for b in all.iter().skip(i + 1) {
7762                assert_ne!(
7763                    a, b,
7764                    "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
7765                     canonical byte-sequences — got `{a}` == `{b}`",
7766                );
7767            }
7768        }
7769    }
7770
7771    #[test]
7772    fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
7773        // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
7774        // lowerCamelCase byte-sequence (no `snake_case` underscores, no
7775        // `kebab-case` hyphens, no `PascalCase` leading capital, no
7776        // whitespace / colons / dots) — the canonical shape the
7777        // `#[serde(rename_all = "camelCase")]` derive produces on
7778        // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
7779        // at the derive surfaces both here (this test fails on the
7780        // stale-constant shape) and at
7781        // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7782        // (that test fails on the mismatch between const and derive).
7783        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
7784        // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
7785        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7786        for key in [
7787            crate::render::M2_UPGRADE_FROM_KEY_FROM,
7788            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7789        ] {
7790            assert!(
7791                !key.is_empty(),
7792                "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
7793            );
7794            let first = key.chars().next().unwrap();
7795            assert!(
7796                first.is_ascii_lowercase(),
7797                "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
7798                 byte (got {key:?}, leads with {first:?})",
7799            );
7800            assert!(
7801                key.chars().all(|c| c.is_ascii_alphanumeric()),
7802                "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
7803                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7804            );
7805        }
7806    }
7807
7808    #[test]
7809    fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
7810        // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
7811        // OTP-appup variant-tag axis: the five canonical author-facing
7812        // kebab-case labels (`:load-module` / `:state-change` /
7813        // `:soft-purge` / `:purge` / `:restart`) the substrate's
7814        // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
7815        // from and every downstream consumer probes for verbatim. Same
7816        // scalar-value discipline the peer
7817        // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
7818        // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7819        // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7820        // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7821        // (be40492) established for the sibling M2 / M3 / Supervisor
7822        // top-level and sub-slot author-facing-label axes. Fail-before-
7823        // pass-after locally verified by mutating
7824        // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
7825        // pin fires as expected; restoring passes.
7826        //
7827        // A future OTP-lineage per-variant rebrand (e.g.
7828        // `:load-module` → `:load` matching Erlang's abbreviated
7829        // `code:load_module` name, `:state-change` → `:code-change`
7830        // matching Erlang's verbatim `code_change/3` callback,
7831        // `:soft-purge` → `:drain` matching a hypothetical operator-side
7832        // vocabulary flip, `:purge` → `:discard` matching a hypothetical
7833        // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
7834        // matching a supervisor-tree vocabulary alignment) lands as an
7835        // edit to exactly one const, and every consumer that reaches for
7836        // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
7837        // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
7838        // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
7839        // `prior_cleanup_kind:` diagnostic field, the
7840        // [`LayoutError::UpgradeViolation`] `issue:` probe in
7841        // `layout.rs`) picks it up at build time rather than at runtime
7842        // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
7843        // far from the rename's commit.
7844        assert_eq!(
7845            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7846            ":load-module"
7847        );
7848        assert_eq!(
7849            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7850            ":state-change"
7851        );
7852        assert_eq!(
7853            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7854            ":soft-purge"
7855        );
7856        assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
7857        assert_eq!(
7858            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7859            ":restart"
7860        );
7861    }
7862
7863    #[test]
7864    fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
7865        // Cross-arm drift-detection pin on the M2
7866        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
7867        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
7868        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
7869        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
7870        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
7871        // closed-set OTP-appup variant-tag pentad: a future collapse
7872        // of two canonical variant byte-strings onto the same value
7873        // (an accidental copy-paste flip of
7874        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7875        // to also read `":purge"`, a per-arm rebrand that lands one
7876        // const without touching its paired peer) would silently
7877        // reroute every downstream OTP-appup dispatcher's per-
7878        // instruction branch onto the sibling arm's runtime
7879        // behavior and pass every propagation-probe test that
7880        // expected only the stale arm's tag — a `:soft-purge`
7881        // instruction (drain-then-swap: existing callers finish
7882        // under the old module, new callers land on the new one)
7883        // would come up under the `:purge` reconcile branch
7884        // (drop-existing: every in-flight caller terminates
7885        // immediately) on every hot-upgrade cycle, so a rolling
7886        // module swap would silently downgrade to a hard cutover
7887        // against its declared appup discipline, with no field
7888        // naming the instruction-tag drift root cause. Every
7889        // [`crate::UpgradeError`] diagnostic that surfaces the tag
7890        // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
7891        // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
7892        // slice, [`crate::UpgradeError::CleanupPrecedes`] with
7893        // `prior_cleanup_kind:` field, the
7894        // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
7895        // `layout.rs`) would emit the sibling arm's stale bytes at
7896        // the operator's console, far from the source rebrand
7897        // commit. Peer of the sibling
7898        // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
7899        // (09ffb2d) /
7900        // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
7901        // (ccdf955) /
7902        // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
7903        // (d739850) distinctness pins on the sibling OTP-shape /
7904        // caixa-kind closed-set typed-enum discriminator axes —
7905        // the fifth closed-set OTP-appup / typed-enum axis to
7906        // converge on the same
7907        // "pairwise-distinct-by-construction" discipline, and the
7908        // canonical companion to the peer
7909        // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
7910        // (ff980bb) distinctness pin on the sibling internally-
7911        // tagged-JSON per-variant data-field-key axis (the tag axis
7912        // this pin covers vs. the data-field-key axis its peer
7913        // covers — two paired axes on the same
7914        // [`crate::UpgradeInstruction`] typed enum surface).
7915        //
7916        // Fail-before-pass-after locally verified by mutating
7917        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7918        // to also read `":purge"` — this pin fires as expected;
7919        // restoring passes.
7920        let all = [
7921            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7922            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7923            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7924            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7925            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7926        ];
7927        for (i, a) in all.iter().enumerate() {
7928            for (j, b) in all.iter().enumerate() {
7929                if i != j {
7930                    assert_ne!(
7931                        a, b,
7932                        "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
7933                         distinct — got duplicate {a:?} at indices {i} and {j}",
7934                    );
7935                }
7936            }
7937        }
7938    }
7939
7940    #[test]
7941    fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
7942        // Production-through-const pin: the five per-variant labels
7943        // [`UpgradeInstruction::lisp_form`] returns route through the
7944        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
7945        // so a future rebrand that reaches the const but not the
7946        // dispatch (or vice versa) surfaces here at build time rather
7947        // than at runtime as a downstream
7948        // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
7949        // diagnostic drift far from the rename's commit. Mirror of the
7950        // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
7951        // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
7952        // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
7953        // (f49c8b0) production-through-const pins on the sibling M3 /
7954        // M2 top-level slot axes.
7955        //
7956        // Fail-before-pass-after locally verified by mutating
7957        // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
7958        // `":purge-drift"` — this pin fires as expected; restoring
7959        // passes.
7960        let cases: &[(UpgradeInstruction, &'static str)] = &[
7961            (
7962                UpgradeInstruction::LoadModule { module: "x".into() },
7963                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7964            ),
7965            (
7966                UpgradeInstruction::StateChange {
7967                    script: PathBuf::from("lib/m.lisp"),
7968                },
7969                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7970            ),
7971            (
7972                UpgradeInstruction::SoftPurge {
7973                    module: "x-old".into(),
7974                },
7975                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7976            ),
7977            (
7978                UpgradeInstruction::Purge {
7979                    module: "x-old".into(),
7980                },
7981                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7982            ),
7983            (
7984                UpgradeInstruction::Restart,
7985                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7986            ),
7987        ];
7988        for (instr, expected) in cases {
7989            assert_eq!(
7990                instr.lisp_form(),
7991                *expected,
7992                "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
7993                 const (expected {expected:?})",
7994            );
7995        }
7996    }
7997
7998    #[test]
7999    fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8000        // The canonical per-`:upgrade-from :instructions` OTP-appup
8001        // migration-instruction-list slice-shape pin:
8002        // [`UpgradeFromEntry::instructions`] must return the
8003        // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8004        // a `&[UpgradeInstruction]` slice-view over the same backing
8005        // buffer the raw `self.instructions.as_slice()` field access
8006        // borrows from, byte-equal across every representative fixture
8007        // in the accept-set — the empty slice (the "no-op upgrade" /
8008        // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8009        // field's own docstring names), the singleton slice on every
8010        // variant of the [`UpgradeInstruction`] arm-space
8011        // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8012        // `Restart` — the five OTP-appup runtime-primitive variants),
8013        // and multi-instruction cohorts (the canonical
8014        // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8015        // load + state-migration triad the module doc names as the
8016        // "runs the instructions in order" example).
8017        //
8018        // Pins against a future silent detour that returned
8019        // `&Vec<UpgradeInstruction>` (which would type-check but leak
8020        // the storage-side `Vec`'s grow/push/reserve surface no
8021        // consumer of the typed view reaches for), a fresh-allocated
8022        // `Vec<UpgradeInstruction>` copy (which would type-check via
8023        // a coercion but silently break every downstream caller that
8024        // relied on the slice sharing the backing buffer's identity),
8025        // or an out-of-order or length-drifted projection (which
8026        // would silently split the paired within-entry cross-
8027        // instruction ordering gates' inputs from the peer per-
8028        // instruction shape-check loop's input, one seven-gate cohort
8029        // silently drifting from the peer gate's actual traversal
8030        // input).
8031        //
8032        // Peer of the sibling
8033        // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8034        // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8035        // `:contratos` edge-list axis, extended onto the M2 per-
8036        // `:upgrade-from :instructions` migration-instruction-list
8037        // axis — the fifth `&[T]`-return byte-equal pin, closing the
8038        // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8039        let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8040            Vec::new(),
8041            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8042            vec![UpgradeInstruction::StateChange {
8043                script: PathBuf::from("lib/m.lisp"),
8044            }],
8045            vec![UpgradeInstruction::SoftPurge {
8046                module: "x-old".into(),
8047            }],
8048            vec![UpgradeInstruction::Purge {
8049                module: "x-old".into(),
8050            }],
8051            vec![UpgradeInstruction::Restart],
8052            vec![
8053                UpgradeInstruction::LoadModule { module: "x".into() },
8054                UpgradeInstruction::StateChange {
8055                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8056                },
8057                UpgradeInstruction::SoftPurge {
8058                    module: "x-old".into(),
8059                },
8060            ],
8061        ];
8062        for instructions in fixtures {
8063            let e = UpgradeFromEntry {
8064                from: "0.1.0".into(),
8065                instructions: instructions.clone(),
8066            };
8067            assert_eq!(
8068                e.instructions(),
8069                e.instructions.as_slice(),
8070                "UpgradeFromEntry::instructions must project the raw \
8071                 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8072                 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8073                 (fixture: {instructions:?})",
8074            );
8075            assert_eq!(
8076                e.instructions().len(),
8077                instructions.len(),
8078                "UpgradeFromEntry::instructions length must match the raw \
8079                 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8080            );
8081        }
8082    }
8083
8084    #[test]
8085    fn validate_reads_through_lifted_instructions_accessor() {
8086        // Three-consumer coherence pin on the lifted
8087        // [`UpgradeFromEntry::instructions`] slice-return accessor:
8088        // exercises three of the nine paired production consumers of
8089        // the per-`:upgrade-from :instructions` OTP-appup migration-
8090        // instruction-list surface through end-to-end validate() paths
8091        // that require the accessor to reach each of the fixture's
8092        // instructions.
8093        //
8094        // (1) The per-instruction shape-check fan-out
8095        // ([`UpgradeFromEntry::validate`]'s `for instr in
8096        // self.instructions()` loop): pass the well-formed load →
8097        // state-change → soft-purge triad — `validate()` must accept
8098        // it, which requires the accessor to project every entry so
8099        // each `instr.validate()` fires.
8100        //
8101        // (2) The within-entry state-change-ordering gate
8102        // ([`Self::validate_state_change_ordering`]): pass a
8103        // `((:state-change …))` singleton — `validate()` must return
8104        // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8105        // requires the accessor to reach the state-change so the
8106        // no-prior-load probe fires.
8107        //
8108        // (3) The within-entry per-module cleanup-singularity gate
8109        // ([`Self::validate_cleanup_singularity`]): pass a
8110        // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8111        // "x-old"))` cohort — `validate()` must return
8112        // [`UpgradeError::DuplicateCleanup`], which requires the
8113        // accessor to iterate the whole list so the second `SoftPurge`
8114        // matches the first via the `seen` set.
8115        //
8116        // Peer of the sibling
8117        // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8118        // three-consumer coherence pin on the M3 per-`:contratos`
8119        // edge-list axis, extended onto the M2 per-`:upgrade-from
8120        // :instructions` migration-instruction-list axis.
8121
8122        // (1) accept the well-formed OTP two-phase code-load triad
8123        let well_formed = entry(
8124            "0.1.0",
8125            vec![
8126                UpgradeInstruction::LoadModule { module: "x".into() },
8127                UpgradeInstruction::StateChange {
8128                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8129                },
8130                UpgradeInstruction::SoftPurge {
8131                    module: "x-old".into(),
8132                },
8133            ],
8134        );
8135        assert!(
8136            well_formed.validate().is_ok(),
8137            "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8138             the per-instruction shape-check fan-out requires the accessor to reach every entry"
8139        );
8140
8141        // (2) refuse a `((:state-change …))` singleton — the
8142        // state-change-without-prior-load gate must fire, which
8143        // requires the accessor to reach the single instruction.
8144        let no_prior_load = entry(
8145            "0.1.0",
8146            vec![UpgradeInstruction::StateChange {
8147                script: PathBuf::from("lib/m.lisp"),
8148            }],
8149        );
8150        match no_prior_load.validate() {
8151            Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8152            other => panic!(
8153                "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8154                 — the within-entry state-change-ordering gate must reach the single \
8155                 instruction through the lifted accessor; got: {other:?}"
8156            ),
8157        }
8158
8159        // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8160        // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8161        // singularity gate must fire on the second `SoftPurge`, which
8162        // requires the accessor to iterate the whole list.
8163        let duplicate_cleanup = entry(
8164            "0.1.0",
8165            vec![
8166                UpgradeInstruction::LoadModule { module: "x".into() },
8167                UpgradeInstruction::SoftPurge {
8168                    module: "x-old".into(),
8169                },
8170                UpgradeInstruction::SoftPurge {
8171                    module: "x-old".into(),
8172                },
8173            ],
8174        );
8175        match duplicate_cleanup.validate() {
8176            Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8177                assert_eq!(
8178                    module, "x-old",
8179                    "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8180                     cleanup-singularity gate must iterate through the lifted accessor to \
8181                     match the second SoftPurge against the first via the `seen` set"
8182                );
8183            }
8184            other => panic!(
8185                "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8186                 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8187                 iterate the whole list through the lifted accessor; got: {other:?}"
8188            ),
8189        }
8190
8191        // Path::new suppresses the unused-import warning if the
8192        // outer module trims `use std::path::Path;` in a future edit.
8193        let _ = Path::new("lib/m.lisp");
8194    }
8195
8196    // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8197    // macro definition (see the paired doc-block above the macro
8198    // definition) — every generated `<ctor>(from: &str, script: &Path)
8199    // -> Self` constructor folds the uniform `Self::<Variant> { from:
8200    // from.to_string(), script: script.to_path_buf() }` two-field
8201    // struct-literal onto one substrate primitive. The three per-variant
8202    // equivalence pins below (fail-before-pass-after by construction — a
8203    // byte-mismatched macro arm would trip its equivalence pin first)
8204    // lock each generated constructor to its struct-literal peer under
8205    // `PartialEq`, so every wire-up in
8206    // [`UpgradeFromEntry::validate_state_change_ordering`],
8207    // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8208    // [`validate_state_change_on_state_change_callback`] on that
8209    // variant produces a byte-equal `UpgradeError` to the pre-lift
8210    // open-coded struct-literal. The cross-axis pin that follows
8211    // (non-default `(from, script)` pair) routes both constructor input
8212    // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8213    // not silently collapse onto a fixed `from` / `script` value.
8214    //
8215    // Peer of the sibling `empty_child_version_ctor_matches_struct_
8216    // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8217    // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8218    // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8219    // to_string` equivalence + cross-axis pins the sibling
8220    // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8221    // established on the peer `SupervisorError` envelope; extended
8222    // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8223    // two-slot envelope so every substrate-primitive ctor family in
8224    // caixa-core guarantees the same-shape fold every wire-up on the
8225    // family reads through one dispatch.
8226
8227    #[test]
8228    fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8229        let from = "0.1.0";
8230        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8231        assert_eq!(
8232            UpgradeError::state_change_without_prior_load(from, script),
8233            UpgradeError::StateChangeWithoutPriorLoad {
8234                from: from.to_string(),
8235                script: script.to_path_buf(),
8236            },
8237            "generated state_change_without_prior_load ctor must produce \
8238             byte-equal UpgradeError to the open-coded struct-literal \
8239             wrap on the same (&str, &Path) fixture",
8240        );
8241    }
8242
8243    #[test]
8244    fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
8245        let from = "0.1.0";
8246        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8247        assert_eq!(
8248            UpgradeError::duplicate_state_change(from, script),
8249            UpgradeError::DuplicateStateChange {
8250                from: from.to_string(),
8251                script: script.to_path_buf(),
8252            },
8253            "generated duplicate_state_change ctor must produce byte-equal \
8254             UpgradeError to the open-coded struct-literal wrap on the \
8255             same (&str, &Path) fixture",
8256        );
8257    }
8258
8259    #[test]
8260    fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
8261        let from = "0.1.0";
8262        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8263        assert_eq!(
8264            UpgradeError::state_change_without_on_state_change_callback(from, script),
8265            UpgradeError::StateChangeWithoutOnStateChangeCallback {
8266                from: from.to_string(),
8267                script: script.to_path_buf(),
8268            },
8269            "generated state_change_without_on_state_change_callback ctor \
8270             must produce byte-equal UpgradeError to the open-coded \
8271             struct-literal wrap on the same (&str, &Path) fixture",
8272        );
8273    }
8274
8275    #[test]
8276    fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
8277        // Cross-axis pin: sweep both constructor input axes (`from:
8278        // &str`, `script: &Path`) through non-default fixtures against
8279        // every generated arm in the [`upgrade_from_script_ctors!`]
8280        // macro, so any wrapper-side lowercase / trim / truncate /
8281        // re-order / fixed-path substitution on the two-field
8282        // construction surfaces here rather than at a downstream
8283        // diagnostic-shape mismatch. Also exercises the `&Path`
8284        // parameter under both `&Path` (direct `Path::new`) and
8285        // `&PathBuf` (via Deref coercion), matching the two shapes the
8286        // three wire-up sites thread through — the ordering /
8287        // callback-declaration gates hand a `&PathBuf` from
8288        // `instr.declared_path()`; the uniqueness gate hands a `&Path`
8289        // from `script.as_path()`. Peer of the sibling
8290        // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
8291        // cross-axis pin on the peer `SupervisorError` `{ caixa:
8292        // String }` envelope.
8293        let from = "1.2.3-rc.1";
8294        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8295        let script_ref: &Path = script_owned.as_path();
8296        for script in [script_ref, &script_owned as &Path] {
8297            assert_eq!(
8298                UpgradeError::state_change_without_prior_load(from, script),
8299                UpgradeError::StateChangeWithoutPriorLoad {
8300                    from: from.to_string(),
8301                    script: script.to_path_buf(),
8302                },
8303            );
8304            assert_eq!(
8305                UpgradeError::duplicate_state_change(from, script),
8306                UpgradeError::DuplicateStateChange {
8307                    from: from.to_string(),
8308                    script: script.to_path_buf(),
8309                },
8310            );
8311            assert_eq!(
8312                UpgradeError::state_change_without_on_state_change_callback(from, script),
8313                UpgradeError::StateChangeWithoutOnStateChangeCallback {
8314                    from: from.to_string(),
8315                    script: script.to_path_buf(),
8316                },
8317            );
8318        }
8319    }
8320
8321    // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
8322    // macro definition (see the paired doc-block above the macro
8323    // definition) — every generated `<ctor>(script: &Path) -> Self`
8324    // constructor folds the uniform `Self::<Variant> { script:
8325    // script.to_path_buf() }` one-field struct-literal onto one substrate
8326    // primitive. The three per-variant equivalence pins below
8327    // (fail-before-pass-after by construction — a byte-mismatched macro
8328    // arm would trip its equivalence pin first) lock each generated
8329    // constructor to its struct-literal peer under `PartialEq`, so every
8330    // closure passed to [`crate::render::require_sandboxed_lisp_path`]
8331    // at [`UpgradeInstruction::validate`] on that variant produces a
8332    // byte-equal `UpgradeError` to the pre-lift open-coded
8333    // struct-literal. The cross-axis pin that follows (non-default
8334    // `script` path, both `&Path` and `&PathBuf` shapes) routes the
8335    // constructor input axis through `.to_path_buf()`, so the fold does
8336    // not silently collapse onto a fixed `script` value or drop the
8337    // Deref-coercion arm the wire-up sites depend on.
8338    //
8339    // Peer of the sibling
8340    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
8341    // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
8342    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
8343    // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
8344    // equivalence + cross-axis pins the sibling
8345    // [`upgrade_from_script_ctors!`] family (8e67041) established on the
8346    // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
8347    // extended here onto the `{ script: PathBuf }` one-slot envelope
8348    // shape so every substrate-primitive ctor family on `UpgradeError`
8349    // guarantees the same-shape fold every wire-up on the family reads
8350    // through one dispatch.
8351
8352    #[test]
8353    fn absolute_script_ctor_matches_struct_literal_wrap() {
8354        let script = Path::new("/etc/nope.lisp");
8355        assert_eq!(
8356            UpgradeError::absolute_script(script),
8357            UpgradeError::AbsoluteScript {
8358                script: script.to_path_buf(),
8359            },
8360            "generated absolute_script ctor must produce byte-equal \
8361             UpgradeError to the open-coded struct-literal wrap on the \
8362             same &Path fixture",
8363        );
8364    }
8365
8366    #[test]
8367    fn parent_escape_script_ctor_matches_struct_literal_wrap() {
8368        let script = Path::new("../oops.lisp");
8369        assert_eq!(
8370            UpgradeError::parent_escape_script(script),
8371            UpgradeError::ParentEscapeScript {
8372                script: script.to_path_buf(),
8373            },
8374            "generated parent_escape_script ctor must produce byte-equal \
8375             UpgradeError to the open-coded struct-literal wrap on the \
8376             same &Path fixture",
8377        );
8378    }
8379
8380    #[test]
8381    fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8382        let script = Path::new("lib/migrations.rs");
8383        assert_eq!(
8384            UpgradeError::non_lisp_extension_script(script),
8385            UpgradeError::NonLispExtensionScript {
8386                script: script.to_path_buf(),
8387            },
8388            "generated non_lisp_extension_script ctor must produce \
8389             byte-equal UpgradeError to the open-coded struct-literal \
8390             wrap on the same &Path fixture",
8391        );
8392    }
8393
8394    #[test]
8395    fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8396        // Cross-axis pin: sweep the constructor input axis (`script:
8397        // &Path`) through a non-default fixture against every generated
8398        // arm in the [`upgrade_script_only_ctors!`] macro, so any
8399        // wrapper-side lowercase / trim / truncate / re-order /
8400        // fixed-path substitution on the one-field construction
8401        // surfaces here rather than at a downstream diagnostic-shape
8402        // mismatch. Also exercises the `&Path` parameter under both
8403        // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
8404        // coercion), matching the shape the three closures at
8405        // [`UpgradeInstruction::validate`] thread through — the
8406        // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
8407        // each closure, so the Deref-coercion arm the ctor advertises
8408        // must actually route through `.to_path_buf()` and not
8409        // silently swap in a fixed path.
8410        //
8411        // Peer of the sibling
8412        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8413        // cross-axis pin on the sibling `{ from, script }` two-slot
8414        // envelope shape.
8415        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8416        let script_ref: &Path = script_owned.as_path();
8417        for script in [script_ref, &script_owned as &Path] {
8418            assert_eq!(
8419                UpgradeError::absolute_script(script),
8420                UpgradeError::AbsoluteScript {
8421                    script: script.to_path_buf(),
8422                },
8423            );
8424            assert_eq!(
8425                UpgradeError::parent_escape_script(script),
8426                UpgradeError::ParentEscapeScript {
8427                    script: script.to_path_buf(),
8428                },
8429            );
8430            assert_eq!(
8431                UpgradeError::non_lisp_extension_script(script),
8432                UpgradeError::NonLispExtensionScript {
8433                    script: script.to_path_buf(),
8434                },
8435            );
8436        }
8437    }
8438
8439    // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
8440    // macro definition (see the paired doc-block above the macro
8441    // definition) — every generated `<ctor>(from: &str, <axis>: &str)
8442    // -> Self` constructor folds the uniform `Self::<Variant> { from:
8443    // from.to_string(), <axis>: <axis>.to_string() }` two-field
8444    // struct-literal onto one substrate primitive. The three per-variant
8445    // equivalence pins below (fail-before-pass-after by construction — a
8446    // byte-mismatched macro arm would trip its equivalence pin first)
8447    // lock each generated constructor to its struct-literal peer under
8448    // `PartialEq`, so every wire-up in
8449    // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
8450    // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
8451    // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
8452    // `:from < :versao` gate on that variant produces a byte-equal
8453    // `UpgradeError` to the pre-lift open-coded struct-literal. The
8454    // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
8455    // pair) routes both constructor input axes through `.to_string()`
8456    // in declared field order, so the fold does not silently swap `from`
8457    // and the middle `<axis>` field, or silently collapse onto a fixed
8458    // `from` / `<axis>` value on any one variant.
8459    //
8460    // Peer of the sibling `state_change_without_prior_load_ctor_matches_
8461    // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
8462    // struct_literal_wrap` / `state_change_without_on_state_change_
8463    // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
8464    // ctors_route_from_and_script_verbatim` equivalence + cross-axis
8465    // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
8466    // established on the sibling `{ from: String, script: PathBuf }`
8467    // two-slot envelope shape; extended here onto the `{ from: String,
8468    // <axis>: String }` two-slot envelope shape so every substrate-
8469    // primitive ctor family on `UpgradeError` guarantees the same-shape
8470    // fold every wire-up on the family reads through one dispatch. Also
8471    // mirror-symmetric peer of the sibling
8472    // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8473    // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
8474    // <axis>: String }` two-slot envelope shape.
8475
8476    #[test]
8477    fn from_invalid_ctor_matches_struct_literal_wrap() {
8478        let from = "not-a-semver";
8479        let reason = "unexpected character '-' at position 3";
8480        assert_eq!(
8481            UpgradeError::from_invalid(from, reason),
8482            UpgradeError::FromInvalid {
8483                from: from.to_string(),
8484                reason: reason.to_string(),
8485            },
8486            "generated from_invalid ctor must produce byte-equal \
8487             UpgradeError to the open-coded struct-literal wrap on the \
8488             same (&str, &str) fixture",
8489        );
8490    }
8491
8492    #[test]
8493    fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
8494        let from = "0.2.0";
8495        let versao = "0.1.0";
8496        assert_eq!(
8497            UpgradeError::from_not_before_versao(from, versao),
8498            UpgradeError::FromNotBeforeVersao {
8499                from: from.to_string(),
8500                versao: versao.to_string(),
8501            },
8502            "generated from_not_before_versao ctor must produce byte-equal \
8503             UpgradeError to the open-coded struct-literal wrap on the \
8504             same (&str, &str) fixture",
8505        );
8506    }
8507
8508    #[test]
8509    fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
8510        let from = "0.1.0";
8511        let module = "hello-rio";
8512        assert_eq!(
8513            UpgradeError::duplicate_load_module(from, module),
8514            UpgradeError::DuplicateLoadModule {
8515                from: from.to_string(),
8516                module: module.to_string(),
8517            },
8518            "generated duplicate_load_module ctor must produce byte-equal \
8519             UpgradeError to the open-coded struct-literal wrap on the \
8520             same (&str, &str) fixture",
8521        );
8522    }
8523
8524    #[test]
8525    fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
8526        // Cross-axis routing pin: sweep the two constructor input axes
8527        // (`from: &str`, `<axis>: &str`) through distinct-per-axis
8528        // fixtures against every generated arm in the
8529        // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
8530        // lowercase / trim / truncate at codegen time — a silent field
8531        // swap between `from` and the middle `<axis>` field, or a
8532        // `<axis>` axis silently rerouted through the wrong field on any
8533        // one variant — surfaces here rather than at a downstream
8534        // diagnostic-shape mismatch. Peer of the sibling
8535        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8536        // (8e67041) cross-axis pin on the same envelope's sibling
8537        // `{ from: String, script: PathBuf }` two-slot family, and of the
8538        // sibling
8539        // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8540        // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
8541        // String, <axis>: String }` two-slot envelope. Distinct-per-
8542        // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
8543        // that would still pass a same-fixture-per-axis pin. Both
8544        // `&str`-literal and `&String` (via Deref coercion) carriers
8545        // are exercised because the three wire-up sites hand a mix of
8546        // both (the `from_invalid` site hands `&e.to_string()` — an
8547        // owned `String` — for `reason`; the `duplicate_load_module`
8548        // site hands a `&str` slice for `module`; the
8549        // `from_not_before_versao` site hands the caller-supplied
8550        // `versao: &str` for `versao`).
8551        let from = "0.1.0";
8552        let axis = "distinct-axis-value";
8553        let from_owned: String = from.to_string();
8554        let axis_owned: String = axis.to_string();
8555        for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
8556            assert_eq!(
8557                UpgradeError::from_invalid(from_in, axis_in),
8558                UpgradeError::FromInvalid {
8559                    from: from.to_string(),
8560                    reason: axis.to_string(),
8561                },
8562                "from_invalid must route `from` → `from`, `axis` → `reason` \
8563                 in declared field order",
8564            );
8565            assert_eq!(
8566                UpgradeError::from_not_before_versao(from_in, axis_in),
8567                UpgradeError::FromNotBeforeVersao {
8568                    from: from.to_string(),
8569                    versao: axis.to_string(),
8570                },
8571                "from_not_before_versao must route `from` → `from`, \
8572                 `axis` → `versao` in declared field order",
8573            );
8574            assert_eq!(
8575                UpgradeError::duplicate_load_module(from_in, axis_in),
8576                UpgradeError::DuplicateLoadModule {
8577                    from: from.to_string(),
8578                    module: axis.to_string(),
8579                },
8580                "duplicate_load_module must route `from` → `from`, \
8581                 `axis` → `module` in declared field order",
8582            );
8583        }
8584    }
8585
8586    // Per-variant equivalence + accessor-fidelity + cross-axis pins for
8587    // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
8588    // the paired doc-block above the ctor definition) — the fold of the
8589    // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
8590    // struct-literal inside [`validate_upgrade_from`]'s cross-entry
8591    // duplicate gate onto one substrate primitive on the
8592    // [`UpgradeError`] envelope, projecting through the paired
8593    // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
8594    // primitive. A byte-mismatched ctor body would trip the equivalence
8595    // pin first, ahead of any downstream diagnostic-shape drift.
8596    //
8597    // Peer of the sibling standalone-ctor equivalence pins on the peer
8598    // one-off variants across caixa-core:
8599    // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
8600    // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
8601    // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
8602    // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
8603    // envelope, the sibling
8604    // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
8605    // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
8606    // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
8607    // pin on the sibling standalone `{ host, reason }` two-slot ctor.
8608
8609    #[test]
8610    fn duplicate_from_ctor_matches_struct_literal_wrap() {
8611        // Equivalence pin: the ctor produces byte-equal
8612        // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
8613        // struct-literal that read the same `from` field through
8614        // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
8615        // addition / reordering / string-conversion tweak on the
8616        // variant. Same equivalence-pin shape as the sibling
8617        // `contrato_self_loop_ctor_matches_struct_literal_wrap`
8618        // (b30edfe) on the paired two-slot `{ caixa, wit }`
8619        // envelope inside `impl AplicacaoSpec`.
8620        let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
8621        let lifted = UpgradeError::duplicate_from(&entry);
8622        let struct_literal = UpgradeError::DuplicateFrom {
8623            from: entry.prior_versao().to_string(),
8624        };
8625        assert_eq!(lifted, struct_literal);
8626    }
8627
8628    #[test]
8629    fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
8630        // Routing pin sweeping a non-default `:from` value
8631        // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
8632        // release and build metadata) through the paired
8633        // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
8634        // wrapper-side lowercase / trim / truncate on the one-field
8635        // construction surfaces here rather than at a downstream
8636        // diagnostic-shape drift. Peer of the sibling
8637        // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
8638        // (b30edfe) routing pin on the sibling two-slot envelope.
8639        //
8640        // The pre-release + build-metadata carrier value is deliberately
8641        // chosen to exercise the `.to_string()` path against a `:from`
8642        // shape [`semver::Version::PartialEq`] treats as distinct from
8643        // its release-only sibling (per the
8644        // `validate_upgrade_from_treats_pre_release_as_distinct` and
8645        // build-metadata-tightening-note doc-block on
8646        // [`validate_upgrade_from`]) — so any silent normalization at
8647        // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
8648        // `.split_once('-')` collapse) would drop bytes from the
8649        // rendered diagnostic and surface here.
8650        let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
8651        let built = UpgradeError::duplicate_from(&entry);
8652        match built {
8653            UpgradeError::DuplicateFrom { from } => {
8654                assert_eq!(
8655                    from, "1.2.3-rc.4+build.5",
8656                    "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
8657                     preserving pre-release + build-metadata bytes"
8658                );
8659            }
8660            other => panic!("expected DuplicateFrom, got {other:?}"),
8661        }
8662    }
8663
8664    #[test]
8665    fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
8666        // Accessor-fidelity pin: the ctor's `from` slot keys off the
8667        // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
8668        // the pre-lift open-coded body's field selection), not any
8669        // stringified rendering of the full entry (e.g. the
8670        // `impl Display for UpgradeFromEntry` output, if one were later
8671        // added, or a `format!("{:?}", entry)` debug dump). Pins the
8672        // projection axis so a silent swap at the ctor body — say, a
8673        // future refactor that projects through `entry.instructions()`
8674        // in shape (dropping the `:from` axis entirely) or through a
8675        // whole-entry `format!` — surfaces here rather than at a
8676        // downstream diagnostic mis-attribution far from the duplicate
8677        // gate's owner.
8678        //
8679        // A future consumer that constructs the ctor against a not-yet-
8680        // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
8681        // CR admission webhook re-checking a per-`:upgrade-from`-patched
8682        // candidate before the cross-entry duplicate gate re-fires, a
8683        // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
8684        // `(:from …)` introduced by a cluster-local `:upgrade-from`
8685        // override) needs the pre-lift projection axis pinned.
8686        //
8687        // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
8688        // paired with a distinctive multi-instruction sequence so a
8689        // silent swap that projects through the whole-entry rendering
8690        // instead of the paired scalar accessor would land debug bytes
8691        // from the `:instructions` list into the `from` slot and trip
8692        // the assertion here.
8693        let entry = entry(
8694            "0.2.0-alpha.7",
8695            vec![
8696                UpgradeInstruction::LoadModule {
8697                    module: "distinctive-load-target".into(),
8698                },
8699                UpgradeInstruction::StateChange {
8700                    script: PathBuf::from("lib/distinctive-migrate.lisp"),
8701                },
8702                UpgradeInstruction::Restart,
8703            ],
8704        );
8705        let built = UpgradeError::duplicate_from(&entry);
8706        match built {
8707            UpgradeError::DuplicateFrom { from } => {
8708                assert_eq!(
8709                    from, "0.2.0-alpha.7",
8710                    "from slot must project UpgradeFromEntry::prior_versao() \
8711                     (not any whole-entry rendering)"
8712                );
8713            }
8714            other => panic!("expected DuplicateFrom, got {other:?}"),
8715        }
8716    }
8717
8718    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
8719    // the standalone [`UpgradeError::purge_without_prior_load`] inherent
8720    // ctor (see the paired doc-block above the ctor definition) — the
8721    // fold of the last open-coded three-slot `{ from: String, kind:
8722    // &'static str, module: String }` struct-literal wire-up on
8723    // [`UpgradeError`] closes the sole in-crate wire-up site inside
8724    // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
8725    // load-family sticky-latch dispatch onto one substrate primitive.
8726    // A byte-mismatched ctor body would trip the equivalence pin first,
8727    // ahead of any downstream diagnostic-shape drift.
8728    //
8729    // Peer of the sibling standalone-ctor equivalence + routing pins on
8730    // the sibling one-off variants across `UpgradeError`
8731    // (`duplicate_from_ctor_matches_struct_literal_wrap` /
8732    // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
8733    // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
8734    // the paired one-slot `{ from: String }` envelope) and across
8735    // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
8736    // literal_wrap` on the paired three-slot `{ de, para, endpoint:
8737    // String }` `AplicacaoError` envelope).
8738
8739    #[test]
8740    fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
8741        // Equivalence pin: the ctor produces byte-equal
8742        // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
8743        // open-coded three-field struct-literal on the same `(&str,
8744        // &'static str, &str)` fixture. Guards any future field-
8745        // addition / reordering / string-conversion tweak on the
8746        // variant. Same equivalence-pin shape as the sibling
8747        // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
8748        // on the peer one-slot `{ from: String }` envelope.
8749        let from = "0.1.0";
8750        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
8751        let module = "hello-rio-old";
8752        assert_eq!(
8753            UpgradeError::purge_without_prior_load(from, kind, module),
8754            UpgradeError::PurgeWithoutPriorLoad {
8755                from: from.to_string(),
8756                kind,
8757                module: module.to_string(),
8758            },
8759            "generated purge_without_prior_load ctor must produce \
8760             byte-equal UpgradeError to the open-coded struct-literal \
8761             wrap on the same (&str, &'static str, &str) fixture",
8762        );
8763    }
8764
8765    #[test]
8766    fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
8767        // Cross-axis routing pin: sweep the three constructor input
8768        // axes (`from: &str`, `kind: &'static str`, `module: &str`)
8769        // through distinct-per-axis fixtures across every cleanup-family
8770        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
8771        // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
8772        // module shapes (leaf, hyphenated, deeply-hyphenated) so any
8773        // wrapper-side lowercase / trim / truncate / silent axis-swap
8774        // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
8775        // three-field construction surfaces at assert time rather than
8776        // at a downstream diagnostic consumer that reads the fields
8777        // back and gets a different value than the one it stored. Both
8778        // `&str`-literal and `&String` (via Deref coercion) carriers
8779        // are exercised for `from` / `module` because the sole wire-up
8780        // hands `self.prior_versao()` (a `&str` accessor) and
8781        // `instr.declared_module().expect(…)` (also a `&str`) — the
8782        // ctor must accept both shapes without a pre-conversion.
8783        let kinds: [&'static str; 2] = [
8784            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8785            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8786        ];
8787        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
8788        let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
8789        for kind in kinds {
8790            for from in froms {
8791                for module in modules {
8792                    let from_owned: String = from.to_string();
8793                    let module_owned: String = module.to_string();
8794                    for (from_in, module_in) in
8795                        [(from, module), (from_owned.as_str(), module_owned.as_str())]
8796                    {
8797                        assert_eq!(
8798                            UpgradeError::purge_without_prior_load(from_in, kind, module_in),
8799                            UpgradeError::PurgeWithoutPriorLoad {
8800                                from: from.to_string(),
8801                                kind,
8802                                module: module.to_string(),
8803                            },
8804                            "purge_without_prior_load must route from → from, \
8805                             kind → kind, module → module in declared field \
8806                             order verbatim on ({from:?}, {kind:?}, {module:?})",
8807                        );
8808                    }
8809                }
8810            }
8811        }
8812    }
8813
8814    #[test]
8815    fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
8816        // End-to-end wire-up pin: build an entry whose declared
8817        // `:instructions` list places a `:soft-purge` (and separately a
8818        // `:purge`) before any `:load-module` so
8819        // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
8820        // sticky-latch dispatch surfaces
8821        // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
8822        // observed `Err` byte-equals the substrate-primitive
8823        // [`UpgradeError::purge_without_prior_load`] ctor's output on
8824        // the same fixture. A future silent de-lift of the wire-up back
8825        // to the open-coded struct-literal (or a silent axis-swap on
8826        // the three-field construction at the wire-up site) trips at
8827        // caixa-core test time rather than at a downstream diagnostic
8828        // consumer far from the wire-up commit. Same end-to-end-wire-up
8829        // discipline as the sibling
8830        // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
8831        // on the peer cross-entry duplicate-`:from` gate; both key off
8832        // exactly one typed dispatch on the substrate primitive.
8833        let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
8834            (
8835                "0.1.0",
8836                UpgradeInstruction::SoftPurge {
8837                    module: "hello-rio-old".into(),
8838                },
8839                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8840                "hello-rio-old",
8841            ),
8842            (
8843                "1.2.3-rc.1",
8844                UpgradeInstruction::Purge {
8845                    module: "cache-v2-ancient".into(),
8846                },
8847                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8848                "cache-v2-ancient",
8849            ),
8850        ];
8851        for (from, instr, kind, module) in cases {
8852            let e = entry(from, vec![instr]);
8853            let observed = e.validate().unwrap_err();
8854            assert_eq!(
8855                observed,
8856                UpgradeError::purge_without_prior_load(from, kind, module),
8857                "validate_purge_ordering must route its refusal through \
8858                 UpgradeError::purge_without_prior_load(from, kind, \
8859                 module) on a bare-cleanup {kind:?} entry, byte-equal \
8860                 to the pre-lift open-coded struct-literal wrap on the \
8861                 same fixture",
8862            );
8863        }
8864    }
8865
8866    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
8867    // the standalone [`UpgradeError::state_change_after_cleanup`]
8868    // inherent ctor (see the paired doc-block above the ctor
8869    // definition) — the fold of the last open-coded four-slot `{ from:
8870    // String, script: PathBuf, prior_cleanup_kind: &'static str,
8871    // prior_cleanup_module: String }` struct-literal wire-up on
8872    // [`UpgradeError`] closes the sole in-crate wire-up site inside
8873    // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
8874    // migrate-family sticky-latch dispatch onto one substrate primitive.
8875    // A byte-mismatched ctor body would trip the equivalence pin first,
8876    // ahead of any downstream diagnostic-shape drift. Peer of the
8877    // sibling standalone-ctor equivalence + routing pins on the sibling
8878    // one-off variants across `UpgradeError`
8879    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
8880    // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
8881    // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
8882    // on the paired three-slot `{ from, kind, module }` envelope;
8883    // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
8884    // one-slot `{ from }` envelope).
8885
8886    #[test]
8887    fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
8888        // Equivalence pin: the ctor produces byte-equal
8889        // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
8890        // open-coded four-field struct-literal on the same `(&str,
8891        // &Path, &'static str, &str)` fixture. Guards any future
8892        // field-addition / reordering / string-conversion tweak on the
8893        // variant. Same equivalence-pin shape as the sibling
8894        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
8895        // (9752da1) on the peer three-slot envelope.
8896        let from = "0.1.0";
8897        let script = Path::new("lib/m.lisp");
8898        let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
8899        let prior_cleanup_module = "x-old";
8900        assert_eq!(
8901            UpgradeError::state_change_after_cleanup(
8902                from,
8903                script,
8904                prior_cleanup_kind,
8905                prior_cleanup_module,
8906            ),
8907            UpgradeError::StateChangeAfterCleanup {
8908                from: from.to_string(),
8909                script: script.to_path_buf(),
8910                prior_cleanup_kind,
8911                prior_cleanup_module: prior_cleanup_module.to_string(),
8912            },
8913            "generated state_change_after_cleanup ctor must produce \
8914             byte-equal UpgradeError to the open-coded struct-literal \
8915             wrap on the same (&str, &Path, &'static str, &str) fixture",
8916        );
8917    }
8918
8919    #[test]
8920    fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
8921        // Cross-axis routing pin: sweep the four constructor input
8922        // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
8923        // &'static str`, `prior_cleanup_module: &str`) through
8924        // distinct-per-axis fixtures across every cleanup-family
8925        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
8926        // SemVer-2 `from` shapes (release, pre-release, pre-release +
8927        // build-metadata, zero), sibling-`.lisp` script-path shapes
8928        // (leaf, nested, deeply-nested), and DNS-1123 module shapes
8929        // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
8930        // lowercase / trim / truncate / silent axis-swap
8931        // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
8932        // `from`, `prior_cleanup_kind` misrouted onto
8933        // `prior_cleanup_module`) on the four-field construction
8934        // surfaces at assert time rather than at a downstream diagnostic
8935        // consumer that reads the fields back and gets a different value
8936        // than the one it stored. Both `&str`-literal and `&String` (via
8937        // Deref coercion) carriers are exercised for `from` /
8938        // `prior_cleanup_module` because the sole wire-up hands
8939        // `self.prior_versao()` (a `&str` accessor) and `prior_module`
8940        // (also `&str`, from `declared_module().expect(…)`) — the ctor
8941        // must accept both shapes without a pre-conversion. Both
8942        // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
8943        // are exercised for `script` because the sole wire-up hands a
8944        // `&PathBuf` sticky-latch projection from `declared_path()`'s
8945        // `Option<&PathBuf>` return — the ctor must accept both shapes
8946        // without a pre-conversion.
8947        let kinds: [&'static str; 2] = [
8948            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8949            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8950        ];
8951        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
8952        let scripts: [&str; 3] = [
8953            "m.lisp",
8954            "lib/migrations.lisp",
8955            "lib/migrations/v01/step-1.lisp",
8956        ];
8957        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
8958        for kind in kinds {
8959            for from in froms {
8960                for script_str in scripts {
8961                    for module in modules {
8962                        let from_owned: String = from.to_string();
8963                        let module_owned: String = module.to_string();
8964                        let script_path = Path::new(script_str);
8965                        let script_pathbuf = PathBuf::from(script_str);
8966                        for (from_in, module_in, script_in) in [
8967                            (from, module, script_path),
8968                            (
8969                                from_owned.as_str(),
8970                                module_owned.as_str(),
8971                                script_pathbuf.as_path(),
8972                            ),
8973                        ] {
8974                            assert_eq!(
8975                                UpgradeError::state_change_after_cleanup(
8976                                    from_in, script_in, kind, module_in,
8977                                ),
8978                                UpgradeError::StateChangeAfterCleanup {
8979                                    from: from.to_string(),
8980                                    script: PathBuf::from(script_str),
8981                                    prior_cleanup_kind: kind,
8982                                    prior_cleanup_module: module.to_string(),
8983                                },
8984                                "state_change_after_cleanup must route from → from, \
8985                                 script → script, prior_cleanup_kind → prior_cleanup_kind, \
8986                                 prior_cleanup_module → prior_cleanup_module in declared \
8987                                 field order verbatim on ({from:?}, {script_str:?}, \
8988                                 {kind:?}, {module:?})",
8989                            );
8990                        }
8991                    }
8992                }
8993            }
8994        }
8995    }
8996
8997    #[test]
8998    fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
8999        // End-to-end wire-up pin: build an entry whose declared
9000        // `:instructions` list places a `:soft-purge` (and separately a
9001        // `:purge`) before a `:state-change` so
9002        // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9003        // migrate-family sticky-latch dispatch surfaces
9004        // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9005        // observed `Err` byte-equals the substrate-primitive
9006        // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9007        // the same fixture. A future silent de-lift of the wire-up back
9008        // to the open-coded struct-literal (or a silent axis-swap on
9009        // the four-field construction at the wire-up site) trips at
9010        // caixa-core test time rather than at a downstream diagnostic
9011        // consumer far from the wire-up commit. Same end-to-end-wire-up
9012        // discipline as the sibling
9013        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9014        // on the peer load → cleanup ordering gate; both key off
9015        // exactly one typed dispatch on the substrate primitive. Every
9016        // entry here front-loads a `:load-module` so the sole surviving
9017        // ordering refusal is the migrate → cleanup one this gate
9018        // owns — the peer `validate_purge_ordering` load → cleanup gate
9019        // returns `Ok(())` on these fixtures, so the migrate-after-
9020        // cleanup arm is the only path to an `Err`.
9021        let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9022            (
9023                "0.1.0",
9024                UpgradeInstruction::SoftPurge {
9025                    module: "hello-rio-old".into(),
9026                },
9027                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9028                "hello-rio-old",
9029                "lib/migrations/v01.lisp",
9030            ),
9031            (
9032                "1.2.3-rc.1",
9033                UpgradeInstruction::Purge {
9034                    module: "cache-v2-ancient".into(),
9035                },
9036                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9037                "cache-v2-ancient",
9038                "lib/migrations/v02.lisp",
9039            ),
9040        ];
9041        for (from, cleanup, kind, module, script_str) in cases {
9042            let script = PathBuf::from(script_str);
9043            let e = entry(
9044                from,
9045                vec![
9046                    UpgradeInstruction::LoadModule {
9047                        module: "hello-rio".into(),
9048                    },
9049                    cleanup,
9050                    UpgradeInstruction::StateChange {
9051                        script: script.clone(),
9052                    },
9053                ],
9054            );
9055            let observed = e.validate().unwrap_err();
9056            assert_eq!(
9057                observed,
9058                UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9059                "validate_state_change_before_cleanup must route its \
9060                 refusal through \
9061                 UpgradeError::state_change_after_cleanup(from, script, \
9062                 prior_cleanup_kind, prior_cleanup_module) on a \
9063                 `:state-change` after a bare-cleanup {kind:?} entry, \
9064                 byte-equal to the pre-lift open-coded struct-literal \
9065                 wrap on the same fixture",
9066            );
9067        }
9068    }
9069}