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