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::restart_not_exclusive(
447            self.prior_versao(),
448            restart_count,
449            other_kinds,
450        ))
451    }
452
453    /// Reject an entry whose `(:state-change …)` is not preceded by a
454    /// `(:load-module …)` in the same `:instructions` list.
455    ///
456    /// `StateChange` is the `gen_server:code_change/3` analog
457    /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
458    /// it runs the migration script that folds the *old* state into the
459    /// shape the *new* code expects. In OTP, `code_change/3` is invoked
460    /// in the context of the newly-loaded code — `release_handler`
461    /// always loads the new module before running the advanced update
462    /// that triggers the callback. caixa decomposes that into two
463    /// explicit instructions (`LoadModule` brings the new version up
464    /// "alongside the current one"; `StateChange` migrates the state),
465    /// and the module doc pins that the operator "runs the instructions
466    /// in order" and only swaps traffic after all succeed. So a
467    /// `:state-change` with no preceding `:load-module` migrates state
468    /// into code that was never loaded — the migration script runs while
469    /// the only resident version is still the *old* one, which expects
470    /// the *old* state. Two authoring footguns close here:
471    ///
472    ///   - `((:state-change "…"))` — the "I wrote the migration but
473    ///     forgot to load the new module" footgun. The new code that
474    ///     defines the new state representation (and that the migration
475    ///     output is destined for) never comes up; the operator runs
476    ///     the script against the old code and either no-ops or corrupts
477    ///     live state.
478    ///   - `((:state-change "…") (:load-module "…"))` — the
479    ///     right-instructions-wrong-order footgun. Because the operator
480    ///     executes in declared order, the migration runs *before* the
481    ///     new code is resident, then the load brings up code expecting
482    ///     already-migrated state that the just-run script produced
483    ///     against the old version's shape. The canonical order is
484    ///     `(:load-module …) (:state-change …) (:soft-purge …)`
485    ///     (module doc example).
486    ///
487    /// Same within-entry cross-instruction discipline as
488    /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
489    /// exclusivity gate it runs beside): both reject an
490    /// `:instructions` list whose instructions are individually
491    /// well-shaped but jointly incoherent, at the typed build surface
492    /// rather than as a runtime surprise. Runs *after*
493    /// `validate_restart_exclusive` so a `((:state-change …)
494    /// (:restart))` shape still surfaces the more-fundamental
495    /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
496    /// alone, so no Restart-bearing entry reaches this gate carrying a
497    /// `StateChange`).
498    fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
499        // Route the per-instruction load-family arm-discriminator through
500        // the `gen_platform::IsVariant`-derive-generated
501        // [`UpgradeInstruction::is_load_module`] predicate and the
502        // per-instruction migration-family `:script` scalar projection
503        // through the sibling lifted [`UpgradeInstruction::declared_path`]
504        // `Option<&PathBuf>` accessor rather than the raw two-arm
505        // `match instr { UpgradeInstruction::LoadModule { .. } =>
506        // loaded = true, UpgradeInstruction::StateChange { script } if
507        // !loaded => …, _ => {} }` open-coded pattern-match — closes the
508        // last unlifted `match`-shaped per-arm-hand-rolled load-family
509        // arm-discriminator + migration-family script-projection pair
510        // inside `impl UpgradeFromEntry`. Sibling of the peer
511        // [`Self::validate_purge_ordering`] (580d0f1) routing already
512        // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
513        // load → cleanup ordering axis, the peer
514        // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
515        // onto the [`UpgradeInstruction::is_load_module`] +
516        // [`UpgradeInstruction::declared_module`] pair on the singularity
517        // axis, and the peer [`Self::validate_state_change_singularity`]
518        // routing already lifted onto the sibling
519        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
520        // accessor on the migration-family script-projection axis — both
521        // ordering-gate load-family sticky-latch dispatches now key off
522        // exactly one typed dispatch on the substrate primitive for
523        // their load-family arm-discriminator, and both migration-family
524        // projection sites (this ordering gate + the peer singularity
525        // gate) now key off exactly one typed dispatch on the substrate
526        // primitive for the `:script`-carrying axis. A future sixth arm
527        // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
528        // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
529        // `CanaryTraffic` split-traffic variant the M4 CR materializer
530        // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
531        // enum-declaration edit through the derive rather than a
532        // coordinated rewrite of every ordering / singularity gate's
533        // per-arm hand-rolled pattern-match. Byte-identity of this
534        // dispatch against the pre-lift `match` shape is pinned by
535        // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
536        let mut loaded = false;
537        for instr in self.instructions() {
538            if instr.is_load_module() {
539                loaded = true;
540            } else if !loaded && let Some(script) = instr.declared_path() {
541                return Err(UpgradeError::state_change_without_prior_load(
542                    self.prior_versao(),
543                    script,
544                ));
545            }
546        }
547        Ok(())
548    }
549
550    /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
551    /// preceded by a `(:load-module …)` in the same `:instructions` list.
552    ///
553    /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
554    /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
555    /// *old* module from memory after the new one is resident. OTP's
556    /// two-phase code load is `code:load_module/1` *then*
557    /// `code:soft_purge/1` — load the new version alongside the old
558    /// (both in memory, new requests route to new), then purge the old
559    /// after in-flight callers drain. caixa decomposes that into two
560    /// explicit instructions (`LoadModule` brings the new version up
561    /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
562    /// doc; `SoftPurge` "waits for in-flight requests on a named module
563    /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
564    /// and the module doc pins that the operator "runs the instructions
565    /// in order". So a `:soft-purge` / `:purge` with no preceding
566    /// `:load-module` purges old code while the only resident version is
567    /// still the *same* old code, leaving the upgrade entry asking the
568    /// operator to drain or discard the live module with no replacement
569    /// resident. Two authoring footguns close here:
570    ///
571    ///   - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
572    ///     cleanup but forgot to load the new module" footgun. The new
573    ///     code never comes up alongside; the operator either drains the
574    ///     old version to nothing (`SoftPurge`) or discards it outright
575    ///     mid-request (`Purge`), with no replacement to route in-flight
576    ///     or future requests to.
577    ///   - `((:soft-purge "…") (:load-module "…"))` /
578    ///     `((:purge "…") (:load-module "…"))` — the right-instructions-
579    ///     wrong-order footgun. Because the operator executes in declared
580    ///     order, the cleanup runs *before* the new code is resident,
581    ///     leaving a window during which neither version is available;
582    ///     the canonical order is `(:load-module …) (:state-change …)
583    ///     (:soft-purge …)` (module doc example).
584    ///
585    /// Same within-entry cross-instruction discipline as
586    /// [`Self::validate_state_change_ordering`] (the `:state-change`-
587    /// ordering gate it runs beside): both close the same load-before-X
588    /// post-condition on the OTP appup ordering contract, now extending
589    /// the typed coverage from "new code resident before its state
590    /// migration runs" to "new code resident before the old code is
591    /// drained or discarded" — the second half of OTP's two-phase code
592    /// load. Runs *after* `validate_state_change_ordering` so an entry
593    /// like `((:state-change …) (:soft-purge …))` surfaces the more-
594    /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
595    /// are load-less, but state-change is the load-bearing semantic — the
596    /// purge is meaningless either way without a preceding load, so the
597    /// author should see the migration-side diagnostic first).
598    fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
599        let mut loaded = false;
600        for instr in self.instructions() {
601            // Route the per-instruction cleanup-family arm-discriminator
602            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
603            // predicate rather than the raw
604            // `UpgradeInstruction::SoftPurge { module } |
605            // UpgradeInstruction::Purge { module }` open-coded per-arm
606            // union pattern-match — the first of three within-entry cross-
607            // instruction cleanup-facing gates now keys off exactly one
608            // typed dispatch on the substrate primitive, so any future
609            // fifth cleanup-shaped variant (a `Discard` variant the
610            // `code:delete/1` peer inspires) added to
611            // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
612            // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
613            // through the accessor's one body. The paired cleanup-arm
614            // `:module` scalar is routed through the sibling
615            // [`UpgradeInstruction::declared_module`] accessor rather than
616            // the raw pattern-bound `module` binding — same substrate-
617            // primitive-owns-the-scalar discipline every peer
618            // per-`UpgradeInstruction` scalar-value axis already routes
619            // through, with the `is_cleanup`-implies-`declared_module`-is-
620            // `Some` composition pin at
621            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
622            // making the `.expect(…)` structurally infallible at build
623            // time. Peer of the sibling
624            // [`UpgradeFromEntry::validate_restart_exclusive`]
625            // paired positive / negated
626            // [`UpgradeInstruction::is_restart`] routing (915a934) on the
627            // per-arm terminal-fallback partition — same closed-set-typed-
628            // enum arm-discriminator dispatch discipline extended from
629            // the single-arm terminal-fallback family onto the two-arm
630            // cleanup family here.
631            //
632            // Route the paired load-family arm-discriminator through the
633            // `gen_platform::IsVariant`-derive-generated
634            // [`UpgradeInstruction::is_load_module`] predicate rather than
635            // the raw `matches!(instr, UpgradeInstruction::LoadModule
636            // { .. })` open-coded pattern-match — closes the last
637            // unlifted `matches!`-based per-variant arm-discriminator
638            // axis on the [`UpgradeInstruction`] closed-set typed enum,
639            // sibling of the [`UpgradeInstruction::is_restart`] terminal-
640            // fallback routing (915a934) and the
641            // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
642            // routing (0bc469f) that already lifted the paired
643            // arm-discriminator sites in this method. Every arm-family
644            // partition the gate keys off — load-family (`LoadModule`),
645            // cleanup-family (`SoftPurge | Purge`), terminal-fallback
646            // (`Restart`) — now consults exactly one typed dispatch on
647            // the substrate primitive, so a future sixth arm added to
648            // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
649            // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
650            // a `CanaryTraffic` split-traffic variant the M4 CR
651            // materializer could resolve per-CR — INSPIRATIONS §II.4)
652            // migrates as a single enum-declaration edit through the
653            // derive rather than a scattered per-consumer rewrite. The
654            // partition invariant is pinned by
655            // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
656            // and the byte-identity of this dispatch against the pre-lift
657            // `matches!` pattern by
658            // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
659            if instr.is_load_module() {
660                loaded = true;
661            } else if instr.is_cleanup() && !loaded {
662                return Err(UpgradeError::purge_without_prior_load(
663                    self.prior_versao(),
664                    instr.lisp_form(),
665                    instr
666                        .declared_module()
667                        .expect("is_cleanup() implies declared_module() is Some"),
668                ));
669            }
670        }
671        Ok(())
672    }
673
674    /// Reject an entry whose `(:state-change …)` appears after any
675    /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
676    /// list — completing the canonical OTP appup `code:load_module/1`
677    /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
678    /// chain on the typed `:upgrade-from` slot.
679    ///
680    /// `StateChange` is the `gen_server:code_change/3` analog
681    /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
682    /// verbatim: "State migration uses `gen_server:code_change/3` …
683    /// migrate state from v0.1.0 shape to current shape"). The
684    /// callback's input is the *prior* version's state shape, which
685    /// only exists while the prior code is still resident — the running
686    /// `gen_server` processes hold the v0.1.0 state, and the operator's
687    /// dispatch invokes `code_change/3` to fold that state into the
688    /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
689    /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
690    /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
691    /// *old* module after the new one is resident. The operator runs
692    /// instructions in declared order (module doc), so a cleanup ahead
693    /// of a state-change discards the prior code before the migration
694    /// fold runs against the state it held — the canonical OTP error
695    /// mode "`code_change/3` invoked on a purged module" the
696    /// `release_handler` enforces by always emitting the migration
697    /// callback before the soft-purge step.
698    ///
699    /// `systools`-generated `.relup` files always emit `code_change`
700    /// before `soft_purge` for this reason; the appup cookbook's
701    /// canonical pattern (`[{load_module, m}, {update, m, soft},
702    /// {soft_purge, m}]`) places the migration-triggering `update`
703    /// strictly between the load and the cleanup. The caixa module
704    /// doc pins the same canonical order verbatim — `(:load-module
705    /// …) (:state-change …) (:soft-purge …)` — and this gate makes
706    /// that ordering a structural property at build time. Three
707    /// authoring footguns close here:
708    ///
709    ///   - `((:load-module "x") (:soft-purge "x-old") (:state-change
710    ///     "lib/m.lisp"))` — the right-instructions-wrong-order
711    ///     footgun on the migrate ↔ cleanup axis. Because the operator
712    ///     executes in declared order, the cleanup drains the v0.1.0
713    ///     module to nothing before the migration callback runs, and
714    ///     the script either no-ops (no v0.1.0 state left to fold) or
715    ///     crashes (`code_change/3` invoked on an unloaded version).
716    ///     The canonical order is `(:load-module …) (:state-change
717    ///     …) (:soft-purge …)` (module doc example).
718    ///   - `((:load-module "x") (:purge "x-old") (:state-change
719    ///     "lib/m.lisp"))` — same shape on the more catastrophic
720    ///     `:purge` variant. The immediate-discard semantic destroys
721    ///     v0.1.0 state mid-request; the trailing migration script
722    ///     has nothing to fold from and the `gen_server` processes that
723    ///     held v0.1.0 state were killed by the `:purge`.
724    ///   - `((:load-module "x") (:soft-purge "x-old") (:state-change
725    ///     "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
726    ///     sandwiched between two cleanups" footgun. The first
727    ///     cleanup discards v0.1.0; the migration runs against
728    ///     drained state; the second cleanup is irrelevant. The first
729    ///     cleanup → state-change boundary is the load-bearing defect
730    ///     surfaced.
731    ///
732    /// Same within-entry cross-instruction discipline as
733    /// [`Self::validate_state_change_ordering`] (the load → state-
734    /// change ordering gate it runs after) and
735    /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
736    /// gate it runs after): all three close one boundary of the OTP
737    /// canonical sequence `code:load_module/1` →
738    /// `gen_server:code_change/3` → `code:soft_purge/1`. The
739    /// state-change-ordering gate closes the load → migrate boundary;
740    /// the purge-ordering gate closes the load → cleanup boundary;
741    /// this gate closes the migrate → cleanup boundary, completing
742    /// the typed coverage of the canonical sequence. Runs *after*
743    /// [`Self::validate_purge_ordering`] (and therefore after
744    /// [`Self::validate_state_change_ordering`]) so an entry like
745    /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
746    /// which violates *both* the purge-without-load gate and this
747    /// state-change-after-cleanup gate — surfaces the more-
748    /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
749    /// defect is load-bearing; once a coherent `(:load-module …)`
750    /// precedes both, the migrate ↔ cleanup ordering becomes the
751    /// next live defect). Runs *before* the per-instruction-class
752    /// singularity gates ([`Self::validate_load_singularity`],
753    /// [`Self::validate_state_change_singularity`],
754    /// [`Self::validate_cleanup_singularity`]) so an entry like
755    /// `((:load-module "x") (:soft-purge "x-old") (:state-change
756    /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
757    /// *both* this ordering gate and the state-change-singularity
758    /// gate — surfaces the ordering defect first; the canonical
759    /// "ordering before singularity" precedence the peer
760    /// `validate_state_change_ordering` / `validate_purge_ordering`
761    /// gates already establish.
762    ///
763    /// Detection: linear scan of the instructions list with a
764    /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
765    /// recording the first cleanup encountered; on any subsequent
766    /// `StateChange` the gate fires with the script + the prior
767    /// cleanup's kind/module. Diagnostic-order pin: the first
768    /// colliding state-change-after-cleanup pair surfaces, not the
769    /// last — mirrors every peer ordering gate's first-collision
770    /// posture ([`Self::validate_state_change_ordering`] returns on
771    /// the first `StateChange` without prior load,
772    /// [`Self::validate_purge_ordering`] on the first cleanup
773    /// without prior load).
774    fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
775        let mut prior_cleanup: Option<(&str, &'static str)> = None;
776        for instr in self.instructions() {
777            // Route the per-instruction cleanup-family arm-discriminator
778            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
779            // predicate rather than the raw
780            // `UpgradeInstruction::SoftPurge { module } |
781            // UpgradeInstruction::Purge { module }` open-coded per-arm
782            // union pattern-match — the second of three within-entry
783            // cross-instruction cleanup-facing gates the peer
784            // [`Self::validate_purge_ordering`] routing already lifted;
785            // both now key off exactly one typed dispatch on the substrate
786            // primitive so the "which arms belong to the cleanup family"
787            // question resolves at exactly one caixa-core edit. The
788            // sticky-once latch's `:module` scalar is routed through the
789            // sibling [`UpgradeInstruction::declared_module`] accessor
790            // rather than the raw pattern-bound `module.as_str()`
791            // projection, with the `is_cleanup`-implies-`declared_module`-
792            // is-`Some` composition pin at
793            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
794            // making the `.expect(…)` structurally infallible at build
795            // time.
796            if instr.is_cleanup() && prior_cleanup.is_none() {
797                prior_cleanup = Some((
798                    instr
799                        .declared_module()
800                        .expect("is_cleanup() implies declared_module() is Some"),
801                    instr.lisp_form(),
802                ));
803            } else if let Some(script) = instr.declared_path()
804                && let Some((prior_module, prior_kind)) = prior_cleanup
805            {
806                // Route the per-instruction `StateChange`-arm script-path
807                // projection through the sibling lifted
808                // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
809                // accessor rather than the raw
810                // `if let UpgradeInstruction::StateChange { script } = instr`
811                // open-coded pattern-match — the last unlifted per-
812                // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
813                // inside `impl UpgradeFromEntry`, sibling to the four peer
814                // per-`UpgradeInstruction` consumers already routed through
815                // the accessor: [`UpgradeInstruction::validate`]'s per-
816                // `StateChange` sandbox-path fan-out, the layout-side per-
817                // `StateChange` script-existence fan-out at
818                // [`crate::layout::StandardLayout::verify`]
819                // (caixa-core/src/layout.rs:1058), the within-entry
820                // [`UpgradeFromEntry::validate_state_change_singularity`]
821                // per-`StateChange` script-projection fan-out, and the
822                // cross-slot
823                // [`validate_upgrade_from_against_behavior`]
824                // per-`StateChange` detection loop. Byte-equal today
825                // (`declared_path` returns `Some(script)` iff the
826                // instruction is [`UpgradeInstruction::StateChange`], per
827                // the sibling `declared_path_only_for_state_change` pin),
828                // so a state-change-after-cleanup surfaces
829                // `StateChangeAfterCleanup` byte-identical to the pattern-
830                // match shape. Any future accessor extension that promotes
831                // an additional variant onto the `PathBuf`-carrying axis
832                // reaches this gate through one caixa-core edit rather
833                // than a coordinated rewrite of five call sites — the
834                // migrate→cleanup ordering discipline extends to the
835                // promoted variant by construction. Same "one typed
836                // dispatch on the substrate primitive, thin projections at
837                // each consumer" trajectory the sibling
838                // [`UpgradeInstruction::declared_module`] `String`-axis
839                // per-variant unifier already established.
840                return Err(UpgradeError::state_change_after_cleanup(
841                    self.prior_versao(),
842                    script,
843                    prior_kind,
844                    prior_module,
845                ));
846            }
847        }
848        Ok(())
849    }
850
851    /// Reject an entry whose `:instructions` list names the same module
852    /// as the target of more than one cleanup instruction (`:soft-purge`
853    /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
854    /// module) axis, narrowed to the cleanup class.
855    ///
856    /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
857    /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
858    /// `code:load_module/1` — load v2 alongside v1 … 2.
859    /// `code:soft_purge/1` — wait until no process is running v1, then
860    /// discard. (`code:purge/1` kills v1 immediately if you don't
861    /// care.)"). The author picks *one* cleanup semantic per old
862    /// module — `:soft-purge` (preferred: waits for in-flight callers
863    /// to drain) or `:purge` (when the drain isn't possible) — and the
864    /// operator runs that one in declared order alongside any other
865    /// distinct-module cleanups. systools-generated `.relup` files
866    /// always emit at most one purge per module for this reason; any
867    /// retry / fallback decision is the operator's job on
868    /// instruction failure, not authored into the entry. Three
869    /// authoring footguns close here:
870    ///
871    ///   - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
872    ///     — the "I copy-pasted the cleanup line twice" footgun. The
873    ///     second `:soft-purge` is a no-op (the module is already gone
874    ///     after the first drain-and-discard) or undefined depending
875    ///     on the operator's handling of a non-resident-module purge
876    ///     request; either way the second instruction carries no
877    ///     observable semantic, far from the source caixa.lisp.
878    ///   - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
879    ///     — the "soft-then-hard fallback" footgun. The author wrote
880    ///     "drain, and if drain didn't clean it up, force-discard",
881    ///     but the operator runs instructions unconditionally in
882    ///     declared order — the `:purge` fires whether the
883    ///     `:soft-purge` already discarded the module or not, so the
884    ///     fallback semantic the author imagined is missing; the
885    ///     pair is incoherent (drain *and* force-discard semantics
886    ///     on one module is two contradictory dispositions). The
887    ///     operator's failure-handling surface is its own
888    ///     responsibility: if `:soft-purge` doesn't drain within its
889    ///     cooldown the operator escalates, not the author's entry.
890    ///   - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
891    ///     — same shape on the reversed ordering. The `:purge`
892    ///     discards immediately; the trailing `:soft-purge` has no
893    ///     module to drain.
894    ///
895    /// Same within-entry exclusivity discipline as
896    /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
897    /// exclusivity gate it joins on the per-module cleanup axis): both
898    /// reject an `:instructions` list whose instructions are
899    /// individually well-shaped but jointly incoherent on a chosen
900    /// semantic axis (restart-fallback for the whole entry there;
901    /// cleanup-semantic for one module here), at the typed build
902    /// surface rather than as a runtime surprise. Runs *after*
903    /// [`Self::validate_purge_ordering`] (the load-before-cleanup
904    /// ordering gate) so an entry like `((:soft-purge "x-old")
905    /// (:soft-purge "x-old"))` surfaces the more-fundamental
906    /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
907    /// the missing-load defect is the load-bearing one — the duplicate
908    /// is meaningless either way without the preceding load).
909    ///
910    /// Same set-not-multiset discipline applied to every peer
911    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
912    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
913    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
914    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
915    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
916    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
917    /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
918    /// Each closes the same authoring footgun: a Vec authoring surface
919    /// that silently accepts duplicate entries and renders the "second
920    /// wins" (or "operator processes both, second is a no-op or
921    /// errors") shape downstream, far from the source caixa.lisp.
922    /// This gate extends the discipline onto the within-entry
923    /// instruction-target axis — duplicate cleanup targets *within*
924    /// one `:upgrade-from` entry — the peer of the cross-entry
925    /// duplicate-`:from` axis at one level of nesting deeper.
926    ///
927    /// Detection: linear scan of the instructions list collecting
928    /// the (module, kind) pair from every `SoftPurge` / `Purge`
929    /// encountered; on the second occurrence of any module the gate
930    /// fires with the prior kind and the colliding kind in declaration
931    /// order. Diagnostic-order pin: the first colliding pair surfaces,
932    /// not the last — mirrors
933    /// [`validate_upgrade_from`]'s
934    /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
935    /// posture (the first detected collision wins) and every peer
936    /// duplicate gate's first-collision discipline.
937    fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
938        let mut seen: Vec<(&str, &'static str)> = Vec::new();
939        for instr in self.instructions() {
940            // Route the per-instruction cleanup-family arm-discriminator
941            // through the lifted [`UpgradeInstruction::is_cleanup`] typed
942            // predicate rather than the raw two-arm
943            // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
944            // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
945            // `UpgradeInstruction::Purge { module } => (module.as_str(),
946            // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
947            // per-arm dispatch — the third of three within-entry cross-
948            // instruction cleanup-facing gates the peer
949            // [`Self::validate_purge_ordering`] +
950            // [`Self::validate_state_change_before_cleanup`] routing
951            // already lifted; all three now key off exactly one typed
952            // dispatch on the substrate primitive, structurally. The
953            // cleanup-target `(module, kind)` pair is projected through
954            // the peer [`UpgradeInstruction::declared_module`] /
955            // [`UpgradeInstruction::lisp_form`] accessors rather than
956            // the per-arm-hand-rolled scalar-value + kind-const pair,
957            // with the `is_cleanup`-implies-`declared_module`-is-`Some`
958            // composition pin at
959            // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
960            // making the `.expect(…)` structurally infallible at build
961            // time. Any future fifth cleanup-shaped variant added under
962            // the `is_cleanup` predicate + registered through the peer
963            // `lisp_form` per-arm kebab-case-const dispatch reaches this
964            // dedup gate through the accessor's one body rather than a
965            // fourth per-arm-hand-rolled scalar/kind projection here.
966            if !instr.is_cleanup() {
967                continue;
968            }
969            let module = instr
970                .declared_module()
971                .expect("is_cleanup() implies declared_module() is Some");
972            let kind = instr.lisp_form();
973            if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
974                let prior_kind = seen[prior_idx].1;
975                return Err(UpgradeError::duplicate_cleanup(
976                    self.prior_versao(),
977                    module,
978                    vec![prior_kind, kind],
979                ));
980            }
981            seen.push((module, kind));
982        }
983        Ok(())
984    }
985
986    /// Reject an entry whose `:instructions` list names the same module
987    /// as the target of more than one `(:load-module …)` instruction —
988    /// set-not-multiset on the `LoadModule` axis.
989    ///
990    /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
991    /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
992    /// new code is 'current', old code is 'old'."). The instruction
993    /// brings the new wasm component up resident alongside the old
994    /// one so the operator can route new traffic to the new code
995    /// while in-flight callers drain on the old — and the operator's
996    /// dispatch table reads the module *name* (a caixa name) to bind
997    /// the component, so two `(:load-module "x")` instructions in one
998    /// entry ask the operator to re-bind the same component twice.
999    /// `systools`-generated `.relup` files emit at most one
1000    /// `load_module` per module per upgrade step for this reason; the
1001    /// second load has no observable semantic relative to the first
1002    /// (the component is already resident). Three authoring footguns
1003    /// close here:
1004    ///
1005    ///   - `((:load-module "x") (:load-module "x"))` — the "I
1006    ///     copy-pasted the load line twice" footgun. The second
1007    ///     `:load-module` re-reads the same module name and re-binds
1008    ///     the same wasm component — a no-op in both directions
1009    ///     (no new code becomes resident; no old code is purged) —
1010    ///     and any cleanup / migration the author intended for a
1011    ///     *distinct* module is silently absent from the entry.
1012    ///   - `((:load-module "x") (:load-module "x") (:state-change …))`
1013    ///     — the "I meant to load two distinct modules" typo. The
1014    ///     author intended `((:load-module "x") (:load-module "y"))`
1015    ///     but renamed both to "x" (or copied the first line and
1016    ///     forgot to change the module). The migration runs against
1017    ///     code that's resident only on one module name, and the
1018    ///     second module the author imagined was being loaded never
1019    ///     comes up at all — far from the source caixa.lisp.
1020    ///   - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1021    ///     — same shape with a trailing cleanup. The duplicate load
1022    ///     is dead code; the cleanup still fires correctly, masking
1023    ///     the load-side duplication as a silently-passing entry.
1024    ///
1025    /// Same within-entry exclusivity discipline as
1026    /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1027    /// singularity gate this runs beside) on the sibling
1028    /// `LoadModule` axis: both reject an `:instructions` list whose
1029    /// instructions are individually well-shaped but jointly
1030    /// incoherent on a per-module-per-class basis (load-once for the
1031    /// load axis here; cleanup-once for the cleanup axis there), at
1032    /// the typed build surface rather than as a runtime surprise.
1033    /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1034    /// before-cleanup ordering gate) so an entry like
1035    /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1036    /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1037    /// first (the missing-load defect is load-bearing — the migration
1038    /// runs against unloaded code; the duplicate is meaningless either
1039    /// way without the preceding load). Runs *before*
1040    /// [`Self::validate_cleanup_singularity`] so an entry like
1041    /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1042    /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1043    /// the load axis precedes the cleanup axis in the canonical OTP
1044    /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1045    /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1046    /// before `SoftPurge`/`Purge`), so the load-side singularity is
1047    /// the load-bearing diagnostic when both fire.
1048    ///
1049    /// Same set-not-multiset discipline applied to every peer
1050    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1051    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1052    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1053    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1054    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1055    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1056    /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1057    /// the per-module cleanup-target axis (9cedd8b —
1058    /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1059    /// discipline onto the within-entry `LoadModule` instruction-target
1060    /// axis — the third within-entry per-module singularity completing
1061    /// the load+cleanup pair across the OTP two-phase code-load
1062    /// contract.
1063    ///
1064    /// Detection: linear scan of the instructions list collecting the
1065    /// module name from every `LoadModule` encountered; on the second
1066    /// occurrence of any module the gate fires. Diagnostic-order pin:
1067    /// the first colliding occurrence surfaces, not the last — mirrors
1068    /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1069    /// and every peer duplicate gate's first-collision discipline.
1070    fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1071        let mut seen: Vec<&str> = Vec::new();
1072        for instr in self.instructions() {
1073            // Route the per-instruction load-family arm-discriminator
1074            // through the `gen_platform::IsVariant`-derive-generated
1075            // [`UpgradeInstruction::is_load_module`] predicate rather
1076            // than the raw single-arm `match instr {
1077            // UpgradeInstruction::LoadModule { module } =>
1078            // module.as_str(), _ => continue }` open-coded pattern-
1079            // match — closes the last unlifted `matches!`-shaped
1080            // per-arm-hand-rolled scalar-value + arm-discriminator
1081            // pair inside `impl UpgradeFromEntry`, sibling of the
1082            // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1083            // routing already lifted onto the two-arm cleanup-family
1084            // axis's per-arm arm-discriminator + `:module` projection
1085            // dispatch. The load-target `:module` scalar is projected
1086            // through the sibling [`UpgradeInstruction::declared_module`]
1087            // accessor rather than the per-arm-hand-rolled scalar-
1088            // value binding, with the
1089            // `is_load_module`-implies-`declared_module`-is-`Some`
1090            // composition pin at
1091            // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1092            // making the `.expect(…)` structurally infallible at
1093            // build time. Every arm-family partition the three
1094            // within-entry per-instruction-class singularity gates
1095            // key off — load-family
1096            // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1097            // ([`UpgradeInstruction::SoftPurge`] |
1098            // [`UpgradeInstruction::Purge`]), migration-family
1099            // ([`UpgradeInstruction::StateChange`]) — now consults
1100            // exactly one typed dispatch on the substrate primitive
1101            // (`is_load_module()` here, `is_cleanup()` at
1102            // [`Self::validate_cleanup_singularity`],
1103            // `declared_path()` at
1104            // [`Self::validate_state_change_singularity`]), so a
1105            // future sixth arm added to [`UpgradeInstruction`] (an
1106            // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1107            // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1108            // split-traffic variant the M4 CR materializer could
1109            // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1110            // single enum-declaration edit through the derive rather
1111            // than a scattered per-consumer rewrite. Byte-identity of
1112            // this dispatch against the pre-lift match-pattern is
1113            // pinned by
1114            // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1115            if !instr.is_load_module() {
1116                continue;
1117            }
1118            let module = instr
1119                .declared_module()
1120                .expect("is_load_module() implies declared_module() is Some");
1121            if seen.contains(&module) {
1122                return Err(UpgradeError::duplicate_load_module(
1123                    self.prior_versao(),
1124                    module,
1125                ));
1126            }
1127            seen.push(module);
1128        }
1129        Ok(())
1130    }
1131
1132    /// Reject an entry whose `:instructions` list names the same script
1133    /// as the target of more than one `(:state-change …)` instruction —
1134    /// set-not-multiset on the `StateChange` axis.
1135    ///
1136    /// `StateChange` is the `gen_server:code_change/3` analog
1137    /// (INSPIRATIONS §II.4: "State migration uses
1138    /// `gen_server:code_change/3`"). The instruction folds the *old*
1139    /// state into the shape the *new* code expects — a one-shot
1140    /// transition from one declared state representation to another.
1141    /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1142    /// exactly once per upgrade per `gen_server`; `systools`-generated
1143    /// `.relup` files emit at most one `code_change` per `gen_server` per
1144    /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1145    /// instruction targeting the same script in one entry re-runs the
1146    /// migration fold — at best a no-op (idempotent script masking a
1147    /// typo where the author intended two distinct scripts) and at
1148    /// worst silent state corruption (non-idempotent fold double-
1149    /// applied: an `add column` migration that runs twice, an
1150    /// `increment counter` that double-bumps, a `rename field` that
1151    /// renames-then-fails the second time). Three authoring footguns
1152    /// close here:
1153    ///
1154    ///   - `((:load-module "x") (:state-change "lib/m.lisp")
1155    ///     (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1156    ///     migration line twice" footgun. The second `:state-change`
1157    ///     re-runs the same fold on the already-migrated state — a
1158    ///     no-op if the script is idempotent (dead code masking the
1159    ///     duplication) or state corruption if not (the migration's
1160    ///     pre-condition no longer holds because the post-condition is
1161    ///     already in place).
1162    ///   - `((:load-module "x") (:state-change "lib/m.lisp")
1163    ///     (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1164    ///     "duplicate migrate masked by trailing cleanup" footgun. The
1165    ///     cleanup still fires correctly, masking the migration-side
1166    ///     duplication as a silently-passing entry.
1167    ///   - `((:load-module "x") (:state-change "lib/m1.lisp")
1168    ///     (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1169    ///     two distinct modules" typo. The author intended
1170    ///     `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1171    ///     but renamed both to `m1.lisp` (or copy-pasted the first line
1172    ///     and forgot to change the script). The migration that should
1173    ///     have folded the second module's state never runs, far from
1174    ///     the source caixa.lisp.
1175    ///
1176    /// Same within-entry exclusivity discipline as
1177    /// [`Self::validate_load_singularity`] (the per-module load-
1178    /// singularity gate it runs after) and
1179    /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1180    /// singularity gate it runs before) on the sibling `StateChange`
1181    /// axis: each rejects an `:instructions` list whose instructions
1182    /// are individually well-shaped but jointly incoherent on a per-
1183    /// instruction-class basis (load-once per module for the load
1184    /// axis; migrate-once per script for the migration axis here;
1185    /// cleanup-once per module for the cleanup axis), at the typed
1186    /// build surface rather than as a runtime surprise. Runs *after*
1187    /// [`Self::validate_load_singularity`] so an entry like
1188    /// `((:load-module "x") (:load-module "x") (:state-change
1189    /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1190    /// `DuplicateLoadModule` first — the load axis precedes the
1191    /// migration axis in the canonical OTP sequence
1192    /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1193    /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1194    /// `StateChange`), so the load-side singularity is the load-
1195    /// bearing diagnostic when both fire. Runs *before*
1196    /// [`Self::validate_cleanup_singularity`] so an entry like
1197    /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1198    /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1199    /// surfaces `DuplicateStateChange` first — the migration axis
1200    /// precedes the cleanup axis in the canonical OTP sequence
1201    /// (`code:code_change/3` then `code:soft_purge/1`) and in
1202    /// [`UpgradeInstruction`] declaration order (`StateChange` before
1203    /// `SoftPurge`/`Purge`).
1204    ///
1205    /// Same set-not-multiset discipline applied to every peer
1206    /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1207    /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1208    /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1209    /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1210    /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1211    /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1212    /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1213    /// per-module cleanup-target axis (9cedd8b —
1214    /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1215    /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1216    /// This gate extends the discipline onto the within-entry
1217    /// `StateChange` instruction-target axis — the third within-entry
1218    /// per-instruction-class singularity, completing the OTP two-phase
1219    /// code-load + state-migration coverage triad
1220    /// (`code:load_module/1` → `gen_server:code_change/3` →
1221    /// `code:soft_purge/1`).
1222    ///
1223    /// Detection: linear scan of the instructions list collecting the
1224    /// script path from every `StateChange` encountered; on the second
1225    /// occurrence of any script the gate fires. Diagnostic-order pin:
1226    /// the first colliding occurrence surfaces, not the last — mirrors
1227    /// [`Self::validate_load_singularity`]'s and
1228    /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1229    /// and every peer duplicate gate's first-collision discipline.
1230    fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1231        // Route the per-instruction `StateChange`-arm script-path
1232        // projection through the sibling lifted
1233        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1234        // accessor rather than the raw
1235        // `match instr { UpgradeInstruction::StateChange { script } =>
1236        // script.as_path(), _ => continue }` open-coded pattern-match —
1237        // the third within-entry singularity gate's per-instruction
1238        // script-projection site now keys off exactly one typed
1239        // dispatch on the substrate primitive's `PathBuf`-carrying
1240        // axis, sibling to the four peer per-`UpgradeInstruction`
1241        // consumers ([`Self::validate`]'s per-`StateChange`
1242        // sandbox-path fan-out, the layout-side per-`StateChange`
1243        // script-existence fan-out at
1244        // `caixa-core/src/layout.rs:1017`, the cross-slot
1245        // [`validate_upgrade_from_against_behavior`] gate's
1246        // per-`StateChange` detection loop, the future wasm-operator's
1247        // per-`StateChange` runtime hook-dispatch) that already route
1248        // through `declared_path` / `declared_module`. Byte-equal
1249        // today (`declared_path` returns `Some(script)` iff the
1250        // instruction is [`UpgradeInstruction::StateChange`], per the
1251        // sibling `declared_path_only_for_state_change` pin), so a
1252        // duplicate `:state-change` script surfaces
1253        // `DuplicateStateChange` byte-identical to the pattern-match
1254        // shape. Same "one typed dispatch on the substrate primitive,
1255        // thin projections at each consumer" discipline the sibling
1256        // [`UpgradeInstruction::declared_module`] accessor established
1257        // (b13c4f9) on the peer `String`-carrying axis's per-variant
1258        // consumers, extended here onto the last unlifted
1259        // pattern-match on the `PathBuf`-carrying axis inside
1260        // `impl UpgradeFromEntry`.
1261        let mut seen: Vec<&std::path::Path> = Vec::new();
1262        for instr in self.instructions() {
1263            let Some(script) = instr.declared_path() else {
1264                continue;
1265            };
1266            let script = script.as_path();
1267            if seen.contains(&script) {
1268                return Err(UpgradeError::duplicate_state_change(
1269                    self.prior_versao(),
1270                    script,
1271                ));
1272            }
1273            seen.push(script);
1274        }
1275        Ok(())
1276    }
1277}
1278
1279/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1280/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1281/// invariant — at most one `(:from <prior>)` block per parsed semver.
1282///
1283/// OTP's appup picks at most one matching block to apply to the running
1284/// release (`release_handler:install_release/1` matches the loaded
1285/// `:from` against the currently-running version and executes the
1286/// associated instruction sequence; the wasm-operator picks the matching
1287/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1288/// the same parsed-semver `:from` are an ambiguous edge in the typed
1289/// upgrade graph — the operator can pick either set deterministically,
1290/// but each set may carry different `LoadModule | StateChange |
1291/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1292/// non-deterministic relative to the source caixa.lisp. The author's
1293/// intent is one path per prior version; the typed graph must enforce
1294/// that shape.
1295///
1296/// Same set-not-multiset discipline already applied to every peer
1297/// typed-graph axis: `:children :caixa` (dbf50a9 —
1298/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1299/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1300/// `AplicacaoError::MembroDuplicate`), `:contratos`
1301/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1302/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1303/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1304/// Each closes the same authoring footgun: a Vec authoring surface that
1305/// silently accepts duplicate entries and renders the "second wins"
1306/// (or "operator picks arbitrarily") shape downstream, far from the
1307/// source caixa.lisp.
1308///
1309/// Duplicates are detected by [`semver::Version`] equality (the
1310/// crate's `PartialEq` compares the full identity — major.minor.patch +
1311/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1312/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1313/// The conservative choice mirrors what the wasm-operator's
1314/// `:from`-match dispatch can see; collapsing build metadata to catch
1315/// a wider net of duplicates is a future tightening that requires
1316/// coordinating with the operator's match step.
1317///
1318/// Per-entry shape errors fire before the duplicate gate so the
1319/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1320/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1321/// errors into a single duplicate diagnostic. Mirrors the
1322/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1323/// axis ([`crate::SupervisorSpec::validate`],
1324/// [`crate::AplicacaoSpec::validate_membros`],
1325/// [`crate::AplicacaoSpec::validate_placement`]).
1326pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1327    use semver::Version;
1328    let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1329    for entry in entries {
1330        entry.validate()?;
1331        // `entry.validate()` accepted this `:from`, so parse cannot
1332        // fail here — the FromInvalid arm above is the only gate
1333        // and both call `Version::parse(entry.prior_versao())`.
1334        let parsed = Version::parse(entry.prior_versao()).expect(
1335            "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1336             two gates aligned",
1337        );
1338        if seen.contains(&parsed) {
1339            return Err(UpgradeError::duplicate_from(entry));
1340        }
1341        seen.push(parsed);
1342    }
1343    Ok(())
1344}
1345
1346/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1347/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1348/// same ordering [`semver::Version::cmp`] implements, with build
1349/// metadata ignored per [SemVer §11][semver-11]).
1350///
1351/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1352/// declarative answer to "given the wasm-operator is loading a node
1353/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1354/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1355/// semantic). The operator's `:from`-match dispatch loads the
1356/// current `:versao` and matches the *running* version against each
1357/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1358/// unreachable — the operator never runs a version greater than or
1359/// equal to the current `:versao` that it could then "upgrade *to*"
1360/// the current `:versao`. Two authoring footguns close here:
1361///
1362///   - `:from > :versao` (downgrade-shaped) — the canonical
1363///     "I copy-pasted from the next minor version and forgot to bump
1364///     `:versao`" / "I bumped `:versao` then reverted but left the
1365///     `:upgrade-from` entry behind" footgun. Until this gate landed
1366///     `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1367///     silently passed `feira build` and the wasm-operator's
1368///     `:from`-match dispatch would never fire on the entry — the
1369///     instructions sat dormant in the caixa.lisp forever, the
1370///     author's intent ("upgrade users coming from 0.2.0") permanently
1371///     unreached because they actually meant to bump `:versao`.
1372///
1373///   - `:from == :versao` (precedence-equal self-upgrade) — the
1374///     "I declared an upgrade from myself to myself" no-op the
1375///     operator's dispatch would either skip silently (no semantic
1376///     transition) or attempt and trivially "succeed" with no
1377///     observable state change. Includes the build-metadata-only
1378///     difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1379///     SemVer-2 precedence ignores build metadata so they compare
1380///     equal under [`semver::Version::cmp`] — the gate rejects this
1381///     even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1382///     gate uses derived `PartialEq` which keeps them distinct;
1383///     they're distinct dispatch keys but the same "from" version
1384///     for our purposes here).
1385///
1386/// Same cross-slot value-shape discipline as
1387/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1388/// partition (934bc58 — the typed partition between two declared
1389/// slots): one slot's value constrains the valid set of another's,
1390/// and the constraint is a structural property visible at validate
1391/// time. The validated set after this gate satisfies
1392/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1393/// for every entry, so the future operator-side hot-upgrade dispatch
1394/// step can reach for `entry.from` knowing the precedence relation
1395/// holds without re-deriving it from inline checks.
1396///
1397/// Silent-pass semantics on malformed inputs:
1398///
1399///   - When `versao` itself doesn't parse as semver, this gate
1400///     returns `Ok(())` silently — the narrower
1401///     [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1402///     diagnostics are the load-bearing surfaces for those failure
1403///     modes, and surfacing a `FromNotBeforeVersao` over an
1404///     unparseable `:versao` would mask the more actionable root
1405///     cause.
1406///   - Likewise, an entry whose `:from` itself doesn't parse falls
1407///     through to its narrower diagnostic surface
1408///     ([`UpgradeError::FromInvalid`]), which is expected to fire
1409///     via [`validate_upgrade_from`] *before* this gate runs at the
1410///     [`crate::LayoutInvariants`] call site.
1411///
1412/// [semver-11]: https://semver.org/#spec-item-11
1413pub fn validate_upgrade_from_against_versao(
1414    entries: &[UpgradeFromEntry],
1415    versao: &str,
1416) -> Result<(), UpgradeError> {
1417    use semver::Version;
1418    let Ok(current) = Version::parse(versao) else {
1419        // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1420        // surfacing a precedence-relation diagnostic over an unparseable
1421        // top-level version would mask the more actionable root cause.
1422        return Ok(());
1423    };
1424    for entry in entries {
1425        // Per-entry shape — including a malformed `:from` — is gated
1426        // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1427        // upstream at the LayoutInvariants call site; an unparseable
1428        // `:from` here falls through silently to keep the
1429        // FromInvalid diagnostic load-bearing. Same fall-through
1430        // posture as the `versao` arm above.
1431        let Ok(prior) = Version::parse(entry.prior_versao()) else {
1432            continue;
1433        };
1434        if prior >= current {
1435            return Err(UpgradeError::from_not_before_versao(
1436                entry.prior_versao(),
1437                versao,
1438            ));
1439        }
1440    }
1441    Ok(())
1442}
1443
1444/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1445/// `(:state-change <script>)` instruction unless the caixa also declares
1446/// `:behavior :on-state-change` — the runtime callback the per-version
1447/// migration script is delivered through during hot upgrade.
1448///
1449/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1450/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1451/// callback to deliver state migration during hot upgrades." The peer
1452/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1453/// promise from the callback side: the slot is the
1454/// `gen_server:code_change/3` analog — "receives old state + version,
1455/// returns new state. Composes with the `:upgrade-from` slot declared at
1456/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1457/// the composition by invoking the running `gen_server`'s
1458/// `code_change/3` callback during the appup's `code_change` /
1459/// `update, m, soft` step — the appup's instruction triggers the
1460/// callback, the callback folds the prior-version state shape into the
1461/// current-version shape, and the operator advances to the next
1462/// instruction only after the callback returns successfully. caixa
1463/// decomposes the same composition into two typed slots: the per-version
1464/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1465/// instruction's `:script` (the `:upgrade-from` author surface), and the
1466/// runtime hook the operator dispatches the migration through lives in
1467/// the `:behavior :on-state-change` callback (the `:behavior` author
1468/// surface). A `:state-change` instruction declared without the callback
1469/// is half the composition: the per-version script the author wrote has
1470/// no runtime delivery path, and the operator's hot-upgrade dispatch
1471/// reaches for `caixa.behavior.on_state_change` at the migration step,
1472/// finds `None`, and either fails the upgrade mid-flight (the
1473/// transactional rollback the module doc names — "On any failure, the
1474/// current version stays load-bearing — a typed atomic upgrade") or
1475/// silently skips the migration depending on the operator's handling of
1476/// a missing callback, both far from the source caixa.lisp.
1477///
1478/// Two authoring footguns close here:
1479///
1480///   - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1481///     :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1482///     (:soft-purge "x-old")))))` — the "I declared the migration script
1483///     but forgot the callback" footgun. The author wrote the per-version
1484///     fold against the prior state shape, the typed `:upgrade-from`
1485///     slot validated every per-instruction shape + ordering + singularity
1486///     gate, and the missing callback only surfaces at upgrade time as
1487///     either a transactional rollback to the prior version (no progress
1488///     across the upgrade) or as a silently-skipped migration that leaves
1489///     v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1490///     state shape).
1491///   - `:behavior` absent entirely + `:upgrade-from` carrying any
1492///     `:state-change` — the "I added the upgrade path but never declared
1493///     `:behavior`" footgun. `:behavior` is optional at the typed root
1494///     ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1495///     `:upgrade-from` slot validates on its own merits, but a `Caixa`
1496///     with `behavior: None` and a `:state-change` instruction is the
1497///     same missing-callback shape — the operator's dispatch can't reach
1498///     a callback that doesn't exist.
1499///
1500/// Same cross-slot composition discipline as
1501/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1502/// precedence gate at the peer wire-up site): one slot's value
1503/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1504/// the valid set of another's (the entry must be dispatchable there; the
1505/// callback must be declared here), and the constraint is a structural
1506/// property visible at validate time. The validated set after this gate
1507/// satisfies the documented composition: every `:state-change`
1508/// instruction the operator iterates at hot-upgrade time has a
1509/// corresponding `:on-state-change` callback declared on the same caixa,
1510/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1511/// `release_handler` canonical-sequence loop) can reach for
1512/// `behavior.on_state_change` at the migration step knowing the
1513/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1514/// from inline checks.
1515///
1516/// Diagnostic-precedence:
1517///
1518///   - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1519///     shape + the within-entry ordering / singularity gates) and
1520///     [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1521///     gate), so a malformed `:state-change` (`EmptyScript`,
1522///     `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1523///     (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1524///     duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1525///     self-locating diagnostic first — the canonical "per-instr-shape +
1526///     within-entry ordering + cross-entry uniqueness before
1527///     cross-slot composition" precedence the peer
1528///     `validate_upgrade_from_against_versao` gate establishes at the
1529///     same wire-up site. Without this precedence pin a malformed
1530///     `:state-change` instruction would surface this gate's
1531///     missing-callback diagnostic over the narrower
1532///     `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1533///     load-bearing per-instruction defect with a cross-slot composition
1534///     diagnostic.
1535///   - Within the entries, walks the list in declaration order and
1536///     surfaces the *first* `:state-change` instruction encountered —
1537///     mirrors every peer first-collision diagnostic posture on this
1538///     module (`validate_state_change_ordering` returns on the first
1539///     `StateChange` without prior load,
1540///     `validate_load_singularity` returns on the second matching
1541///     module, etc.). A future entry's later `:state-change` doesn't
1542///     surface a different diagnostic — the missing callback is the same
1543///     defect regardless of which entry's `:state-change` exposes it.
1544///
1545/// Silent-pass semantics:
1546///
1547///   - Entries carrying no `:state-change` instruction (load-only,
1548///     cleanup-only, restart-only, or empty `:instructions`) leave the
1549///     gate vacuous — no per-version migration means no callback to
1550///     dispatch through, so the absence of `:on-state-change` is
1551///     coherent. Pins the gate's identity element on the empty-set side
1552///     of the composition.
1553///   - `behavior: None` is *not* a free pass when a `:state-change`
1554///     instruction is present — the same missing-callback shape as
1555///     `behavior: Some(_)` with `on_state_change: None`. The gate reads
1556///     `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1557///     surface the same diagnostic.
1558pub fn validate_upgrade_from_against_behavior(
1559    entries: &[UpgradeFromEntry],
1560    behavior: Option<&crate::BehaviorSpec>,
1561) -> Result<(), UpgradeError> {
1562    if behavior
1563        .and_then(crate::BehaviorSpec::on_state_change)
1564        .is_some()
1565    {
1566        return Ok(());
1567    }
1568    for entry in entries {
1569        // Route the per-instruction `StateChange`-arm script-path
1570        // projection through the sibling lifted
1571        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1572        // accessor rather than the raw
1573        // `if let UpgradeInstruction::StateChange { script } = instr`
1574        // open-coded pattern-match — the cross-slot
1575        // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1576        // script-projection site now keys off exactly one typed dispatch
1577        // on the substrate primitive's `PathBuf`-carrying axis, sibling
1578        // to the four peer per-`UpgradeInstruction` consumers
1579        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1580        // sandbox-path fan-out, the layout-side per-`StateChange`
1581        // script-existence fan-out at
1582        // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1583        // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1584        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1585        // peer [`UpgradeInstruction::declared_module`] `String`-axis
1586        // per-variant unifier) that already route through
1587        // `declared_path` / `declared_module`. Byte-equal today
1588        // (`declared_path` returns `Some(script)` iff the instruction is
1589        // [`UpgradeInstruction::StateChange`], per the sibling
1590        // `declared_path_only_for_state_change` pin), so a
1591        // `:state-change`-without-`:on-state-change`-callback
1592        // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1593        // byte-identical to the pattern-match shape. Fourth (and last)
1594        // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1595        // axis now routed through the accessor — closes the last
1596        // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1597        // site outside `impl UpgradeFromEntry`, so the peer four
1598        // consumer set named in the sibling
1599        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1600        // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1601        // closed.
1602        for instr in entry.instructions() {
1603            if let Some(script) = instr.declared_path() {
1604                return Err(UpgradeError::state_change_without_on_state_change_callback(
1605                    entry.prior_versao(),
1606                    script,
1607                ));
1608            }
1609        }
1610    }
1611    Ok(())
1612}
1613
1614impl UpgradeInstruction {
1615    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup kind-tag
1616    /// projection every consumer that renders / classifies / grepping-
1617    /// projects an instruction's lisp form keys off — returns the
1618    /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1619    /// straight through the paired
1620    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1621    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1622    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1623    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1624    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1625    /// roster the substrate already carries at the wire-form axis.
1626    ///
1627    /// Consumers today: [`Self::validate`] threads the label through the
1628    /// per-variant [`UpgradeError::ModuleEmpty`] /
1629    /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1630    /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1631    /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1632    /// `(:purge …)` and fix it in one edit; every within-entry cross-
1633    /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1634    /// same accessor's `&'static str` return in place of hand-rolling
1635    /// the per-arm match.
1636    ///
1637    /// Promoted from `pub(self)` to `pub`: every future consumer that
1638    /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1639    /// by its OTP-appup lisp form outside caixa-core — a deferred
1640    /// wasm-operator `install_release/1` per-instruction dispatch
1641    /// logger tagging each executed instruction under its kebab-case
1642    /// kind, a `feira lint --upgrade-from` per-instruction author-time
1643    /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1644    /// webhook naming the offending instruction's kind in its rejection
1645    /// body, a future `caixa-actions` renderer that surfaces the
1646    /// declared appup instruction list in a workflow annotation, an
1647    /// LSP hover projecting the per-instruction kind onto a text-
1648    /// document diagnostic — reaches this projection through one call
1649    /// on the substrate primitive rather than open-coding the same
1650    /// five-arm match plus per-arm const imports at every consumer.
1651    /// A future variant addition (a `Discard` peer the `code:delete/1`
1652    /// analog inspires, an M4 `SoftPurge` split into
1653    /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1654    /// policy grows a two-arm shape) reaches every consumer at one edit
1655    /// — this method's match — rather than fanning out through hand-
1656    /// rolled per-arm dispatch across every downstream site.
1657    ///
1658    /// Peer of the sibling substrate-canonical arm-family accessors on
1659    /// the same closed-set enum: [`Self::declared_module`] on the
1660    /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1661    /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1662    /// [`Self::StateChange`] / [`Self::Restart`]),
1663    /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1664    /// (`Some(_)` for [`Self::StateChange`]), and
1665    /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1666    /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1667    /// generated per-variant `is_*` predicate family — every downstream
1668    /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1669    /// one typed dispatch on the substrate primitive rather than open-
1670    /// coding a per-arm match.
1671    ///
1672    /// `const fn` preserves the zero-runtime-work property of the pre-
1673    /// promotion body verbatim, and the `&'static str` return (not
1674    /// `&str` tied to `&self`'s lifetime) matches the paired
1675    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1676    /// program-lifetime discipline so callers can stash the returned
1677    /// label in `&'static`-bounded positions (a static logger's format
1678    /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1679    /// slice-of-`&'static str` accept-set) without re-borrowing through
1680    /// the instruction reference. Named `lisp_form` (not `kind_label` /
1681    /// `discriminant_label`) to name the axis the substrate already
1682    /// reaches for in the paired
1683    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1684    /// in every per-arm `UpgradeError` diagnostic that carries the
1685    /// kebab-case tag verbatim — the lisp author-surface term, not the
1686    /// Rust discriminant name.
1687    #[must_use]
1688    pub const fn lisp_form(&self) -> &'static str {
1689        match self {
1690            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1691            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1692            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1693            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1694            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1695        }
1696    }
1697
1698    /// Substrate-canonical per-`UpgradeInstruction` kebab-case wire-form
1699    /// discriminator every consumer that lands on the un-prefixed
1700    /// kebab byte-string (matching serde's
1701    /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive's
1702    /// per-variant tag output and the
1703    /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1704    /// fleet-catalog identity) reaches through — returns `"load-module"`
1705    /// / `"state-change"` / `"soft-purge"` / `"purge"` / `"restart"`,
1706    /// byte-for-byte the same five strings the JSON `"kind"` tag carries
1707    /// (per the sibling
1708    /// [`crate::tests::dispatcher_registration::reflection_round_trips_through_serde_tags`]
1709    /// pin) and the fleet-wide dispatcher-catalog registers under
1710    /// `"caixa.upgrade-instruction"` (per
1711    /// [`crate::tests::dispatcher_registration::variant_kinds_match_otp_appup_kebab`]).
1712    ///
1713    /// Distinct axis from the peer [`Self::lisp_form`] accessor, which
1714    /// returns the tatara-lisp author-surface form with the leading `:`
1715    /// prefix (`":load-module"` / `":state-change"` / `":soft-purge"` /
1716    /// `":purge"` / `":restart"`) that lands in `feira lint` per-
1717    /// instruction diagnostics and every
1718    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const's docstring.
1719    /// The two axes carry different bytes by design, not drift: the lisp
1720    /// form is the author-facing tag the caixa.lisp grep-and-fix
1721    /// workflow reaches for (`grep '(:load-module '` finds the offending
1722    /// entry verbatim), while [`Self::as_str`] is the wire-format byte-
1723    /// string every serde-serialized CR / [`std::fmt::Display`]-formatted
1724    /// diagnostic line / [`AsRef<str>`]-bound consumer / fleet-catalog
1725    /// identity converge onto — the same two-axis discipline the sibling
1726    /// [`crate::CaixaKind::as_str`] / [`crate::CaixaKind::wire_name`]
1727    /// pair (2aa6d23) documents on the top-level `:kind` closed-set
1728    /// discriminator, extended here onto the M2 OTP-appup
1729    /// per-instruction tag axis.
1730    ///
1731    /// Peer of the sibling closed-set typed enums' `as_str` /
1732    /// `as_suffix` canonical-projection accessors:
1733    /// [`crate::CaixaKind::as_str`] (6b1f4fb),
1734    /// [`crate::supervisor::RestartStrategy::as_str`] (09ffb2d),
1735    /// [`crate::supervisor::RestartPolicy::as_str`] (ccdf955),
1736    /// [`crate::aplicacao::PlacementStrategy::as_str`] (cc8f749),
1737    /// [`crate::aplicacao::RateLimitUnit::as_suffix`] (6bce03d) — the
1738    /// last closed-set typed enum on the caixa `:upgrade-from` surface
1739    /// to converge onto the substrate-canonical
1740    /// `(as_str, AsRef<str>, Display)` triple through one lifted
1741    /// `const fn` scalar accessor, so a future author-facing rebrand
1742    /// (a per-consumer disambiguation of the OTP-appup vocabulary, a
1743    /// hypothetical `:reload` collapse of `:load-module` under an
1744    /// Elixir/Phoenix hot-reload convergence, an M4-side rename of
1745    /// `:state-change` onto Erlang's own `code_change/3` verbatim) lands
1746    /// at one match arm — the paired [`std::fmt::Display`] impl and
1747    /// [`AsRef<str>`] impl route through this accessor by construction,
1748    /// so every consumer downstream of any of the three reaches the same
1749    /// per-arm byte-string in lockstep.
1750    ///
1751    /// `pub const fn` matches the peer accessors' const-context posture:
1752    /// downstream `const`-context callers (a module-scope
1753    /// `const _:() = assert!(<variant>.as_str().len() > 0)` invariant
1754    /// pin, a `const fn` per-instruction wire-shape audit table the M4
1755    /// admission webhook materializes at build time) reach the accessor
1756    /// through one dispatch on the substrate primitive without an
1757    /// intermediate non-`const` step. Returns `&'static str` (not
1758    /// `&str` bound to `&self`'s lifetime) so callers can stash the
1759    /// returned label in `&'static`-bounded positions (a static logger's
1760    /// format argument, a `HashMap<&'static str, _>` key, a `matches!`-
1761    /// style slice-of-`&'static str` accept-set) without re-borrowing
1762    /// through the instruction reference.
1763    #[must_use]
1764    pub const fn as_str(&self) -> &'static str {
1765        match self {
1766            Self::LoadModule { .. } => "load-module",
1767            Self::StateChange { .. } => "state-change",
1768            Self::SoftPurge { .. } => "soft-purge",
1769            Self::Purge { .. } => "purge",
1770            Self::Restart => "restart",
1771        }
1772    }
1773
1774    /// Validate the instruction's typed shape. Path existence is
1775    /// checked separately by [`crate::layout::StandardLayout`].
1776    ///
1777    /// The per-variant scalar the value-shape gates fire against is
1778    /// read through this method's two sibling accessors — the
1779    /// `String`-carrying axis via [`Self::declared_module`] (the
1780    /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1781    /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1782    /// carrying axis via [`Self::declared_path`] (the `StateChange`
1783    /// variant's tatara-lisp `:script`) — rather than the per-arm
1784    /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1785    /// Self::Purge { module }` pattern the module-axis previously
1786    /// open-coded and the per-arm `Self::StateChange { script }` the
1787    /// script-axis previously open-coded. Every scalar this enum
1788    /// carries now flows through one of the two `Option<&…>`
1789    /// accessors, so a future extension of either axis (a fifth
1790    /// module-bearing variant, an operator-side pre-parsed scalar
1791    /// cache the accessors materialize behind the same return
1792    /// contract, an M4 typed sub-slot the accessors could route
1793    /// alongside the existing scalar) migrates as a single edit on
1794    /// the accessor rather than a coordinated rewrite of every
1795    /// downstream value-shape gate. `Restart` (the only variant that
1796    /// carries neither scalar) falls through both `Option` checks and
1797    /// returns `Ok(())` — the terminal-fallback shape the
1798    /// [`Self::Restart`] variant doc pins.
1799    pub fn validate(&self) -> Result<(), UpgradeError> {
1800        if let Some(module) = self.declared_module() {
1801            return validate_module(self.lisp_form(), module);
1802        }
1803        if let Some(script) = self.declared_path() {
1804            // Delegate the four-arm cascade (empty / absolute /
1805            // parent-escape / non-`.lisp`-extension) to the lifted
1806            // [`crate::render::require_sandboxed_lisp_path`] helper —
1807            // same `Empty → Absolute → ParentEscape → NonLispExtension`
1808            // arm-ordering this method previously inlined verbatim,
1809            // now shared with [`crate::BehaviorSpec::validate`]'s
1810            // per-`:on-*`-callback gate so every author-supplied
1811            // tatara-lisp source path on every M2 typed slot consults
1812            // one gate, not two-and-counting verbatim copies of the
1813            // same four-arm cascade. Each closure wraps the tag in
1814            // the same `*Script` variant the original inline code
1815            // raised, so the diagnostic shape every caller depends
1816            // on (the `:state-change :script` self-locating error)
1817            // is preserved by construction. See
1818            // [`crate::render::require_sandboxed_lisp_path`] for the
1819            // smallest-scope-arm-fires-last ordering rationale.
1820            crate::render::require_sandboxed_lisp_path(
1821                script,
1822                || UpgradeError::EmptyScript,
1823                || UpgradeError::absolute_script(script),
1824                || UpgradeError::parent_escape_script(script),
1825                || UpgradeError::non_lisp_extension_script(script),
1826            )?;
1827        }
1828        // `Restart` (the only variant with no `Option<&…>`-carrying
1829        // scalar) falls through both accessor gates and returns
1830        // `Ok(())` — the terminal-fallback shape.
1831        Ok(())
1832    }
1833
1834    /// The `:module` scalar carried by this instruction — the
1835    /// K8s DNS-1123-label OTP-appup caixa-name reference every
1836    /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1837    /// variant declares against, and every author expects `feira lint`
1838    /// to name verbatim in per-instruction diagnostics. Returns `None`
1839    /// on [`Self::StateChange`] (which carries a `:script` — closed by
1840    /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1841    /// (which carries no data at all, the OTP terminal-fallback
1842    /// shape).
1843    ///
1844    /// Sibling in shape to [`Self::declared_path`] on the second and
1845    /// final scalar-carrying axis of [`UpgradeInstruction`]:
1846    /// `declared_path` closes the `PathBuf`-carrying arm
1847    /// (`StateChange`); `declared_module` closes the `String`-carrying
1848    /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1849    /// enum carries now routes through one of the two `Option<&…>`
1850    /// accessors — a caller that doesn't care which variant declared
1851    /// the scalar reads through one `if let Some(…)` rather than a
1852    /// per-variant pattern match. The pair is the enum-variant-
1853    /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1854    /// on the M3 side ([`crate::WitContract::source`] /
1855    /// [`crate::WitContract::destination`] /
1856    /// [`crate::WitContract::world_ref`] closing `:contratos`;
1857    /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1858    /// closing `:entrada`; [`crate::Membro::nome`] /
1859    /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1860    /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1861    /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1862    /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1863    /// closed OTP-shape supervisor family) — those peer accessors
1864    /// return a struct field verbatim; this pair unifies enum-
1865    /// variant-carried scalars into one accessor per typed axis.
1866    ///
1867    /// Byte-for-byte from the typed variant's own `String` storage;
1868    /// no cloning, no re-parsing. A future extension of the axis (an
1869    /// M4 typed sub-slot the module string is derived from, an
1870    /// operator-side pre-parsed caixa-name cache the accessor could
1871    /// materialize behind the same `&str` return contract, a fifth
1872    /// module-bearing OTP-appup variant the enum grows) migrates as
1873    /// a single caixa-core edit rather than a coordinated rewrite
1874    /// of every downstream module-axis consumer (currently
1875    /// [`Self::validate`]'s DNS-1123-label gate through
1876    /// [`validate_module`]; extensible to future consumers on the
1877    /// same axis without further per-variant match sites).
1878    #[must_use]
1879    pub const fn declared_module(&self) -> Option<&str> {
1880        match self {
1881            Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1882                Some(module.as_str())
1883            }
1884            Self::StateChange { .. } | Self::Restart => None,
1885        }
1886    }
1887
1888    /// If the instruction references an on-disk path, return it —
1889    /// used by the layout checker to verify the path resolves.
1890    ///
1891    /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1892    /// on the `String`-carrying axis: `declared_path` closes the
1893    /// `StateChange` arm's `:script`; `declared_module` closes the
1894    /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1895    /// they route every scalar this enum carries through one of two
1896    /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1897    /// gates dispatch on the accessor return rather than a per-variant
1898    /// pattern match on the enum shape itself.
1899    ///
1900    /// Four per-`UpgradeInstruction` consumers now key off this
1901    /// accessor's `PathBuf`-carrying axis:
1902    /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1903    /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1904    /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1905    /// within-entry
1906    /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1907    /// per-`StateChange` script-projection fan-out, and the cross-slot
1908    /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1909    /// :behavior` composition gate's per-`StateChange` detection loop
1910    /// — every downstream consumer of the `PathBuf`-carrying axis
1911    /// reaches through this one dispatch, so a future accessor
1912    /// extension (an M4 typed sub-slot the script path is derived from,
1913    /// an operator-side pre-resolved-path cache the accessor
1914    /// materializes behind the same `Option<&PathBuf>` return contract,
1915    /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1916    /// migrates as a single caixa-core edit rather than a coordinated
1917    /// rewrite of four call sites.
1918    #[must_use]
1919    pub const fn declared_path(&self) -> Option<&PathBuf> {
1920        match self {
1921            Self::StateChange { script } => Some(script),
1922            _ => None,
1923        }
1924    }
1925
1926    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1927    /// family arm-discriminator predicate every within-entry cross-
1928    /// instruction cleanup-facing gate keys off — true iff `self` is
1929    /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1930    /// named module until no process is running it, then GC) or
1931    /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1932    /// module immediately, without waiting for drain), the two OTP
1933    /// two-phase-code-load cleanup arms the closed-set enum's
1934    /// non-terminal / non-migration / non-load variants exhaust.
1935    /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1936    /// two-phase-load half, [`Self::StateChange`] on the
1937    /// `gen_server:code_change/3`-analog migration axis,
1938    /// [`Self::Restart`] on the OTP terminal-fallback shape)
1939    /// returns `false`.
1940    ///
1941    /// Prior to this lift the `Self::SoftPurge { module } |
1942    /// Self::Purge { module }` two-arm cleanup-family pattern-
1943    /// match sat inline at three within-entry cross-instruction
1944    /// gate sites, each hand-rolling its own copy of the union
1945    /// with no compile-time link back to the substrate primitive's
1946    /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1947    /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1948    /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1949    /// arriving before a preceding [`Self::LoadModule`]),
1950    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1951    /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1952    /// recording the first-encountered cleanup so a subsequent
1953    /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1954    /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1955    /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1956    /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1957    /// second cleanup targeting the same `:module`). Three open-
1958    /// coded per-arm-union pattern-matches that expressed no
1959    /// compile-time link back to the substrate primitive. A future
1960    /// fifth cleanup-shaped variant (a `Discard` variant the
1961    /// `code:delete/1` peer inspires that folds under the same
1962    /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1963    /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1964    /// cool-down policy grows a two-arm shape, an operator-side
1965    /// pre-resolved cleanup-decision cache the predicate could
1966    /// route through the same `bool` return contract) would have
1967    /// had to be threaded through every open-coded per-arm-union
1968    /// pattern-match in lockstep or one gate would silently
1969    /// classify the new arm outside the cleanup family while the
1970    /// peer gates classified it in (or vice versa) — a
1971    /// classification split across the three within-entry cross-
1972    /// instruction gates at build time that lands far from the
1973    /// source [`UpgradeInstruction`] declaration with no field
1974    /// naming which gate carries the drifted arm-set. Lifting the
1975    /// resolution to a typed predicate on the substrate primitive
1976    /// means every downstream cleanup-facing consumer of the
1977    /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1978    /// one typed dispatch — the resolver's arm-set migrates as a
1979    /// unit on any future arm addition composing under this
1980    /// predicate's `||` chain.
1981    ///
1982    /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1983    /// derive-generated [`Self::is_restart`] terminal-fallback
1984    /// arm-discriminator predicate on the same closed-set
1985    /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1986    /// family partition as one typed dispatch on the substrate
1987    /// primitive; `is_restart` on the single-arm terminal-
1988    /// fallback family, `is_cleanup` on the two-arm cleanup
1989    /// family), extended here from the single-arm case onto the
1990    /// two-arm arm-family union case. Composes through the
1991    /// [`gen_platform::IsVariant`]-derive-generated
1992    /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1993    /// predicates rather than an open-coded raw `matches!`
1994    /// pattern-match, so a future rebrand on either underlying
1995    /// per-arm classifier flows through this predicate's one
1996    /// body without a coordinated per-consumer rewrite across
1997    /// the three within-entry cross-instruction gates that route
1998    /// through it. Peer of the sibling per-`:contratos`
1999    /// shape-family union predicates [`crate::WitContract::is_http`] /
2000    /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
2001    /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
2002    /// per-shape WIT-prefix rule the substrate primitive's arm-
2003    /// family partition names as one typed dispatch) — the same
2004    /// "one typed dispatch on the substrate primitive, thin
2005    /// projections at each consumer" discipline extended onto the
2006    /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
2007    /// cleanup-family axis.
2008    ///
2009    /// The name `is_cleanup` maps directly onto the canonical
2010    /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
2011    /// `code:soft_purge/1` — wait until no process is running v1,
2012    /// then discard. (`code:purge/1` kills v1 immediately if you
2013    /// don't care.)" — the two `code:*_purge/1` operations are
2014    /// the two-phase-load contract's cleanup half, paired under
2015    /// one concept), and the peer [`Self::validate_cleanup_singularity`]
2016    /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
2017    /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
2018    /// reaches for the same "cleanup" vocabulary in identifier +
2019    /// diagnostic form.
2020    #[must_use]
2021    pub const fn is_cleanup(&self) -> bool {
2022        self.is_soft_purge() || self.is_purge()
2023    }
2024}
2025
2026/// [`std::fmt::Display`] routed through [`UpgradeInstruction::as_str`],
2027/// so the pretty-printed byte-string every consumer that formats the
2028/// per-`:upgrade-from :instructions` entry's OTP-appup tag as user-
2029/// facing text lands on (the future wasm-operator's
2030/// `install_release/1` per-instruction dispatch log line, the future
2031/// `feira lint --upgrade-from` per-entry annotation, an M4
2032/// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection body
2033/// naming the offending instruction's kind, an LSP hover projecting
2034/// the instruction kind onto a text-document diagnostic) reaches for
2035/// the same wire byte-string the un-`rename`d
2036/// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive emits
2037/// under the paired [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
2038/// tag key.
2039///
2040/// Peer of the sibling closed-set typed enums' `Display` route through
2041/// their `as_str` accessor: [`crate::CaixaKind`] (2aa6d23),
2042/// [`crate::supervisor::RestartStrategy`] (supervisor.rs),
2043/// [`crate::supervisor::RestartPolicy`] (supervisor.rs), and
2044/// [`crate::aplicacao::PlacementStrategy`] (aplicacao.rs) — the last
2045/// M2 OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2046/// surface to converge onto the `Display`-through-`as_str` discipline.
2047///
2048/// Deliberately routes through the wire-aligned
2049/// [`UpgradeInstruction::as_str`] axis (kebab-case, no `:` prefix),
2050/// not the tatara-lisp author-surface [`UpgradeInstruction::lisp_form`]
2051/// axis (kebab-case, with `:` prefix): the two axes carry different
2052/// bytes by design, and Rust convention pairs [`std::fmt::Display`]
2053/// with the wire byte-string every serde-carried CR / structured-log /
2054/// catalog identity reaches. The two-axis split is preserved
2055/// structurally by the pin
2056/// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
2057/// so a future accidental collapse (routing `Display` through
2058/// [`Self::lisp_form`] via a mistaken match-arm re-inlining) trips at
2059/// caixa-core test time rather than silently merging the two axes at
2060/// some future consumer's per-instruction dispatch step.
2061///
2062/// Discards the per-variant scalar data (`module: String` on
2063/// `LoadModule` / `SoftPurge` / `Purge`; `script: PathBuf` on
2064/// `StateChange`) by design — the `Display` axis is the *tag*
2065/// projection, not a full value dump; consumers wanting the field
2066/// scalar reach for [`Self::declared_module`] /
2067/// [`Self::declared_path`] on the sibling scalar-accessor family. The
2068/// `{:?}` [`std::fmt::Debug`] derive stays untouched for callers that
2069/// want the full variant + field rendering.
2070impl std::fmt::Display for UpgradeInstruction {
2071    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2072        f.write_str(self.as_str())
2073    }
2074}
2075
2076/// Substrate-canonical [`AsRef<str>`] projection on the M2 OTP-appup
2077/// per-instruction [`UpgradeInstruction`] closed-set typed enum —
2078/// routes through the same [`UpgradeInstruction::as_str`]
2079/// `pub const fn` scalar accessor the paired [`std::fmt::Display`]
2080/// impl and the un-`rename`d [`serde::Serialize`] derive already key
2081/// off, so any future consumer that binds an [`UpgradeInstruction`]
2082/// through the standard-library `impl AsRef<str>` bound (a deferred
2083/// wasm-operator per-instruction structured-log recorder that accepts
2084/// `impl AsRef<str>` at the `tracing::field::Value` `Str`-arm, a
2085/// [`std::collections::HashMap`] lookup keyed on the instruction wire
2086/// byte through `map.get::<str>(instr.as_ref())` on a future
2087/// per-instruction dispatch table an M4 admission webhook composes,
2088/// a [`std::process::Command::arg`] shell-out threading the instruction
2089/// tag through a deferred `feira upgrade-from --dry-run <kind>` verb)
2090/// reaches the same kebab-case wire byte-string the
2091/// [`Self::as_str`] accessor returns through one substrate-primitive
2092/// dispatch rather than an open-coded `.as_str()` projection at
2093/// every wire-up.
2094///
2095/// Peer of the sibling [`std::fmt::Display`] impl on the same
2096/// primitive — both delegate to the shared
2097/// [`UpgradeInstruction::as_str`] `pub const fn` accessor, so
2098/// `format!("{v}")`, `v.as_str()`, and
2099/// `<UpgradeInstruction as AsRef<str>>::as_ref(&v)` resolve to the
2100/// same byte-string per instance by construction. A future variant
2101/// rename or `#[serde(rename_all = "…")]` attribute-drift on the enum
2102/// reaches every one of the three paths (plus the wire-format
2103/// `Serialize` derive that already routes through the same kebab
2104/// vocabulary and the [`gen_platform::Discriminant`]-derived
2105/// [`Self::discriminant`] catalog identity) through exactly one
2106/// caixa-core edit — the [`Self::as_str`] match arms.
2107///
2108/// Same "route the trait impl through the substrate-primitive
2109/// accessor" discipline the sibling
2110/// [`crate::supervisor::RestartStrategy`] [`AsRef<str>`] impl
2111/// (63eb1a4), [`crate::supervisor::RestartPolicy`] [`AsRef<str>`]
2112/// impl (419ea81), [`crate::aplicacao::PlacementStrategy`]
2113/// [`AsRef<str>`] impl (d86edd2), [`crate::CaixaKind`]
2114/// [`AsRef<str>`] impl (cd2091f), [`crate::aplicacao::RateLimitUnit`]
2115/// [`AsRef<str>`] impl (d8136db), and [`crate::CaixaVersion`]
2116/// [`AsRef<str>`] impl (16d5c7e) carry — closes the substrate
2117/// primitive's [`AsRef<str>`] projection axis onto the last M2
2118/// OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2119/// surface, so every closed-set typed enum on the caixa typed
2120/// surface now carries the paired [`AsRef<str>`] +
2121/// [`fmt::Display`] + `as_str` triple.
2122///
2123/// Pinned load-bearing by
2124/// [`tests::upgrade_instruction_as_ref_str_routes_through_as_str_accessor`]
2125/// — any future silent detour that routes the impl through a
2126/// divergent projection (a per-arm inline `match self { … }`
2127/// re-inlining that opens a compile-time link to the un-lifted arm-
2128/// literal, a swap onto the [`Self::lisp_form`] tatara-lisp axis
2129/// that would collide the wire axis with the author-surface axis)
2130/// trips at caixa-core test time under `assert_eq!` rather than at a
2131/// downstream `impl AsRef<str>`-bound consumer's silent split.
2132impl AsRef<str> for UpgradeInstruction {
2133    fn as_ref(&self) -> &str {
2134        self.as_str()
2135    }
2136}
2137
2138/// Reject upgrade instruction `:module` values that aren't K8s
2139/// DNS-1123 labels. Thin wrapper around
2140/// [`crate::render::is_dns_1123_label`] that maps the shared
2141/// parser-shaped reason into the kind-tagged
2142/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
2143/// diagnostics, so the author can grep their caixa.lisp for the
2144/// offending `(:<kind> <module>)` form and fix it in one edit.
2145///
2146/// The contract — the same DNS-1123 label rule the K8s apiserver
2147/// enforces on every `metadata.name` / Service name / label value the
2148/// module name lands in. Each upgrade instruction's `:module` is a
2149/// reference to a caixa name (the wasm-engine resolves it through the
2150/// same `ComputeUnit` registry the operator manages), so the value must
2151/// match every downstream apiserver-side schema: the per-Servico
2152/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
2153/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
2154/// against the loaded-module table at hot-upgrade dispatch, and the
2155/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
2156/// per-module reference axis. Same trajectory as `:children :caixa`
2157/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
2158/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
2159/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
2160///
2161/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
2162/// variant before this predicate is consulted, mirroring
2163/// `validate_membro_caixa`'s empty-first cascade.
2164fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
2165    // Routes through the shared
2166    // [`crate::render::require_valid_dns_1123_label`] gate the peer
2167    // name axes each land on. The `kind: &'static str` field flows
2168    // through both error variants so the diagnostic names which
2169    // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
2170    // offending value came from.
2171    crate::render::require_valid_dns_1123_label(
2172        module,
2173        || UpgradeError::module_empty(kind),
2174        |reason| UpgradeError::module_invalid(kind, module, reason),
2175    )
2176}
2177
2178#[derive(Debug, Error, PartialEq, Eq)]
2179pub enum UpgradeError {
2180    #[error(
2181        ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
2182         consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
2183         `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
2184         every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
2185         the running version through `semver::Version::parse` and matches it against each entry's \
2186         `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
2187         SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2188         git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2189         requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2190    )]
2191    FromInvalid { from: String, reason: String },
2192    #[error(
2193        "upgrade instruction `{kind}` :module is empty (every appup module reference \
2194         must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2195         the instruction entirely)"
2196    )]
2197    ModuleEmpty { kind: &'static str },
2198    #[error(
2199        "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2200         {reason} (every appup module reference resolves to a caixa name, which lands \
2201         verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2202         creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2203         dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2204         axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2205         `\"cache-v2\"`)"
2206    )]
2207    ModuleInvalid {
2208        kind: &'static str,
2209        module: String,
2210        reason: String,
2211    },
2212    #[error("instruction's :script is empty")]
2213    EmptyScript,
2214    #[error(
2215        "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2216         root (Path::join would otherwise escape the project sandbox)",
2217        script.display()
2218    )]
2219    AbsoluteScript { script: PathBuf },
2220    #[error(
2221        "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2222         above the caixa root",
2223        script.display()
2224    )]
2225    ParentEscapeScript { script: PathBuf },
2226    #[error(
2227        ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2228         wasm-engine instantiator reads every migration script as tatara-lisp source through \
2229         `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2230         peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2231         other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2232         parser error far from the source caixa.lisp, with no field naming the offending \
2233         `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2234         terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2235         `\"lib/migrations/v01-to-v02.lisp\"`).",
2236        script.display()
2237    )]
2238    NonLispExtensionScript { script: PathBuf },
2239    #[error(
2240        ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2241         one matching block per running version (`release_handler:install_release/1` dispatches \
2242         on the loaded `:from` against the currently-running release), so two entries with the \
2243         same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2244         pick either set non-deterministically). Author one path per prior version; if two \
2245         distinct instruction sequences are needed, fold them into one ordered list under the \
2246         single matching `(:from {from:?} :instructions (…))` block."
2247    )]
2248    DuplicateFrom { from: String },
2249    #[error(
2250        ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2251         `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2252         greater than or equal to the caixa's own version is structurally unreachable \
2253         (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2254         the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2255         is never reached because the operator never runs a version greater than or equal to \
2256         the current one that it could then upgrade *to* the current one). Bump the caixa's \
2257         `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2258         *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2259         reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2260         version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2261         than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2262         values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2263         here as a self-upgrade no-op."
2264    )]
2265    FromNotBeforeVersao { from: String, versao: String },
2266    #[error(
2267        ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2268         exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2269         `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2270         instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2271         `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2272         component-model world incompatibility, irreversible state shape change), and the \
2273         fallback is terminal by construction (the operator restarts the pod and the new \
2274         version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2275         in both directions: if the typed instructions would succeed, `(:restart)` is \
2276         unreached; if they wouldn't, the typed instructions are dead because the operator \
2277         restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2278         (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2279         never repeated. If two distinct upgrade strategies are needed for the same prior \
2280         version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2281         dispatch picks exactly one block per running version) — keep the typed sequence; \
2282         the fallback restart is what the operator does on any typed-sequence failure \
2283         already."
2284    )]
2285    RestartNotExclusive {
2286        from: String,
2287        restart_count: usize,
2288        other_kinds: Vec<&'static str>,
2289    },
2290    #[error(
2291        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2292         `(:load-module …)` in its :instructions list — a state migration is the \
2293         gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2294         code, but the operator executes instructions in declared order, so this migration \
2295         runs while the only resident version is still the prior one (which expects the \
2296         pre-migration state shape). Load the new module first: author the canonical \
2297         `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2298         resident before its state migration runs.",
2299        script.display(),
2300        script.display()
2301    )]
2302    StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2303    #[error(
2304        ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2305         `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2306         code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2307         resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2308         then `code:soft_purge/1`), but the operator executes instructions in declared \
2309         order, so this cleanup runs while the only resident version is still the same \
2310         old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2311         mid-request), leaving no replacement to route in-flight or future requests \
2312         to. Load the new module first: author the canonical `(:load-module …) \
2313         (:state-change …) ({kind} {module:?})` order so the new code is resident \
2314         before the old code is drained or discarded."
2315    )]
2316    PurgeWithoutPriorLoad {
2317        from: String,
2318        kind: &'static str,
2319        module: String,
2320    },
2321    #[error(
2322        ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2323         more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2324         code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2325         wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2326         if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2327         them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2328         both, never repeated. systools-generated `.relup` files emit at most one purge per \
2329         module for this reason. A second cleanup on the same module is at best redundant (the \
2330         module is already gone after the first cleanup, so the second is a no-op or undefined \
2331         depending on the operator's handling of a non-resident-module purge request) and at \
2332         worst incoherent (mixing drain and discard semantics on one module suggests the author \
2333         wanted a fallback, but the operator runs declared instructions unconditionally — \
2334         fallback on cleanup failure is the operator's job, not authored into the entry). \
2335         Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2336         callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2337         can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2338         cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2339    )]
2340    DuplicateCleanup {
2341        from: String,
2342        module: String,
2343        kinds: Vec<&'static str>,
2344    },
2345    #[error(
2346        ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2347         once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2348         `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2349         'old'.\"), and the instruction binds the named wasm component once: the operator's \
2350         dispatch table reads the module name and brings up the corresponding component \
2351         alongside the running version. systools-generated `.relup` files emit at most one \
2352         `load_module` per module per upgrade step for this reason. A second `(:load-module \
2353         {module:?})` instruction has no observable semantic relative to the first (the \
2354         component is already resident) — either dead code (copy-pasted load line) or a typo \
2355         masking a distinct module the author intended to load alongside (renamed both to \
2356         {module:?} by mistake), leaving the second module silently absent from the entry. \
2357         Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2358         versions need loading alongside the running one, name them distinctly (e.g. \
2359         `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2360    )]
2361    DuplicateLoadModule { from: String, module: String },
2362    #[error(
2363        ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2364         once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2365         \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2366         state shape into the current-version shape: a one-shot transition, not a step that \
2367         composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2368         per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2369         callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2370         the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2371         author intended two distinct migration scripts) and at worst silent state corruption \
2372         (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2373         counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2374         Author one `(:state-change {})` per migration script per entry; if two distinct state \
2375         transitions are needed (e.g. one module's schema *and* another module's projection), \
2376         name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2377        script.display(),
2378        script.display(),
2379        script.display(),
2380        script.display()
2381    )]
2382    DuplicateStateChange { from: String, script: PathBuf },
2383    #[error(
2384        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2385         {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2386         gen_server:code_change/3 analog and folds the prior-version state shape into the \
2387         current shape, but the prior version's state only exists while the prior code is \
2388         still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2389         analogs and drain or discard that prior code. The operator executes instructions in \
2390         declared order, so a cleanup ahead of a state-change has already drained the prior \
2391         module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2392         the migration script runs, leaving the script either no-op (no prior-version state \
2393         left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2394         canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2395         `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2396         {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2397         strictly between load and cleanup. Author the canonical `(:load-module …) \
2398         (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2399         migration runs against the prior-version state before the cleanup drains it.",
2400        script.display(),
2401        script.display()
2402    )]
2403    StateChangeAfterCleanup {
2404        from: String,
2405        script: PathBuf,
2406        prior_cleanup_kind: &'static str,
2407        prior_cleanup_module: String,
2408    },
2409    #[error(
2410        ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2411         declare `:behavior :on-state-change` — the per-version migration script is the \
2412         gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2413         hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2414         realizes the composition by invoking the running gen_server's code_change/3 callback \
2415         during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2416         composition into two typed slots, the per-version migration logic in this \
2417         `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2418         `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2419         verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2420         migration during hot upgrades\"). The missing callback leaves the per-version script \
2421         with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2422         callback at the migration step, finds it absent, and either fails the upgrade \
2423         mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2424         current version stays load-bearing\") or silently skips the migration leaving the \
2425         new code running against unmigrated prior-version state. Add the callback: \
2426         `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2427         path) alongside the existing `(:state-change {})` instruction (the per-version \
2428         script). If the upgrade truly carries no state migration, drop the `(:state-change \
2429         …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2430         migration — is the canonical shape).",
2431        script.display(),
2432        script.display()
2433    )]
2434    StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2435}
2436
2437// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2438// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2439// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2440// two-slot struct-variant wire-up sites at
2441// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2442// / `script` from `instr.declared_path()`),
2443// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2444// (`self.prior_versao()` / `script.as_path()` from
2445// `instr.declared_path()`), and
2446// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2447// / `script` from `instr.declared_path()`) onto one substrate primitive
2448// per typed variant — the paired `{ from: String, script: PathBuf }`
2449// two-slot sibling on [`UpgradeError`] of the peer
2450// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2451// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2452// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2453// (8580068, 4 variants on `{ de, para }`),
2454// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2455// `{ de, para, wit, expected }`),
2456// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2457// variants on `{ <field>: String, reason: String }`), and
2458// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2459// variants on `{ de, para, <field>: String, reason: String }`) on the
2460// sibling `AplicacaoError` envelopes, and the peer
2461// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2462// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2463// (0419438, 4 variants on `{ caixa, kind, slots }`),
2464// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2465// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2466// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2467// `LayoutError` envelopes, plus the peer
2468// [`crate::limits::limits_codec_value_only_ctors!`] /
2469// [`crate::limits::limits_codec_value_byte_ctors!`] /
2470// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2471// wire-ups) on the sibling `LimitsError` envelopes.
2472//
2473// Each of the three wire-up sites on this shape opens the identical
2474// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2475// script: <script>.to_path_buf() }` struct-literal against a local
2476// `prior_versao()` and `declared_path()` accessor pair — the exact
2477// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2478// names as a bug, on the same altitude the peer `SupervisorError` /
2479// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2480// closed on their sibling envelopes. The three variants share one
2481// `{ from: String, script: PathBuf }` shape, so the fold routes each
2482// wire-up site through one dispatch per typed variant.
2483//
2484// The macro below generates one `#[must_use]` inherent constructor per
2485// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2486// Self`, so every wire-up site collapses onto one dispatch:
2487// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2488// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2489// uniform two-field construction (`from.to_string()` /
2490// `script.to_path_buf()`) is spelled once — inside the macro — rather
2491// than at every wire-up site. The `&Path` parameter accepts both
2492// `&Path` (from `script.as_path()` at the uniqueness gate) and
2493// `&PathBuf` (from `instr.declared_path()` at the ordering /
2494// callback-declaration gates, via Deref coercion), so every existing
2495// wire-up threads through the ctor without a pre-conversion.
2496//
2497// Every future consumer that wants to construct one of these three
2498// variants outside the three in-crate `UpgradeFromEntry` /
2499// `validate_state_change_on_state_change_callback` gates (a deferred
2500// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2501// re-checker at hot-upgrade dispatch time, a future
2502// `feira validate --upgrade-from` per-caixa admission verb re-checking
2503// the three axes, a per-`Caixa` overlay resolver rejecting an
2504// ordering / uniqueness / callback-declaration invariant against a
2505// cluster-local snapshot) now reaches each variant through one call
2506// rather than re-inlining the three-line struct-literal in lockstep
2507// with the three in-crate wire-up sites.
2508macro_rules! upgrade_from_script_ctors {
2509    ($($ctor:ident => $variant:ident),* $(,)?) => {
2510        impl UpgradeError {
2511            $(
2512                #[doc = concat!(
2513                    "Construct an [`UpgradeError::",
2514                    stringify!($variant),
2515                    "`] naming the offending `(:from <prior-versao>)` and ",
2516                    "`(:state-change <script>)` pair. Folds the uniform ",
2517                    "`Self::",
2518                    stringify!($variant),
2519                    " { from: from.to_string(), script: script.to_path_buf() }` ",
2520                    "two-field struct-literal onto one substrate primitive so ",
2521                    "every wire-up on this variant reads through one dispatch ",
2522                    "rather than the pre-lift three-line open-coded block. The ",
2523                    "`from` string threads verbatim from ",
2524                    "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2525                    "from [`UpgradeInstruction::declared_path`] at the call site."
2526                )]
2527                #[must_use]
2528                pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2529                    Self::$variant {
2530                        from: from.to_string(),
2531                        script: script.to_path_buf(),
2532                    }
2533                }
2534            )*
2535        }
2536    };
2537}
2538
2539upgrade_from_script_ctors! {
2540    state_change_without_prior_load => StateChangeWithoutPriorLoad,
2541    duplicate_state_change => DuplicateStateChange,
2542    state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2543}
2544
2545// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2546// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2547// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2548// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2549// onto one substrate primitive per typed variant — the paired
2550// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2551// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2552// `{ from: String, script: PathBuf }`) two-slot family on the same
2553// envelope, and of the peer
2554// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2555// variants on `{ caixa: String }`) and
2556// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2557// `{ nome: String }`) single-slot families on the sibling
2558// `SupervisorError` / `DepError` envelopes, and of the peer
2559// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2560// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2561// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2562// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2563// variants on `{ <field>: String, reason: String }`), and
2564// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2565// variants on `{ de, para, <field>: String, reason: String }`) on the
2566// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2567// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2568// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2569// [`crate::LayoutError::missing_entry`] 1b09f9d;
2570// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2571// `LimitsError` codec families (81c856c), and the sibling
2572// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2573// `{ nome, caminho }`) two-slot family.
2574//
2575// The three wire-up sites this fold closes are the three closures
2576// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2577// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2578// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2579// passed to [`crate::render::require_sandboxed_lisp_path`] at
2580// [`UpgradeInstruction::validate`] — each opens the identical
2581// `UpgradeError::<Variant> { script: script.clone() }` three-line
2582// struct-literal against the same `script: &PathBuf` local threaded
2583// from [`UpgradeInstruction::declared_path`], the exact "same block
2584// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2585// bug. The three variants share one `{ script: PathBuf }` shape, so
2586// the fold routes each closure through one dispatch per typed variant.
2587// The sibling `EmptyScript` unit-variant on the same envelope stays on
2588// its pre-lift open-coded shape — it carries no `script` field (the
2589// offending `:script` value *is* the empty path this variant catches),
2590// so the uniform `fn(script: &Path) -> Self` signature this macro
2591// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2592// closure is already a one-liner. This is the second fold family on
2593// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2594// two-slot family established in 8e67041, which explicitly named this
2595// `{ script: PathBuf }` single-slot family as the next fold to land
2596// on the envelope; per that commit's coverage roster, both of the two
2597// most-populated shapes on `UpgradeError` — the two-slot
2598// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2599//
2600// The macro below generates one `#[must_use]` inherent constructor per
2601// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2602// every closure collapses onto one dispatch:
2603// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2604// struct-literal on the same `&Path` fixture. The uniform one-field
2605// construction (`script.to_path_buf()`) is spelled once — inside the
2606// macro — rather than at every wire-up site. The `&Path` parameter
2607// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2608// `instr.declared_path()` at the three closures, via Deref coercion),
2609// so every existing closure threads through the ctor without a
2610// pre-conversion.
2611//
2612// Every future consumer that wants to construct one of these three
2613// variants outside the three in-crate closures (a deferred
2614// wasm-operator's `install_release/1` per-instruction script-shape
2615// re-checker at hot-upgrade dispatch time, a future
2616// `feira validate --upgrade-from` per-caixa admission verb re-checking
2617// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2618// an author-supplied `:state-change :script` against a cluster-local
2619// snapshot) now reaches each variant through one call rather than
2620// re-inlining the three-line struct-literal in lockstep with the three
2621// in-crate closure sites.
2622macro_rules! upgrade_script_only_ctors {
2623    ($($ctor:ident => $variant:ident),* $(,)?) => {
2624        impl UpgradeError {
2625            $(
2626                #[doc = concat!(
2627                    "Construct an [`UpgradeError::",
2628                    stringify!($variant),
2629                    "`] naming the offending `(:state-change <script>)`. ",
2630                    "Folds the uniform `Self::",
2631                    stringify!($variant),
2632                    " { script: script.to_path_buf() }` one-field ",
2633                    "struct-literal onto one substrate primitive so every ",
2634                    "closure passed to ",
2635                    "[`crate::render::require_sandboxed_lisp_path`] at ",
2636                    "[`UpgradeInstruction::validate`] on this variant reads ",
2637                    "through one dispatch rather than the pre-lift three-line ",
2638                    "open-coded block. The `script` path threads verbatim ",
2639                    "from [`UpgradeInstruction::declared_path`] at the call ",
2640                    "site."
2641                )]
2642                #[must_use]
2643                pub fn $ctor(script: &std::path::Path) -> Self {
2644                    Self::$variant {
2645                        script: script.to_path_buf(),
2646                    }
2647                }
2648            )*
2649        }
2650    };
2651}
2652
2653upgrade_script_only_ctors! {
2654    absolute_script => AbsoluteScript,
2655    parent_escape_script => ParentEscapeScript,
2656    non_lisp_extension_script => NonLispExtensionScript,
2657}
2658
2659// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2660// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2661// <value>.to_string() }` two-slot struct-variant wire-up sites at
2662// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2663// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2664// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2665// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2666// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2667// self.prior_versao().to_string(), module: module.to_string() });`),
2668// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2669// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2670// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2671// });`) onto one substrate-primitive family per typed variant — the
2672// missing paired two-slot rung on the `UpgradeError`-side four-family
2673// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2674// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2675// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2676// script: PathBuf }`), and mirror-symmetric sibling of the peer
2677// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2678// String, <axis>: String }` fold on the `DepError` envelope — same
2679// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2680// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2681// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2682// carries the offending prior-version `:from` verbatim so the author
2683// can grep their caixa.lisp for the offending `(:from "<value>")` /
2684// `(:load-module …)` / `:versao` block in one edit). The three
2685// variants share the same `{ from: String, <axis>: String }` two-slot
2686// shape: the `from` field names the offending per-`:upgrade-from` block's
2687// prior-version tag the diagnostic points the author back at, and the
2688// middle `<axis>: String` field carries the offending per-envelope axis
2689// value verbatim (`reason` on `FromInvalid` carries the wrapped
2690// `semver::Version::parse` error message that pinpoints why the tag
2691// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2692// own current-`:versao` the entry's `:from` failed to precede; `module`
2693// on `DuplicateLoadModule` carries the caixa name the second
2694// `(:load-module …)` instruction re-loaded within the same entry).
2695// The middle axis-field name differs across variants (`reason` /
2696// `versao` / `module`) so the ctor family below takes the axis field
2697// name as a macro parameter (`$axis:ident`) alongside the ctor +
2698// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2699// -> Self` inherent constructor per typed variant that spells the
2700// uniform two-field construction (`from.to_string()` /
2701// `<axis>.to_string()`) exactly once.
2702//
2703// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2704// variants on `{ from: String, script: PathBuf }`) two-slot family on
2705// the same envelope — both key off the same `from: String` axis at the
2706// same per-`:upgrade-from :from`-owned altitude; this family carries the
2707// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2708// carrier) where the script-slot family carries the owned-`PathBuf`
2709// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2710// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2711// same envelope, of the sibling
2712// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2713// variants on `{ caixa: String }`) and
2714// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2715// `{ nome: String }`) single-slot families on the sibling
2716// `SupervisorError` / `DepError` envelopes, and of the peer
2717// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2718// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2719// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2720// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2721// variants on `{ <field>: String, reason: String }`),
2722// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2723// variants on `{ caixa: String }`),
2724// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2725// on `{ path: String }`), and
2726// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2727// variants on `{ de, para, <field>: String, reason: String }`) on the
2728// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2729// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2730// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2731// [`crate::LayoutError::missing_entry`] 1b09f9d;
2732// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2733// `LimitsError` codec families (81c856c), the sibling
2734// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2735// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2736// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2737// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2738// `{ nome, list: &'static str }`), and
2739// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2740// `{ nome, <axis>: String, reason: String }`) families.
2741//
2742// Each of the three wire-up sites on this shape opens the identical
2743// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2744// <value>.to_string() }` four-line struct-literal against a local
2745// `(prior_versao(), <axis-value>)` pair threaded from
2746// [`UpgradeFromEntry::prior_versao`] (or, at the
2747// [`validate_upgrade_from_against_versao`] site, directly from the
2748// caller-supplied `versao: &str` argument) — the exact "same block
2749// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2750// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2751// / `upgrade_script_only_ctors!` families closed on the sibling
2752// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2753// axis-field discriminators are the only things that vary between them;
2754// the rest of the struct-literal is a byte-for-byte re-inline.
2755//
2756// The macro below generates one `#[must_use]` inherent constructor per
2757// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2758// every wire-up site collapses onto one dispatch:
2759// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2760// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2761// parameters accept `&str` literals and `&String` (via Deref coercion)
2762// so every existing wire-up threads through the ctor without a
2763// pre-conversion.
2764//
2765// Every future consumer that wants to construct one of these three
2766// variants outside the three in-crate `UpgradeFromEntry::validate` /
2767// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2768// gates (a deferred wasm-operator's `install_release/1` per-entry
2769// `:from`-parse / per-`:load-module` singularity / per-entry
2770// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2771// `feira validate --upgrade-from` per-caixa admission verb re-checking
2772// the three axes, a per-`Caixa` overlay resolver rejecting a
2773// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2774// invariant against a cluster-local snapshot) now reaches each variant
2775// through one call rather than re-inlining the four-line struct-literal
2776// in lockstep with the three in-crate wire-up sites.
2777macro_rules! upgrade_from_axis_ctors {
2778    ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2779        impl UpgradeError {
2780            $(
2781                #[doc = concat!(
2782                    "Construct an [`UpgradeError::",
2783                    stringify!($variant),
2784                    "`] naming the offending `(:from <prior-versao>)` and ",
2785                    "the offending `:", stringify!($axis), "` axis value. ",
2786                    "Folds the uniform `Self::",
2787                    stringify!($variant),
2788                    " { from: from.to_string(), ",
2789                    stringify!($axis),
2790                    ": ",
2791                    stringify!($axis),
2792                    ".to_string() }` two-field struct-literal onto one ",
2793                    "substrate primitive so every in-crate wire-up on ",
2794                    "this variant reads through one dispatch rather than ",
2795                    "the pre-lift four-line open-coded block. Both `from: ",
2796                    "&str` and `",
2797                    stringify!($axis),
2798                    ": &str` parameters accept `&str` literals and ",
2799                    "`&String` (via Deref coercion) so every existing ",
2800                    "wire-up threads through the ctor without a pre-",
2801                    "conversion."
2802                )]
2803                #[must_use]
2804                pub fn $ctor(from: &str, $axis: &str) -> Self {
2805                    Self::$variant {
2806                        from: from.to_string(),
2807                        $axis: $axis.to_string(),
2808                    }
2809                }
2810            )*
2811        }
2812    };
2813}
2814
2815upgrade_from_axis_ctors! {
2816    from_invalid => FromInvalid { reason },
2817    from_not_before_versao => FromNotBeforeVersao { versao },
2818    duplicate_load_module => DuplicateLoadModule { module },
2819}
2820
2821// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2822// entry.prior_versao().to_string() }` one-slot struct-literal inside
2823// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2824// one substrate primitive on the [`UpgradeError`] envelope, projecting
2825// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2826// on the substrate primitive. The `DuplicateFrom` variant is the last
2827// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2828// every peer envelope shape (`{ script: PathBuf }` one-slot via
2829// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2830// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2831// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2832// 8e67041) already reads through one substrate-primitive dispatch, so
2833// this fold closes the last one-off single-slot on the envelope.
2834//
2835// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2836// (b30edfe) on the paired [`WitContract`] projection — same
2837// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2838// through the substrate primitive's own scalar accessor rather than
2839// re-inlining the `.to_string()` at the call site. Extended here onto
2840// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2841// M2 companion of the M3 mesh-slot accessors (see
2842// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2843// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2844// 4a32abf, and the [`crate::WitContract::{source, destination,
2845// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2846// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2847//
2848// The one wire-up site this fold closes opens the identical
2849// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2850// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2851// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2852// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2853// names as a bug, on the same altitude the peer `contrato_self_loop`
2854// closed on the sibling `{ caixa: String, wit: String }` two-slot
2855// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2856// parameter accepts the borrowed entry verbatim so the wire-up site
2857// threads through the ctor without a pre-projection — the ctor body
2858// spells the paired `prior_versao().to_string()` projection once.
2859//
2860// Every future consumer that wants to construct this variant outside
2861// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2862// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2863// re-checker at hot-upgrade dispatch time rejecting a second entry
2864// with the same prior-versao tag, a future `feira validate --upgrade-
2865// from` per-caixa admission verb re-running the cross-entry duplicate
2866// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2867// supplied duplicate `(:from "<value>")` against a cluster-local
2868// snapshot — now reaches the variant through one call rather than
2869// re-inlining the three-line struct-literal in lockstep with the one
2870// in-crate wire-up site.
2871impl UpgradeError {
2872    /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2873    /// duplicate `(:from <prior-versao>)` entry, projecting through the
2874    /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2875    /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2876    /// from: entry.prior_versao().to_string() }` one-field struct-literal
2877    /// onto one substrate primitive so every wire-up on this variant
2878    /// reads through one dispatch, matching the sibling
2879    /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2880    /// substrate-primitive-projection ctor's shape on the peer
2881    /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2882    /// parameter accepts the borrowed entry verbatim so the paired
2883    /// `prior_versao().to_string()` projection is spelled once — inside
2884    /// the ctor body — rather than at every wire-up site.
2885    #[must_use]
2886    pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2887        Self::DuplicateFrom {
2888            from: entry.prior_versao().to_string(),
2889        }
2890    }
2891
2892    /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2893    /// offending `(:from <prior-versao>)` entry, the offending cleanup
2894    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2895    /// its `:module` target. Folds the uniform
2896    /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2897    /// module: module.to_string() }` three-field struct-literal onto one
2898    /// substrate primitive so every wire-up on this sole-variant
2899    /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2900    /// through one dispatch rather than the pre-lift seven-line
2901    /// open-coded block.
2902    ///
2903    /// The `from: &str` parameter accepts `&str` literals and `&String`
2904    /// via Deref coercion so the sole in-crate wire-up site threads
2905    /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2906    /// pre-conversion. The `kind: &'static str` parameter accepts the
2907    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2908    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2909    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2910    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2911    /// re-projection at the ctor path. The `module: &str` parameter
2912    /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
2913    /// via `.expect("is_cleanup() implies declared_module() is Some")`
2914    /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
2915    /// `Some` composition pin at
2916    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2917    /// makes the `.expect(…)` structurally infallible at build time.
2918    ///
2919    /// Peer of the sibling one-off standalone-ctor
2920    /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
2921    /// String }` envelope on the same `UpgradeError` envelope, and of
2922    /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
2923    /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
2924    /// variant standalone ctor on the peer `AplicacaoError` envelope.
2925    /// Closes the last unlifted `{ from: String, kind: &'static str,
2926    /// module: String }` three-slot open-coded struct-literal wire-up
2927    /// on the OTP-appup load-before-cleanup ordering axis, sibling of
2928    /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
2929    /// (41d08db, three variants on `{ from: String, <axis>: String }`)
2930    /// on the paired ordering / uniqueness / callback-declaration axes,
2931    /// and of the peer standalone [`UpgradeError::duplicate_from`]
2932    /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
2933    /// `:from` gate. Every future consumer that raises this refusal
2934    /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
2935    /// wasm-operator's `install_release/1` per-entry load-before-cleanup
2936    /// re-checker at hot-upgrade dispatch time, a future
2937    /// `feira validate --upgrade-from` per-caixa admission verb
2938    /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
2939    /// overlay resolver rejecting a cluster-local `:soft-purge` /
2940    /// `:purge` overlay lacking a preceding `:load-module` — reaches
2941    /// the variant through one call rather than re-inlining the
2942    /// seven-line struct-literal in lockstep with the sole in-crate
2943    /// wire-up site.
2944    #[must_use]
2945    pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
2946        Self::PurgeWithoutPriorLoad {
2947            from: from.to_string(),
2948            kind,
2949            module: module.to_string(),
2950        }
2951    }
2952
2953    /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
2954    /// offending `(:from <prior-versao>)` entry, the offending
2955    /// `(:state-change …)` `:script` path, and the prior cleanup
2956    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
2957    /// `:module` target. Folds the uniform
2958    /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
2959    /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
2960    /// prior_cleanup_module.to_string() }` four-field struct-literal
2961    /// onto one substrate primitive so every wire-up on this sole-
2962    /// variant migrate-after-cleanup ordering-refusal envelope reads
2963    /// through one dispatch rather than the pre-lift seven-line open-
2964    /// coded block. Closes the last unlifted `{ from: String, script:
2965    /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
2966    /// String }` four-slot open-coded struct-literal wire-up on the
2967    /// OTP-appup migrate-before-cleanup ordering axis, filling the
2968    /// missing four-slot rung on the `UpgradeError`-side ctor-family
2969    /// ladder alongside the sibling one-slot
2970    /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
2971    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2972    /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
2973    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
2974    /// families, and the one-slot [`upgrade_script_only_ctors!`]
2975    /// (7468ca9) family. Sole in-crate wire-up site is inside
2976    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
2977    /// migrate-family sticky-latch dispatch — the third of three
2978    /// within-entry cross-instruction OTP-appup ordering gates the
2979    /// module doc pins (`validate_state_change_ordering` on the load →
2980    /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
2981    /// `state_change_without_prior_load`; `validate_purge_ordering` on
2982    /// the load → cleanup boundary via `purge_without_prior_load`;
2983    /// `validate_state_change_before_cleanup` on the migrate → cleanup
2984    /// boundary via this ctor — now).
2985    ///
2986    /// The `from: &str` parameter accepts `&str` literals and `&String`
2987    /// via Deref coercion so the sole in-crate wire-up site threads
2988    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2989    /// without a pre-conversion. The `script: &std::path::Path`
2990    /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
2991    /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
2992    /// via Deref coercion) so the wire-up threads the sticky-latch
2993    /// script projection through the ctor without a pre-conversion; the
2994    /// uniform `script.to_path_buf()` one-field construction is spelled
2995    /// once — inside the ctor body — rather than at every wire-up site.
2996    /// The `prior_cleanup_kind: &'static str` parameter accepts the
2997    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2998    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2999    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3000    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3001    /// re-projection at the ctor path. The `prior_cleanup_module: &str`
3002    /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
3003    /// returns via `.expect("is_cleanup() implies declared_module() is
3004    /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
3005    /// is-`Some` composition pin at
3006    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3007    /// makes the `.expect(…)` structurally infallible at build time.
3008    ///
3009    /// Every future consumer that raises this refusal outside
3010    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
3011    /// deferred wasm-operator's `install_release/1` per-entry
3012    /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
3013    /// a future `feira validate --upgrade-from` per-caixa admission verb
3014    /// re-running the migrate-before-cleanup gate on demand, a
3015    /// per-`Caixa` overlay resolver rejecting a cluster-local
3016    /// `:state-change` overlay authored after a `:soft-purge` /
3017    /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
3018    /// webhook re-checking a per-`:upgrade-from`-patched candidate
3019    /// before the migrate-before-cleanup gate re-fires — reaches the
3020    /// variant through one call rather than re-inlining the seven-line
3021    /// struct-literal in lockstep with the sole in-crate wire-up site.
3022    #[must_use]
3023    pub fn state_change_after_cleanup(
3024        from: &str,
3025        script: &std::path::Path,
3026        prior_cleanup_kind: &'static str,
3027        prior_cleanup_module: &str,
3028    ) -> Self {
3029        Self::StateChangeAfterCleanup {
3030            from: from.to_string(),
3031            script: script.to_path_buf(),
3032            prior_cleanup_kind,
3033            prior_cleanup_module: prior_cleanup_module.to_string(),
3034        }
3035    }
3036
3037    /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
3038    /// offending `(:from <prior-versao>)` entry, the colliding `:module`
3039    /// target, and the ordered pair of colliding cleanup `:kind` lisp-
3040    /// forms (`:soft-purge` / `:purge`). Folds the uniform
3041    /// `Self::DuplicateCleanup { from: from.to_string(), module:
3042    /// module.to_string(), kinds }` three-field struct-literal onto one
3043    /// substrate primitive so every wire-up on this sole-variant within-
3044    /// entry per-module cleanup-singularity refusal envelope reads
3045    /// through one dispatch rather than the pre-lift five-line open-coded
3046    /// block. Closes the last unlifted `{ from: String, module: String,
3047    /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
3048    /// wire-up on the OTP-appup per-module cleanup-singularity axis,
3049    /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
3050    /// family ladder alongside the sibling three-slot
3051    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3052    /// ctor on the paired within-entry load → cleanup ordering axis, the
3053    /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
3054    /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
3055    /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
3056    /// standalone ctor on the migrate → cleanup boundary, the two-slot
3057    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3058    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3059    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3060    /// Sole in-crate wire-up site is inside
3061    /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
3062    /// cleanup-family dedup arm.
3063    ///
3064    /// The `from: &str` parameter accepts `&str` literals and `&String`
3065    /// via Deref coercion so the sole in-crate wire-up threads
3066    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3067    /// without a pre-conversion. The `module: &str` parameter takes the
3068    /// `&str` [`UpgradeInstruction::declared_module`] returns via
3069    /// `.expect("is_cleanup() implies declared_module() is Some")` at the
3070    /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
3071    /// composition pin at
3072    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3073    /// makes the `.expect(…)` structurally infallible at build time. The
3074    /// `kinds: Vec<&'static str>` parameter takes the ordered pair
3075    /// `vec![prior_kind, kind]` built at the caller from the two
3076    /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
3077    /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3078    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
3079    /// primitive `&'static str` projection the paired three-slot
3080    /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
3081    /// sibling load → cleanup ordering axis.
3082    ///
3083    /// Every future consumer that raises this refusal outside
3084    /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
3085    /// wasm-operator's `install_release/1` per-entry per-module
3086    /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
3087    /// future `feira validate --upgrade-from` per-caixa admission verb
3088    /// re-running the singularity pass on demand, a per-`Caixa` overlay
3089    /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
3090    /// overlay that collides with a base-entry cleanup on the same
3091    /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3092    /// re-checking a per-`:upgrade-from`-patched candidate before the
3093    /// singularity gate re-fires — reaches the variant through one call
3094    /// rather than re-inlining the five-line struct-literal in lockstep
3095    /// with the sole in-crate wire-up site.
3096    #[must_use]
3097    pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
3098        Self::DuplicateCleanup {
3099            from: from.to_string(),
3100            module: module.to_string(),
3101            kinds,
3102        }
3103    }
3104
3105    /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
3106    /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
3107    /// instruction count, and the ordered list of non-`:restart`
3108    /// instruction lisp-forms the entry mixed with the terminal fallback.
3109    /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
3110    /// restart_count, other_kinds }` three-field struct-literal onto one
3111    /// substrate primitive so every wire-up on this sole-variant within-
3112    /// entry `(:restart)`-exclusivity refusal envelope reads through one
3113    /// dispatch rather than the pre-lift five-line open-coded block. Closes
3114    /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
3115    /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
3116    /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
3117    /// the last-remaining open-coded emission site the sibling
3118    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
3119    /// the natural next lift on the `UpgradeError` envelope. Fills a peer
3120    /// three-slot rung on the `UpgradeError`-side ctor-family ladder
3121    /// alongside the sibling three-slot
3122    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3123    /// ctor on the paired within-entry load → cleanup ordering axis and
3124    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
3125    /// the per-module cleanup-singularity axis, the one-slot
3126    /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
3127    /// cross-entry duplicate-`:from` gate, the four-slot
3128    /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
3129    /// ctor on the migrate → cleanup boundary, the two-slot
3130    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3131    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3132    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3133    /// Sole in-crate wire-up site is inside
3134    /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
3135    /// arm.
3136    ///
3137    /// The `from: &str` parameter accepts `&str` literals and `&String`
3138    /// via Deref coercion so the sole in-crate wire-up threads
3139    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3140    /// without a pre-conversion. The `restart_count: usize` parameter
3141    /// takes the observed `(:restart)` occurrence count built at the
3142    /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
3143    /// — the same `IsVariant`-derived arm-discriminator dispatch the
3144    /// paired `other_kinds` projection routes through — so the diagnostic
3145    /// surfaces the duplication mode unambiguously even when `other_kinds`
3146    /// is empty (the `((:restart) (:restart))` shape the sibling
3147    /// `validate_rejects_restart_duplicated` test pins with
3148    /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
3149    /// Vec<&'static str>` parameter takes the ordered list of non-
3150    /// `:restart` instruction lisp-forms built at the caller from
3151    /// `instructions.iter().filter(|i| !i.is_restart()).map(
3152    /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
3153    /// primitive `&'static str` projection the peer three-slot
3154    /// [`UpgradeError::purge_without_prior_load`] /
3155    /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
3156    /// within-entry cleanup axes.
3157    ///
3158    /// Every future consumer that raises this refusal outside
3159    /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
3160    /// wasm-operator's `install_release/1` per-entry `(:restart)`-
3161    /// exclusivity re-checker at hot-upgrade dispatch time, a future
3162    /// `feira validate --upgrade-from` per-caixa admission verb re-running
3163    /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
3164    /// rejecting a cluster-local `(:restart)` overlay that mixes with a
3165    /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
3166    /// CR admission webhook re-checking a per-`:upgrade-from`-patched
3167    /// candidate before the exclusivity gate re-fires — reaches the
3168    /// variant through one call rather than re-inlining the five-line
3169    /// struct-literal in lockstep with the sole in-crate wire-up site.
3170    #[must_use]
3171    pub fn restart_not_exclusive(
3172        from: &str,
3173        restart_count: usize,
3174        other_kinds: Vec<&'static str>,
3175    ) -> Self {
3176        Self::RestartNotExclusive {
3177            from: from.to_string(),
3178            restart_count,
3179            other_kinds,
3180        }
3181    }
3182
3183    /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
3184    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3185    /// `:purge`), the malformed `:module` value, and the parser-shaped
3186    /// `reason` from
3187    /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3188    /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3189    /// three-field struct-literal onto one substrate primitive so every
3190    /// wire-up on this variant reads through one dispatch rather than the
3191    /// pre-lift open-coded closure block inside [`validate_module`]'s
3192    /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3193    ///
3194    /// The `kind: &'static str` parameter accepts the lisp-form
3195    /// [`UpgradeInstruction::lisp_form`] returns for the three
3196    /// [`UpgradeInstruction::declared_module`]-bearing arms —
3197    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3198    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3199    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3200    /// without a per-arm re-projection at the ctor path. The `module: &str`
3201    /// parameter threads the offending author-supplied `:module` value
3202    /// verbatim from [`UpgradeInstruction::declared_module`]. The
3203    /// `reason: impl Into<String>` bound accepts both `&str` literals and
3204    /// the `String` [`crate::render::is_dns_1123_label`] returns via
3205    /// `.into()`, matching the peer
3206    /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3207    /// [`crate::SupervisorError::child_caixa_invalid`] /
3208    /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3209    /// three-slot invalid-arm ctor discipline on the sibling
3210    /// DNS-1123-label per-envelope shape.
3211    ///
3212    /// Peer of the sibling standalone-ctor
3213    /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3214    /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3215    /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3216    /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3217    /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3218    /// [`validate_module`]'s cascade now reads through one substrate
3219    /// primitive on the invalid-arm rather than an open-coded four-line
3220    /// struct-literal in lockstep with the sole in-crate wire-up site.
3221    ///
3222    /// Every future consumer that raises this refusal outside
3223    /// [`validate_module`] — a deferred wasm-operator's
3224    /// `install_release/1` per-instruction `:module` re-validator at
3225    /// hot-upgrade dispatch time re-running the same DNS-1123-label
3226    /// floor against a candidate module reference, a future
3227    /// `feira validate --upgrade-from` per-caixa admission verb
3228    /// re-running the module-shape gate on demand, an M4
3229    /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3230    /// per-`:upgrade-from`-patched candidate before the module-shape
3231    /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3232    /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3233    /// overlay against a cluster-local snapshot — now reaches this
3234    /// variant through one call rather than re-inlining the four-line
3235    /// struct-literal in lockstep with the [`validate_module`]
3236    /// closure-form wire-up.
3237    #[must_use]
3238    pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3239        Self::ModuleInvalid {
3240            kind,
3241            module: module.to_string(),
3242            reason: reason.into(),
3243        }
3244    }
3245
3246    /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3247    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3248    /// `:purge`) at which the appup module reference is the empty
3249    /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3250    /// struct-literal onto one substrate primitive so the sole in-crate
3251    /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3252    /// at [`validate_module`] on this variant reads through one dispatch
3253    /// rather than the pre-lift open-coded block. The `kind` label
3254    /// threads verbatim from the caller-side
3255    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3256    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3257    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3258    /// roster the wire-up feeds through [`validate_module`]'s
3259    /// `kind: &'static str` parameter.
3260    ///
3261    /// Sibling of the paired three-slot [`Self::module_invalid`]
3262    /// (3d0d64a) substrate primitive on the same
3263    /// [`crate::render::require_valid_dns_1123_label`] two-closure
3264    /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3265    /// now both reach the `UpgradeError` envelope through one substrate
3266    /// primitive per typed variant, closing the pair on the OTP-appup
3267    /// per-instruction `:module` caixa-reference axis. Same shape
3268    /// discipline as the peer
3269    /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3270    /// one-slot `{ slot: &'static str }` sibling that closed the peer
3271    /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3272    /// cascade at the `:contratos <slot>` per-edge axis
3273    /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3274    /// "one substrate primitive per typed arm on both sides of a
3275    /// `require_valid_dns_1123_label` two-closure cascade, projecting
3276    /// through the caller-supplied axis-tag" discipline now extended
3277    /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3278    /// of the pair the M3 (`:contratos <slot>`) side already carries.
3279    ///
3280    /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3281    /// :instructions <kind>` tag comes from the
3282    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3283    /// carrying program-lifetime storage, matching the enum-field type
3284    /// and the [`validate_module`] wire-up's per-arm dispatch. A
3285    /// runtime-borrowed `&str` would silently downgrade the label
3286    /// lifetime and let a caller stash a non-`'static` borrow into the
3287    /// returned error. `#[must_use]` fires a compile warning at any
3288    /// wire-up that mistakenly discards the constructed error rather
3289    /// than routing it through `return Err(…)` / `.map_err(…)` / a
3290    /// closure return. `pub const fn` matches the peer per-envelope
3291    /// one-slot `Copy`-scalar ctor family discipline
3292    /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3293    /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3294    /// ctor is usable in `const` position at every wire-up site.
3295    ///
3296    /// Every future consumer that constructs `ModuleEmpty` outside
3297    /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3298    /// closure — a deferred wasm-operator's `install_release/1`
3299    /// per-instruction `:module` re-validator at hot-upgrade dispatch
3300    /// time re-running the same empty-arm floor against a candidate
3301    /// module reference, a future `feira validate --upgrade-from`
3302    /// per-caixa admission verb re-running the empty-module gate on
3303    /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3304    /// re-checking a per-`:upgrade-from`-patched candidate before the
3305    /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3306    /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3307    /// overlay against a cluster-local snapshot — now reaches this
3308    /// variant through one call rather than re-inlining the one-line
3309    /// struct-literal in lockstep with the sole in-crate wire-up site.
3310    #[must_use]
3311    pub const fn module_empty(kind: &'static str) -> Self {
3312        Self::ModuleEmpty { kind }
3313    }
3314}
3315
3316#[cfg(test)]
3317mod tests {
3318    use std::path::Path;
3319
3320    use super::*;
3321
3322    fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3323        UpgradeFromEntry {
3324            from: from.into(),
3325            instructions: instrs,
3326        }
3327    }
3328
3329    #[test]
3330    fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3331        // Fail-before-pass-after pin on
3332        // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3333        // posture. The accessor projects the per-`:upgrade-from :from`
3334        // [`String`] storage through the `pub const fn`
3335        // [`String::as_str`] (const-stable since Rust 1.87, well within
3336        // the workspace MSRV) — any future accidental downgrade to
3337        // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3338        // build time with E0015 (`cannot call non-const method`),
3339        // strictly stronger than a runtime `assert!`. Sibling of the
3340        // peer M2/M3 slot family pins on the sibling `const`-eval-
3341        // surface passes ([`crate::Caixa::nome`] /
3342        // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3343        // [`crate::aplicacao::Membro::nome`] /
3344        // [`crate::aplicacao::Membro::versao_requirement`],
3345        // [`crate::aplicacao::Entrada::hostname`] /
3346        // [`crate::aplicacao::Entrada::destination`],
3347        // [`crate::supervisor::ChildSpec::nome`] /
3348        // [`crate::supervisor::ChildSpec::versao_requirement`],
3349        // [`crate::dep::Dep::nome`] /
3350        // [`crate::dep::Dep::versao_requirement`], and the
3351        // per-`:contratos`
3352        // [`crate::aplicacao::WitContract::source`] /
3353        // [`crate::aplicacao::WitContract::destination`] /
3354        // [`crate::aplicacao::WitContract::world_ref`] trio the
3355        // sibling pin at 279823b already anchors).
3356        const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3357            e.prior_versao()
3358        }
3359        for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3360            let e = entry(from, vec![]);
3361            assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3362            assert_eq!(e.prior_versao(), from);
3363        }
3364    }
3365
3366    #[test]
3367    fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3368        // Fail-before-pass-after pin on
3369        // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3370        // posture. The accessor destructures the per-`:upgrade-from
3371        // :instructions` `Vec<UpgradeInstruction>` storage through the
3372        // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3373        // 1.66, well within the workspace MSRV) — any future
3374        // accidental downgrade to non-`const` fails
3375        // `instructions_via_const_fn` at caixa-core build time with
3376        // E0015 (`cannot call non-const method`), strictly stronger
3377        // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3378        // slot `Vec → &[T]` slice-return accessor family pin
3379        // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3380        // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3381        // per-`:membros` / per-`:contratos` slice-return axes, and of
3382        // the peer M2 supervisor-tree axis pin
3383        // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3384        // on the per-`:children` slice-return axis.
3385        const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3386            e.instructions()
3387        }
3388        // Sweep both the empty-instructions arm (author-declared
3389        // per-`:from` entry with no migration steps — the degenerate
3390        // shape the appup `restart`-only path folds through) and the
3391        // populated-instructions arm (the canonical OTP-appup shape
3392        // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3393        // chain) so the accessor carries a const-dispatch pin on
3394        // both arms.
3395        let e_empty = entry("0.1.0", vec![]);
3396        assert!(instructions_via_const_fn(&e_empty).is_empty());
3397        assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3398        let e_full = entry(
3399            "0.1.0",
3400            vec![
3401                UpgradeInstruction::LoadModule {
3402                    module: "hello-rio".into(),
3403                },
3404                UpgradeInstruction::StateChange {
3405                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3406                },
3407                UpgradeInstruction::SoftPurge {
3408                    module: "hello-rio-old".into(),
3409                },
3410            ],
3411        );
3412        assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3413        assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3414    }
3415
3416    #[test]
3417    fn round_trip_load_module() {
3418        let i = UpgradeInstruction::LoadModule {
3419            module: "hello-rio".into(),
3420        };
3421        let json = serde_json::to_string(&i).unwrap();
3422        assert!(json.contains("\"kind\":\"load-module\""));
3423        let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3424        assert_eq!(i, back);
3425    }
3426
3427    #[test]
3428    fn round_trip_all_variants() {
3429        let cases = vec![
3430            UpgradeInstruction::LoadModule { module: "x".into() },
3431            UpgradeInstruction::StateChange {
3432                script: PathBuf::from("lib/migrations.lisp"),
3433            },
3434            UpgradeInstruction::SoftPurge {
3435                module: "x-old".into(),
3436            },
3437            UpgradeInstruction::Purge {
3438                module: "x-old".into(),
3439            },
3440            UpgradeInstruction::Restart,
3441        ];
3442        for c in cases {
3443            let json = serde_json::to_string(&c).unwrap();
3444            let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3445            assert_eq!(c, back);
3446        }
3447    }
3448
3449    #[test]
3450    fn validate_accepts_well_formed() {
3451        let e = entry(
3452            "0.1.0",
3453            vec![
3454                UpgradeInstruction::LoadModule {
3455                    module: "hello-rio".into(),
3456                },
3457                UpgradeInstruction::StateChange {
3458                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3459                },
3460                UpgradeInstruction::SoftPurge {
3461                    module: "hello-rio-old".into(),
3462                },
3463            ],
3464        );
3465        e.validate().unwrap();
3466    }
3467
3468    #[test]
3469    fn validate_rejects_non_semver_from() {
3470        let e = entry("not-a-semver", vec![]);
3471        let err = e.validate().unwrap_err();
3472        assert!(
3473            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3474        );
3475    }
3476
3477    #[test]
3478    fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3479        // Diagnostic-shape pin: the error names the offending
3480        // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3481        // reason, so a `feira lint` run can render the diagnostic
3482        // without re-parsing — the author can grep their caixa.lisp for
3483        // `:from "<value>"` and fix it in one edit. Mirrors the peer
3484        // `versao_invalid_diagnostic_carries_offending_versao` pin on
3485        // the sibling SemVer-2 axis (the top-level `:versao`), the
3486        // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3487        // pin on `:membros :versao`, and the peer
3488        // `deps_invalid_diagnostic_carries_offending_value` pin on
3489        // `:deps :versao` — every SemVer-2-parsing slot's invalid
3490        // diagnostic is now structurally equivalent.
3491        let e = entry("v0.1.0", vec![]);
3492        let err = e.validate().unwrap_err();
3493        let UpgradeError::FromInvalid { from, reason } = err else {
3494            panic!("expected FromInvalid variant, got {err:?}");
3495        };
3496        assert_eq!(from, "v0.1.0");
3497        assert!(
3498            !reason.is_empty(),
3499            "FromInvalid `reason` must carry the parser's wording verbatim"
3500        );
3501    }
3502
3503    #[test]
3504    fn prior_versao_returns_from_byte_equal_across_permutations() {
3505        // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3506        // accessor across the SemVer-2 shape lattice every consumer
3507        // reaches through it — the numeric-triad canonical shape, a
3508        // pre-release build with a dotted identifier chain, a full-
3509        // metadata build, a large-magnitude triad, and the empty
3510        // string (which reaches this accessor unchanged before any
3511        // validate gate rejects it). Sibling to the peer
3512        // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3513        // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3514        // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3515        // family — extended here onto the first M2 slot scalar-value
3516        // axis. Any silent detour on the accessor (a `.to_string()`
3517        // + retained ownership shape, a canonicalization pass, a
3518        // trim-whitespace on the return path) surfaces as a byte-
3519        // inequality failure here rather than as a downstream error-
3520        // diagnostic drift.
3521        let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3522        for from in cases {
3523            let e = entry(from, vec![]);
3524            assert_eq!(
3525                e.prior_versao(),
3526                from,
3527                "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3528            );
3529            assert_eq!(
3530                e.prior_versao().len(),
3531                from.len(),
3532                "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3533            );
3534        }
3535    }
3536
3537    #[test]
3538    fn prior_versao_borrows_from_from_storage() {
3539        // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3540        // a borrow into `self.from`'s heap allocation, never a fresh
3541        // owned copy. Guards against a future silent detour where
3542        // the accessor materializes a `Cow<'_, str>` / `String` /
3543        // `Rc<str>` intermediate — the return path stays zero-cost
3544        // even under a refactor that reshapes the storage. Sibling
3545        // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3546        // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3547        // (4a32abf) pins — extended onto the M2 slot's first
3548        // scalar-value axis.
3549        let e = entry("0.1.0", vec![]);
3550        assert!(
3551            std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3552            "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3553        );
3554    }
3555
3556    #[test]
3557    fn validate_parses_prior_versao_through_lifted_accessor() {
3558        // Coherence pin between the accessor and the SemVer-2 parse
3559        // gate: every `:upgrade-from :from` value the validator
3560        // accepts (resp. rejects) must be identical to what
3561        // `Version::parse(entry.prior_versao())` accepts (resp.
3562        // rejects) — the two must remain in lockstep across the
3563        // shape lattice so `validate_upgrade_from`'s
3564        // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3565        // assertion holds by construction. If a future extension of
3566        // `prior_versao` reshapes the return (a canonicalization
3567        // pass, a leading/trailing whitespace trim, an empty-to-
3568        // "0.0.0" fallback) it would either loosen the validator
3569        // (silently accepting shapes the parser rejects) or
3570        // tighten the parser's re-parse (silently panicking on
3571        // shapes the validator accepts) — this pin catches either
3572        // shift at caixa-core build time.
3573        let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3574        for from in accepted {
3575            let e = entry(from, vec![]);
3576            e.validate().unwrap_or_else(|err| {
3577                panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3578            });
3579            semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3580                panic!(
3581                    "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3582                     got {err:?}",
3583                );
3584            });
3585        }
3586        let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3587        for from in rejected {
3588            let e = entry(from, vec![]);
3589            assert!(
3590                matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3591                "validate() must reject {from:?} that Version::parse rejects",
3592            );
3593            assert!(
3594                semver::Version::parse(e.prior_versao()).is_err(),
3595                "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3596            );
3597        }
3598    }
3599
3600    #[test]
3601    fn validate_rejects_empty_module() {
3602        // Per-arm coverage: every Module-bearing variant surfaces the
3603        // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3604        // so the author can grep their caixa.lisp for `(:load-module
3605        // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3606        // edit — same self-locating shape `BehaviorError::EmptyPath`
3607        // (b0c8389) carries on the peer M2 typed slot.
3608        let cases: &[(UpgradeInstruction, &'static str)] = &[
3609            (
3610                UpgradeInstruction::LoadModule {
3611                    module: String::new(),
3612                },
3613                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3614            ),
3615            (
3616                UpgradeInstruction::SoftPurge {
3617                    module: String::new(),
3618                },
3619                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3620            ),
3621            (
3622                UpgradeInstruction::Purge {
3623                    module: String::new(),
3624                },
3625                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3626            ),
3627        ];
3628        for (instr, expected_kind) in cases {
3629            assert_eq!(
3630                instr.validate().unwrap_err(),
3631                UpgradeError::ModuleEmpty {
3632                    kind: expected_kind
3633                },
3634                "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3635            );
3636        }
3637    }
3638
3639    #[test]
3640    fn validate_rejects_non_dns_1123_module() {
3641        // Every appup `:module` reference is a caixa name (the
3642        // wasm-engine resolves it through the same ComputeUnit
3643        // registry the operator manages), so the value-shape gate
3644        // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3645        // the canonical authoring footguns — uppercase letters, `_`
3646        // separator, embedded `.`, leading/trailing `-`, an embedded
3647        // whitespace byte, the >63-byte UUID-shaped slug — across
3648        // every Module-bearing variant; each must surface as
3649        // `ModuleInvalid { kind, module, reason }` carrying the
3650        // offending value verbatim and the parser-shaped reason.
3651        type Build = fn(String) -> UpgradeInstruction;
3652        let footguns: &[&str] = &[
3653            "Hello-Rio",
3654            "hello_rio",
3655            "hello.rio",
3656            "-hello",
3657            "hello-",
3658            "hello rio",
3659            &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3660        ];
3661        let variants: &[(Build, &'static str)] = &[
3662            (
3663                |m| UpgradeInstruction::LoadModule { module: m },
3664                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3665            ),
3666            (
3667                |m| UpgradeInstruction::SoftPurge { module: m },
3668                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3669            ),
3670            (
3671                |m| UpgradeInstruction::Purge { module: m },
3672                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3673            ),
3674        ];
3675        for (build, expected_kind) in variants {
3676            for module in footguns {
3677                let instr = build((*module).to_string());
3678                let err = instr.validate().unwrap_err();
3679                match err {
3680                    UpgradeError::ModuleInvalid {
3681                        kind,
3682                        module: m,
3683                        reason,
3684                    } => {
3685                        assert_eq!(
3686                            kind, *expected_kind,
3687                            ":module footgun on {instr:?} must tag the lisp-form"
3688                        );
3689                        assert_eq!(
3690                            m, *module,
3691                            "ModuleInvalid must carry the offending value verbatim"
3692                        );
3693                        assert!(
3694                            !reason.is_empty(),
3695                            "ModuleInvalid reason must name the specific violation \
3696                             (the predicate's parser-shaped wording from \
3697                             `is_dns_1123_label`), got empty"
3698                        );
3699                    }
3700                    other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3701                }
3702            }
3703        }
3704    }
3705
3706    #[test]
3707    fn validate_accepts_canonical_module_names() {
3708        // Positive control: every documented authoring shape — bare
3709        // identifier, with hyphens, with digits, the
3710        // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3711        // references — passes the gate. Drift here = a future
3712        // tighten that rejects any of these surfaces as a
3713        // test-failure at the predicate boundary, not piecemeal
3714        // across per-instruction call sites.
3715        let canonical: &[&str] = &[
3716            "hello-rio",
3717            "hello-rio-old",
3718            "cache",
3719            "cache-v2",
3720            "x",
3721            "a1",
3722            "0a",
3723            "abc-123-def",
3724        ];
3725        for module in canonical {
3726            UpgradeInstruction::LoadModule {
3727                module: (*module).to_string(),
3728            }
3729            .validate()
3730            .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3731            UpgradeInstruction::SoftPurge {
3732                module: (*module).to_string(),
3733            }
3734            .validate()
3735            .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3736            UpgradeInstruction::Purge {
3737                module: (*module).to_string(),
3738            }
3739            .validate()
3740            .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3741        }
3742    }
3743
3744    #[test]
3745    fn validate_empty_takes_precedence_over_invalid() {
3746        // Empty input is rejected via the narrower `ModuleEmpty`
3747        // diagnostic before the DNS-1123 predicate is consulted, so
3748        // a future tighten that adds another stage between the two
3749        // doesn't accidentally reorder the diagnostic precedence.
3750        // Mirrors the empty-first cascade on every peer DNS-1123
3751        // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3752        // `SupervisorSpec::validate`'s child-name arm).
3753        let err = UpgradeInstruction::LoadModule {
3754            module: String::new(),
3755        }
3756        .validate()
3757        .unwrap_err();
3758        assert_eq!(
3759            err,
3760            UpgradeError::ModuleEmpty {
3761                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3762            }
3763        );
3764    }
3765
3766    #[test]
3767    fn validate_rejects_empty_script() {
3768        let i = UpgradeInstruction::StateChange {
3769            script: PathBuf::new(),
3770        };
3771        assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3772    }
3773
3774    #[test]
3775    fn validate_rejects_absolute_script() {
3776        let i = UpgradeInstruction::StateChange {
3777            script: PathBuf::from("/etc/migrations.lisp"),
3778        };
3779        assert!(matches!(
3780            i.validate().unwrap_err(),
3781            UpgradeError::AbsoluteScript { .. }
3782        ));
3783    }
3784
3785    #[test]
3786    fn validate_rejects_parent_escape_script() {
3787        let i = UpgradeInstruction::StateChange {
3788            script: PathBuf::from("../sibling/migrations.lisp"),
3789        };
3790        assert!(matches!(
3791            i.validate().unwrap_err(),
3792            UpgradeError::ParentEscapeScript { .. }
3793        ));
3794        // mid-path `..` is also caught
3795        let i2 = UpgradeInstruction::StateChange {
3796            script: PathBuf::from("lib/../../escaped.lisp"),
3797        };
3798        assert!(matches!(
3799            i2.validate().unwrap_err(),
3800            UpgradeError::ParentEscapeScript { .. }
3801        ));
3802    }
3803
3804    // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3805    // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3806    // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3807    // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3808    // consumer; the file-type contract is identical, so the per-axis
3809    // test grid is mirrored leg-for-leg.
3810
3811    #[test]
3812    fn validate_rejects_no_extension_script() {
3813        // Fail-before-pass-after: the canonical "I declared the
3814        // migration script but forgot the `.lisp` extension"
3815        // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3816        // The wasm-engine's `tatara_lisp::read` consumer needs a
3817        // file-type contract beyond the structural-shape gate; a
3818        // no-extension path past `is_sandboxed_relative_path` would
3819        // surface a parser-shaped diagnostic at hot-upgrade migration
3820        // time far from the source caixa.lisp.
3821        for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3822            let i = UpgradeInstruction::StateChange {
3823                script: PathBuf::from(relpath),
3824            };
3825            let err = i.validate().unwrap_err();
3826            assert!(
3827                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3828                         if s == Path::new(relpath)),
3829                "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3830                 carrying the offending path verbatim, got {err:?}"
3831            );
3832        }
3833    }
3834
3835    #[test]
3836    fn validate_rejects_non_lisp_extension_script() {
3837        // Wrong-extension sweep across common authoring footguns: the
3838        // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3839        // drag in from the workspace tree, the `.rs` shape that an
3840        // IDE auto-complete might propose, the `.lisp.bak` shape an
3841        // editor might leave behind, and the `.lispx` near-miss that
3842        // a typo would produce. Each must surface as
3843        // `NonLispExtensionScript` carrying the offending path
3844        // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3845        // rejects all of these at hot-upgrade migration time, and
3846        // the gate lifts that contract to validate time. Mirrors the
3847        // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3848        // the `:behavior :on-*` axis leg-for-leg — same downstream
3849        // consumer, same accepted set, same per-axis test grid.
3850        let footguns: &[&str] = &[
3851            "lib/migrations.rs",
3852            "lib/migrations.txt",
3853            "lib/migrations.md",
3854            "lib/migrations.json",
3855            "lib/migrations.yaml",
3856            "lib/migrations.toml",
3857            "lib/migrations.lisp.bak",
3858            "lib/migrations.lispx",
3859            "lib/migrations.lis",
3860        ];
3861        for relpath in footguns {
3862            let i = UpgradeInstruction::StateChange {
3863                script: PathBuf::from(relpath),
3864            };
3865            let err = i.validate().unwrap_err();
3866            assert!(
3867                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3868                         if s == Path::new(relpath)),
3869                "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3870                 carrying the offending path verbatim, got {err:?}"
3871            );
3872        }
3873    }
3874
3875    #[test]
3876    fn validate_rejects_uppercase_lisp_extension_script() {
3877        // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3878        // case-folded shapes a case-insensitive volume's existence
3879        // check would match the on-disk file — but the
3880        // canonical-form codec emits lowercase `.lisp` verbatim, so
3881        // a case-folded shape mismatches the round-trip-stable
3882        // canonical form (THEORY.md §V.2.7 render-determinism).
3883        // Same case-sensitive discipline the byte-size / duration
3884        // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3885        // and every other shape-gate predicate in `render.rs` (label
3886        // / scheme / unit boundaries). Mirrors the peer
3887        // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3888        for relpath in [
3889            "lib/migrations.LISP",
3890            "lib/migrations.Lisp",
3891            "lib/migrations.LiSp",
3892            "lib/migrations.lISP",
3893        ] {
3894            let i = UpgradeInstruction::StateChange {
3895                script: PathBuf::from(relpath),
3896            };
3897            let err = i.validate().unwrap_err();
3898            assert!(
3899                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3900                         if s == Path::new(relpath)),
3901                "case-folded `.lisp` extension {relpath:?} must surface as \
3902                 NonLispExtensionScript (strict lowercase, canonical-form \
3903                 round-trip pin), got {err:?}"
3904            );
3905        }
3906    }
3907
3908    #[test]
3909    fn validate_accepts_canonical_lisp_extension_scripts() {
3910        // Positive-control sweep across every canonical in-tree
3911        // authoring shape: bare filename, standard `lib/`
3912        // subdirectory, deeply-nested migrations subdirectory,
3913        // explicit current-dir-relative prefix, mid-path `./`
3914        // segment, multi-dot stem (the version-suffix shape
3915        // `lib/migrations/v.0.1.lisp` an author might use to encode
3916        // the migration's `:from` version into the filename). Drift
3917        // here = a future tightening that rejects any of these
3918        // surfaces as a test-failure at the per-axis validator
3919        // boundary, not piecemeal across renderer / layout-checker
3920        // call sites. Mirrors the peer `BehaviorSpec` positive-set
3921        // sweep (c97815a).
3922        let canonical: &[&str] = &[
3923            "lib/migrations.lisp",
3924            "lib/migrations/v01-to-v02.lisp",
3925            "migrations.lisp",
3926            "a.lisp",
3927            "./lib/migrations.lisp",
3928            "lib/./migrations.lisp",
3929            "lib/migrations/v.0.1.lisp",
3930        ];
3931        for relpath in canonical {
3932            UpgradeInstruction::StateChange {
3933                script: PathBuf::from(relpath),
3934            }
3935            .validate()
3936            .unwrap_or_else(|e| {
3937                panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3938            });
3939        }
3940    }
3941
3942    #[test]
3943    fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3944        // Cross-arm precedence pin: a script that is *both*
3945        // sandbox-escaping (Empty / Absolute / ParentEscape) and
3946        // non-`.lisp` must surface the more-fundamental
3947        // sandbox-shape diagnostic first — the canonical fix
3948        // collapses both into "pin a relative `.lisp` path under the
3949        // caixa root", and the `.lisp` remediation would be
3950        // misleading when the offending path can never resolve under
3951        // the caixa root anyway. Mirrors the peer
3952        // `BehaviorError` cross-arm precedence (c97815a) and the
3953        // sibling `LimitsError`
3954        // (`MemoryZero` → `MemoryBelowWasm32Page` →
3955        // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3956        // smallest-scope-arm-fires-last posture.
3957        let i_empty = UpgradeInstruction::StateChange {
3958            script: PathBuf::new(),
3959        };
3960        assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3961        let i_abs = UpgradeInstruction::StateChange {
3962            script: PathBuf::from("/etc/migrations.txt"),
3963        };
3964        assert!(
3965            matches!(
3966                i_abs.validate().unwrap_err(),
3967                UpgradeError::AbsoluteScript { .. }
3968            ),
3969            "absolute + non-`.lisp` must surface AbsoluteScript first"
3970        );
3971        let i_esc = UpgradeInstruction::StateChange {
3972            script: PathBuf::from("../sibling/migrations.rs"),
3973        };
3974        assert!(
3975            matches!(
3976                i_esc.validate().unwrap_err(),
3977                UpgradeError::ParentEscapeScript { .. }
3978            ),
3979            "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3980        );
3981    }
3982
3983    #[test]
3984    fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3985        // Diagnostic-shape pin: the surfaced error message names the
3986        // offending path verbatim (so the author can grep their
3987        // caixa.lisp for the literal value), the `.lisp` extension
3988        // is named in the remediation, and the downstream consumer
3989        // (`tatara_lisp::read` at hot-upgrade migration time) is
3990        // named so the author can trace the contract back to its
3991        // source. Same self-locating shape every per-axis variant
3992        // carries (`BehaviorError::NonLispExtension`, c97815a;
3993        // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3994        let bad = PathBuf::from("lib/migrations.txt");
3995        let err = UpgradeInstruction::StateChange {
3996            script: bad.clone(),
3997        }
3998        .validate()
3999        .unwrap_err();
4000        let msg = err.to_string();
4001        assert!(
4002            msg.contains("lib/migrations.txt"),
4003            "diagnostic must name the offending path verbatim, got {msg:?}"
4004        );
4005        assert!(
4006            msg.contains(".lisp"),
4007            "diagnostic must name the expected `.lisp` extension, got {msg:?}"
4008        );
4009        assert!(
4010            msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
4011            "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
4012        );
4013        match err {
4014            UpgradeError::NonLispExtensionScript { script } => {
4015                assert_eq!(
4016                    script, bad,
4017                    "variant must carry the offending path verbatim"
4018                );
4019            }
4020            other => panic!("expected NonLispExtensionScript, got {other:?}"),
4021        }
4022    }
4023
4024    #[test]
4025    fn declared_path_only_for_state_change() {
4026        let load = UpgradeInstruction::LoadModule { module: "x".into() };
4027        assert!(load.declared_path().is_none());
4028        let mig = UpgradeInstruction::StateChange {
4029            script: PathBuf::from("lib/m.lisp"),
4030        };
4031        assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
4032    }
4033
4034    #[test]
4035    fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
4036        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4037        // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
4038        // predicate: [`UpgradeInstruction::Restart`] is the only variant
4039        // that satisfies `.is_restart()`; every module-bearing arm
4040        // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
4041        // `StateChange` arm all return `false`. This pin makes the
4042        // partition invariant load-bearing at caixa-core test time so a
4043        // future derive regression (a hole that returns `false` for
4044        // `Restart` too, or a byte-collision that flips a second variant
4045        // to `true`) trips here rather than laundering the arm at
4046        // [`Self::validate_restart_exclusive`]'s paired positive /
4047        // negated filter sites (a hole flips restart-count to 0 →
4048        // vacuous OK; a collision flips restart-count > 1 → false
4049        // `RestartNotExclusive` on an entry the author declared without
4050        // any `(:restart)`). Peer of the sibling
4051        // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
4052        // pin on the M0 `CaixaKind` axis.
4053        let cases: &[(UpgradeInstruction, bool)] = &[
4054            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4055            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4056            (UpgradeInstruction::Purge { module: "c".into() }, false),
4057            (
4058                UpgradeInstruction::StateChange {
4059                    script: PathBuf::from("lib/m.lisp"),
4060                },
4061                false,
4062            ),
4063            (UpgradeInstruction::Restart, true),
4064        ];
4065        for (variant, expected) in cases {
4066            assert_eq!(
4067                variant.is_restart(),
4068                *expected,
4069                "UpgradeInstruction::{variant:?}.is_restart() must \
4070                 return {expected} (partition invariant on the \
4071                 IsVariant-derived arm-discriminator predicate)"
4072            );
4073        }
4074    }
4075
4076    #[test]
4077    fn validate_restart_exclusive_routes_through_is_restart_predicate() {
4078        // Byte-identity pin on the paired positive / negated
4079        // `.is_restart()` filters at
4080        // [`Self::validate_restart_exclusive`] against the pre-lift
4081        // `matches!(i, UpgradeInstruction::Restart)` /
4082        // `!matches!(i, UpgradeInstruction::Restart)` predicates every
4083        // consumer of the gate previously coupled to inline. Asserts
4084        // the two projections agree byte-for-byte on every arm of the
4085        // enum, so a future derive regression that flipped either
4086        // predicate's arm-set would surface here at caixa-core test
4087        // time rather than at
4088        // [`Self::validate_restart_exclusive`]'s per-entry restart-
4089        // count / other-kinds tabulation far from the derive site.
4090        // Same peer-shape pin every sibling
4091        // `IsVariant`-derive-routed gate carries on the substrate's
4092        // closed-set typed-enum surface.
4093        let cases: Vec<UpgradeInstruction> = vec![
4094            UpgradeInstruction::LoadModule { module: "a".into() },
4095            UpgradeInstruction::SoftPurge { module: "b".into() },
4096            UpgradeInstruction::Purge { module: "c".into() },
4097            UpgradeInstruction::StateChange {
4098                script: PathBuf::from("lib/m.lisp"),
4099            },
4100            UpgradeInstruction::Restart,
4101        ];
4102        for instr in &cases {
4103            let via_predicate = instr.is_restart();
4104            let via_matches = matches!(instr, UpgradeInstruction::Restart);
4105            assert_eq!(
4106                via_predicate, via_matches,
4107                "UpgradeInstruction::{instr:?}: is_restart() must \
4108                 byte-equal matches!(_, UpgradeInstruction::Restart) — \
4109                 the pre-lift open-coded pattern and the \
4110                 IsVariant-derived predicate are the same axis, \
4111                 one typed dispatch"
4112            );
4113        }
4114    }
4115
4116    #[test]
4117    fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
4118        // The fail-before-pass-after pin on the lifted
4119        // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
4120        // arm-discriminator predicate:
4121        // [`UpgradeInstruction::SoftPurge`] and
4122        // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
4123        // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
4124        // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
4125        // on the paired two-phase-load half,
4126        // [`UpgradeInstruction::StateChange`] on the
4127        // `gen_server:code_change/3`-analog migration axis,
4128        // [`UpgradeInstruction::Restart`] on the OTP terminal-
4129        // fallback shape) returns `false`. This pin makes the
4130        // partition invariant load-bearing at caixa-core test time
4131        // so a future accessor regression (a hole that returns
4132        // `false` for `SoftPurge` or `Purge`, or a byte-collision
4133        // that flips `LoadModule` / `StateChange` / `Restart` to
4134        // `true`) trips here rather than laundering the arm at the
4135        // three within-entry cross-instruction cleanup-facing gates
4136        // ([`UpgradeFromEntry::validate_purge_ordering`],
4137        // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
4138        // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
4139        // hole would silently accept a cleanup-shaped entry the
4140        // three gates should refuse; a collision would fire a
4141        // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
4142        // `DuplicateCleanup` refusal on a well-shaped
4143        // [`UpgradeInstruction::LoadModule`] / `StateChange` /
4144        // `Restart` arm the three gates should pass through. Peer
4145        // of the sibling
4146        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4147        // pin on the single-arm terminal-fallback partition —
4148        // extended here from the single-arm case onto the two-arm
4149        // cleanup-family union case.
4150        let cases: &[(UpgradeInstruction, bool)] = &[
4151            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4152            (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
4153            (UpgradeInstruction::Purge { module: "c".into() }, true),
4154            (
4155                UpgradeInstruction::StateChange {
4156                    script: PathBuf::from("lib/m.lisp"),
4157                },
4158                false,
4159            ),
4160            (UpgradeInstruction::Restart, false),
4161        ];
4162        for (variant, expected) in cases {
4163            assert_eq!(
4164                variant.is_cleanup(),
4165                *expected,
4166                "UpgradeInstruction::{variant:?}.is_cleanup() must \
4167                 return {expected} (partition invariant on the \
4168                 lifted OTP-appup two-arm cleanup-family arm-\
4169                 discriminator predicate)"
4170            );
4171        }
4172    }
4173
4174    #[test]
4175    fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
4176        // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
4177        // composition against the two [`gen_platform::IsVariant`]-
4178        // derive-generated per-variant classifiers it routes through
4179        // — the accessor's one body must byte-equal
4180        // `self.is_soft_purge() || self.is_purge()` across every arm
4181        // of the closed-set enum, so a future silent detour that
4182        // reintroduced a raw `matches!` pattern or that stopped
4183        // composing through the derive-generated per-variant
4184        // predicates (an accidental `self.is_soft_purge()` on its
4185        // own — silently dropping the `Purge` arm; an accidental
4186        // `self.is_purge() || self.is_state_change()` — silently
4187        // folding the migration arm into the cleanup family; a
4188        // typo `&&` for the union `||` — silently classifying no
4189        // arm as cleanup) trips here at caixa-core test time
4190        // rather than laundering the arm at the three within-entry
4191        // cross-instruction cleanup-facing gates. Same peer-shape
4192        // pin the sibling
4193        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4194        // carries on the paired terminal-fallback axis.
4195        let cases: Vec<UpgradeInstruction> = vec![
4196            UpgradeInstruction::LoadModule { module: "a".into() },
4197            UpgradeInstruction::SoftPurge { module: "b".into() },
4198            UpgradeInstruction::Purge { module: "c".into() },
4199            UpgradeInstruction::StateChange {
4200                script: PathBuf::from("lib/m.lisp"),
4201            },
4202            UpgradeInstruction::Restart,
4203        ];
4204        for instr in &cases {
4205            let via_predicate = instr.is_cleanup();
4206            let via_composition = instr.is_soft_purge() || instr.is_purge();
4207            assert_eq!(
4208                via_predicate, via_composition,
4209                "UpgradeInstruction::{instr:?}: is_cleanup() must \
4210                 byte-equal is_soft_purge() || is_purge() — the \
4211                 lifted union predicate and its per-variant \
4212                 composition are the same axis, one typed dispatch"
4213            );
4214        }
4215    }
4216
4217    #[test]
4218    fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4219        // Composition-pin the load-bearing invariant every consumer
4220        // that routes through `is_cleanup()` + `declared_module()`
4221        // relies on: any [`UpgradeInstruction`] value whose
4222        // `.is_cleanup()` returns `true` must have a `Some(_)`
4223        // `.declared_module()`. This makes the three within-entry
4224        // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4225        // implies declared_module() is Some")` structurally
4226        // infallible at build time — a future refactor that added
4227        // a cleanup-shaped variant carrying no `:module` would trip
4228        // here rather than panic at
4229        // [`UpgradeFromEntry::validate_purge_ordering`] /
4230        // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4231        // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4232        // runtime on the offending author's caixa.lisp.
4233        let cases: Vec<UpgradeInstruction> = vec![
4234            UpgradeInstruction::LoadModule { module: "a".into() },
4235            UpgradeInstruction::SoftPurge { module: "b".into() },
4236            UpgradeInstruction::Purge { module: "c".into() },
4237            UpgradeInstruction::StateChange {
4238                script: PathBuf::from("lib/m.lisp"),
4239            },
4240            UpgradeInstruction::Restart,
4241        ];
4242        for instr in &cases {
4243            if instr.is_cleanup() {
4244                assert!(
4245                    instr.declared_module().is_some(),
4246                    "UpgradeInstruction::{instr:?}: is_cleanup() \
4247                     must imply declared_module().is_some() — the \
4248                     three within-entry cross-instruction cleanup-\
4249                     facing gates rely on this invariant to route \
4250                     the cleanup-target :module scalar through the \
4251                     sibling declared_module accessor without a \
4252                     pattern-bound `module` binding"
4253                );
4254            }
4255        }
4256    }
4257
4258    #[test]
4259    fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4260        // Composition-pin the load-bearing invariant
4261        // [`UpgradeFromEntry::validate_load_singularity`] relies on
4262        // when routing the per-instruction load-family arm-discriminator
4263        // through the sibling
4264        // [`UpgradeInstruction::is_load_module`] +
4265        // [`UpgradeInstruction::declared_module`] accessor pair: any
4266        // [`UpgradeInstruction`] value whose `.is_load_module()`
4267        // returns `true` must have a `Some(_)` `.declared_module()`.
4268        // This makes the gate's `.expect("is_load_module() implies
4269        // declared_module() is Some")` structurally infallible at
4270        // build time — a future refactor that added a load-shaped
4271        // variant carrying no `:module` would trip here rather than
4272        // panic at [`UpgradeFromEntry::validate_load_singularity`]
4273        // at runtime on the offending author's caixa.lisp. Sibling
4274        // of the peer
4275        // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4276        // composition pin on the two-arm cleanup-family axis — same
4277        // "predicate implies accessor" discipline extended onto the
4278        // single-arm load-family axis, closes the load-vs-cleanup
4279        // pair on the substrate primitive's typed dispatch discipline.
4280        let cases: Vec<UpgradeInstruction> = vec![
4281            UpgradeInstruction::LoadModule { module: "a".into() },
4282            UpgradeInstruction::SoftPurge { module: "b".into() },
4283            UpgradeInstruction::Purge { module: "c".into() },
4284            UpgradeInstruction::StateChange {
4285                script: PathBuf::from("lib/m.lisp"),
4286            },
4287            UpgradeInstruction::Restart,
4288        ];
4289        for instr in &cases {
4290            if instr.is_load_module() {
4291                assert!(
4292                    instr.declared_module().is_some(),
4293                    "UpgradeInstruction::{instr:?}: is_load_module() \
4294                     must imply declared_module().is_some() — the \
4295                     within-entry load-singularity gate relies on this \
4296                     invariant to route the load-target :module scalar \
4297                     through the sibling declared_module accessor \
4298                     without a pattern-bound `module` binding"
4299                );
4300            }
4301        }
4302    }
4303
4304    #[test]
4305    fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4306     {
4307        // Byte-identity pin on the
4308        // [`UpgradeFromEntry::validate_load_singularity`] load-family
4309        // dispatch against the pre-lift
4310        // `match instr { UpgradeInstruction::LoadModule { module } =>
4311        // module.as_str(), _ => continue }` open-coded pattern-match
4312        // the site previously carried. Asserts the two projections
4313        // agree byte-for-byte on every arm of the enum — the
4314        // arm-discriminator via `is_load_module()` and the `:module`
4315        // scalar via `declared_module()` — so a future derive
4316        // regression that flipped the predicate's arm-set (a hole
4317        // returning `false` for [`UpgradeInstruction::LoadModule`], a
4318        // byte-collision flipping a second variant to `true`) or an
4319        // accessor extension that promoted an additional variant onto
4320        // the `String`-carrying axis would trip here at caixa-core
4321        // test time rather than laundering the arm at the gate's
4322        // per-entry load-singularity scan far from the derive site.
4323        // Peer of the sibling
4324        // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4325        // byte-identity pin on the paired ordering-side load-family
4326        // sticky-latch dispatch (both consumers now agree on one
4327        // typed dispatch for the load-family axis) and the peer
4328        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4329        // pin on the migration-family script-projection axis — the
4330        // three within-entry per-instruction-class singularity gates
4331        // now share one byte-identity pin apiece against their
4332        // respective substrate-primitive typed dispatches.
4333        //
4334        // Three-arm projective coverage:
4335        //   (a) `LoadModule` modules project through
4336        //       `declared_module()` byte-equal to the raw
4337        //       `module.as_str()` field access;
4338        //   (b) a duplicate-`LoadModule` input trips the gate on the
4339        //       second occurrence with `DuplicateLoadModule` carrying
4340        //       the offending module verbatim;
4341        //   (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4342        //       `StateChange` / `Restart`) leaves the gate vacuous
4343        //       with `Ok(())` — the `!instr.is_load_module()`
4344        //       `continue` fall-through pins.
4345        //
4346        // Fail-before-pass-after verified locally: swapping the
4347        // production `if !instr.is_load_module() { continue; } let
4348        // module = instr.declared_module().expect(…);` back to `let
4349        // module = match instr { UpgradeInstruction::LoadModule
4350        // { module } => module.as_str(), _ => continue, };` keeps
4351        // arms (a)-(c) passing but silently detaches the gate from
4352        // the accessor's typed dispatch — any future
4353        // `is_load_module` / `declared_module` extension (a hole in
4354        // either predicate, a promotion of an additional variant
4355        // onto the `String`-carrying axis, an operator-side
4356        // pre-parsed caixa-name cache the accessor materializes)
4357        // would then silently disagree between this gate's raw
4358        // pattern-match and the peer per-`UpgradeInstruction`
4359        // consumers that route through the accessor pair.
4360
4361        // (a) LoadModule projection byte-equal via
4362        //     is_load_module() + declared_module().
4363        let lm = UpgradeInstruction::LoadModule {
4364            module: "hello-rio".into(),
4365        };
4366        assert!(
4367            lm.is_load_module(),
4368            "LoadModule must satisfy is_load_module() — the gate's \
4369             load-family arm-discriminator relies on this partition"
4370        );
4371        assert_eq!(
4372            lm.declared_module(),
4373            Some("hello-rio"),
4374            "declared_module() must project the LoadModule :module \
4375             byte-equal to the raw field access — accessor divergence \
4376             would silently detach the gate from the projection every \
4377             peer per-`UpgradeInstruction` consumer routes through"
4378        );
4379
4380        // (b) Duplicate-LoadModule input trips the gate.
4381        let dup = entry(
4382            "0.1.0",
4383            vec![
4384                UpgradeInstruction::LoadModule { module: "x".into() },
4385                UpgradeInstruction::LoadModule { module: "x".into() },
4386            ],
4387        );
4388        assert_eq!(
4389            dup.validate_load_singularity(),
4390            Err(UpgradeError::DuplicateLoadModule {
4391                from: "0.1.0".into(),
4392                module: "x".into(),
4393            }),
4394            "duplicate LoadModule modules within one entry must fire \
4395             DuplicateLoadModule byte-identical to the pre-lift \
4396             pattern-match shape"
4397        );
4398
4399        // (c) Non-LoadModule-only input leaves the gate vacuous.
4400        let no_load = entry(
4401            "0.1.0",
4402            vec![
4403                UpgradeInstruction::StateChange {
4404                    script: PathBuf::from("lib/m.lisp"),
4405                },
4406                UpgradeInstruction::Restart,
4407            ],
4408        );
4409        assert_eq!(
4410            no_load.validate_load_singularity(),
4411            Ok(()),
4412            "non-LoadModule-only entries must leave the load-\
4413             singularity gate vacuous — the `!is_load_module()` \
4414             continue fall-through pins"
4415        );
4416    }
4417
4418    #[test]
4419    fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4420        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4421        // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4422        // predicate: [`UpgradeInstruction::LoadModule`] is the only
4423        // variant that satisfies `.is_load_module()`; every cleanup arm
4424        // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4425        // and the terminal-fallback arm (`Restart`) all return `false`.
4426        // This pin makes the partition invariant load-bearing at
4427        // caixa-core test time so a future derive regression (a hole
4428        // that returns `false` for `LoadModule` too, or a byte-collision
4429        // that flips a second variant to `true`) trips here rather than
4430        // laundering the arm at
4431        // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4432        // dispatch — a hole would silently keep `loaded = false` through
4433        // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4434        // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4435        // a collision would flip `loaded = true` on a well-shaped
4436        // cleanup-only entry and silently swallow the load-less
4437        // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4438        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4439        // and
4440        // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4441        // pins on the paired terminal-fallback and cleanup-family
4442        // arm-discriminator axes — closes the last unlifted `matches!`-
4443        // based arm-discriminator axis on the OTP-appup closed-set
4444        // typed enum.
4445        let cases: &[(UpgradeInstruction, bool)] = &[
4446            (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4447            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4448            (UpgradeInstruction::Purge { module: "c".into() }, false),
4449            (
4450                UpgradeInstruction::StateChange {
4451                    script: PathBuf::from("lib/m.lisp"),
4452                },
4453                false,
4454            ),
4455            (UpgradeInstruction::Restart, false),
4456        ];
4457        for (variant, expected) in cases {
4458            assert_eq!(
4459                variant.is_load_module(),
4460                *expected,
4461                "UpgradeInstruction::{variant:?}.is_load_module() must \
4462                 return {expected} (partition invariant on the \
4463                 IsVariant-derived arm-discriminator predicate)"
4464            );
4465        }
4466    }
4467
4468    #[test]
4469    fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4470        // Byte-identity pin on the [`Self::validate_purge_ordering`]
4471        // load-family sticky-latch dispatch against the pre-lift
4472        // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4473        // predicate the site previously open-coded. Asserts the two
4474        // projections agree byte-for-byte on every arm of the enum, so
4475        // a future derive regression that flipped the predicate's
4476        // arm-set would surface here at caixa-core test time rather
4477        // than at [`Self::validate_purge_ordering`]'s per-entry
4478        // load-before-cleanup ordering scan far from the derive site.
4479        // Same peer-shape pin the sibling
4480        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4481        // carries on the paired terminal-fallback axis and the
4482        // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4483        // carries on the two-arm cleanup-family axis — the third and
4484        // final byte-identity pin closes the substrate primitive's
4485        // arm-discriminator dispatch discipline on the OTP-appup
4486        // closed-set typed enum.
4487        let cases: Vec<UpgradeInstruction> = vec![
4488            UpgradeInstruction::LoadModule { module: "a".into() },
4489            UpgradeInstruction::SoftPurge { module: "b".into() },
4490            UpgradeInstruction::Purge { module: "c".into() },
4491            UpgradeInstruction::StateChange {
4492                script: PathBuf::from("lib/m.lisp"),
4493            },
4494            UpgradeInstruction::Restart,
4495        ];
4496        for instr in &cases {
4497            let via_predicate = instr.is_load_module();
4498            let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4499            assert_eq!(
4500                via_predicate, via_matches,
4501                "UpgradeInstruction::{instr:?}: is_load_module() must \
4502                 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4503                 {{ .. }}) — the pre-lift open-coded pattern and the \
4504                 IsVariant-derived predicate are the same axis, one \
4505                 typed dispatch"
4506            );
4507        }
4508    }
4509
4510    #[test]
4511    fn declared_module_only_for_module_bearing_variants() {
4512        // Pinned partition of the `UpgradeInstruction` closed-set
4513        // variant space against the sibling of the peer
4514        // `declared_path` accessor: every OTP-appup module-bearing
4515        // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4516        // `:module` string byte-for-byte through the lifted
4517        // `declared_module` accessor; every non-module-bearing variant
4518        // (`StateChange` on the peer `:script`-carrying axis;
4519        // `Restart` on the OTP terminal-fallback data-less axis)
4520        // returns `None`. Mirrors the peer
4521        // `declared_path_only_for_state_change` pin — the pair now
4522        // closes both scalar-carrying axes on the enum on one lifted
4523        // `Option<&…>` accessor apiece.
4524        let load = UpgradeInstruction::LoadModule {
4525            module: "hello-rio".into(),
4526        };
4527        assert_eq!(load.declared_module(), Some("hello-rio"));
4528        let soft = UpgradeInstruction::SoftPurge {
4529            module: "hello-rio-old".into(),
4530        };
4531        assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4532        let hard = UpgradeInstruction::Purge {
4533            module: "hello-rio-ancient".into(),
4534        };
4535        assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4536        let mig = UpgradeInstruction::StateChange {
4537            script: PathBuf::from("lib/m.lisp"),
4538        };
4539        assert!(mig.declared_module().is_none());
4540        assert!(UpgradeInstruction::Restart.declared_module().is_none());
4541    }
4542
4543    #[test]
4544    fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4545        // Byte-identity pin on the two-accessor partition: every
4546        // `UpgradeInstruction` variant returns `Some` from *exactly
4547        // one* of {`declared_module`, `declared_path`} (the two
4548        // module-bearing / script-carrying axes) or from *neither*
4549        // (the OTP terminal-fallback `Restart` shape). No variant
4550        // returns `Some` from both — the two axes are disjoint by
4551        // construction, and this pin closes the disjointness at the
4552        // test surface so a future variant that leaks a scalar across
4553        // both axes fails at build time. Mirrors the peer
4554        // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4555        // discipline on the `BehaviorSpec` per-slot family.
4556        let cases: Vec<UpgradeInstruction> = vec![
4557            UpgradeInstruction::LoadModule { module: "a".into() },
4558            UpgradeInstruction::SoftPurge { module: "b".into() },
4559            UpgradeInstruction::Purge { module: "c".into() },
4560            UpgradeInstruction::StateChange {
4561                script: PathBuf::from("lib/m.lisp"),
4562            },
4563            UpgradeInstruction::Restart,
4564        ];
4565        for instr in &cases {
4566            let has_module = instr.declared_module().is_some();
4567            let has_path = instr.declared_path().is_some();
4568            assert!(
4569                !(has_module && has_path),
4570                "no variant may declare both a module and a path — offending: {instr:?}"
4571            );
4572            match instr {
4573                UpgradeInstruction::LoadModule { .. }
4574                | UpgradeInstruction::SoftPurge { .. }
4575                | UpgradeInstruction::Purge { .. } => {
4576                    assert!(has_module && !has_path, "module axis: {instr:?}");
4577                }
4578                UpgradeInstruction::StateChange { .. } => {
4579                    assert!(!has_module && has_path, "script axis: {instr:?}");
4580                }
4581                UpgradeInstruction::Restart => {
4582                    assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4583                }
4584            }
4585        }
4586    }
4587
4588    #[test]
4589    fn entry_with_chain_of_versions() {
4590        // Middle entry pairs a `:load-module` with the trailing
4591        // `:soft-purge` so it satisfies the within-entry purge-ordering
4592        // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4593        // preceding `:load-module`, mirroring the state-change-ordering
4594        // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4595        // test is *cross-entry* `:from` values; the within-entry shape
4596        // is incidental — keeping it canonical (`:load-module` before
4597        // `:soft-purge`) leaves the chain assertion load-bearing.
4598        let entries = vec![
4599            entry(
4600                "0.1.0",
4601                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4602            ),
4603            entry(
4604                "0.1.5",
4605                vec![
4606                    UpgradeInstruction::LoadModule { module: "x".into() },
4607                    UpgradeInstruction::SoftPurge {
4608                        module: "x-old".into(),
4609                    },
4610                ],
4611            ),
4612            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4613        ];
4614        for e in &entries {
4615            e.validate().unwrap();
4616        }
4617        let json = serde_json::to_string(&entries).unwrap();
4618        let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4619        assert_eq!(entries, back);
4620    }
4621
4622    #[test]
4623    fn empty_instructions_list_is_valid() {
4624        let e = entry("0.1.0", vec![]);
4625        e.validate().unwrap();
4626    }
4627
4628    #[test]
4629    fn json_uses_kebab_case_kind_tags() {
4630        let i = UpgradeInstruction::SoftPurge {
4631            module: "x-old".into(),
4632        };
4633        let json = serde_json::to_string(&i).unwrap();
4634        assert!(json.contains("\"kind\":\"soft-purge\""));
4635        let i2 = UpgradeInstruction::StateChange {
4636            script: PathBuf::from("m.lisp"),
4637        };
4638        let json2 = serde_json::to_string(&i2).unwrap();
4639        assert!(json2.contains("\"kind\":\"state-change\""));
4640    }
4641
4642    // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4643
4644    #[test]
4645    fn validate_upgrade_from_accepts_disjoint_versions() {
4646        // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4647        // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4648        // (and `entry_with_chain_of_versions` above) passes the cross-
4649        // entry gate. Different `:from` per entry is the intended
4650        // shape; the gate must not regress this baseline. Middle entry
4651        // pairs `:load-module` with `:soft-purge` to satisfy the
4652        // within-entry purge-ordering gate (see
4653        // `entry_with_chain_of_versions` for the same shape).
4654        let entries = vec![
4655            entry(
4656                "0.1.0",
4657                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4658            ),
4659            entry(
4660                "0.1.5",
4661                vec![
4662                    UpgradeInstruction::LoadModule { module: "x".into() },
4663                    UpgradeInstruction::SoftPurge {
4664                        module: "x-old".into(),
4665                    },
4666                ],
4667            ),
4668            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4669        ];
4670        validate_upgrade_from(&entries).unwrap();
4671    }
4672
4673    #[test]
4674    fn validate_upgrade_from_accepts_empty_list() {
4675        // Absent `:upgrade-from` (the bare `feira init` shape) — the
4676        // gate must trivially pass an empty list. Mirrors the per-axis
4677        // "empty list passes" positive control on every peer typed-
4678        // graph gate (`validate_membros` empty list, `validate_placement`
4679        // requires non-empty clusters but only after a `Placement`
4680        // exists, etc.).
4681        validate_upgrade_from(&[]).unwrap();
4682    }
4683
4684    #[test]
4685    fn validate_upgrade_from_rejects_duplicate_from() {
4686        // Fail-before-pass-after pin: two entries with the same parsed-
4687        // semver `:from` are an ambiguous edge in the typed upgrade
4688        // graph (OTP appup picks at most one matching block per running
4689        // version; with two matching blocks the operator picks either
4690        // set non-deterministically — author intent is one path per
4691        // prior version). Same set-not-multiset discipline as
4692        // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4693        // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4694        // `:entrada :paths` (eb3456d) — now extended onto the fifth
4695        // typed-graph axis.
4696        let entries = vec![
4697            entry(
4698                "0.1.0",
4699                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4700            ),
4701            entry(
4702                "0.1.0",
4703                vec![
4704                    UpgradeInstruction::LoadModule { module: "x".into() },
4705                    UpgradeInstruction::SoftPurge {
4706                        module: "x-old".into(),
4707                    },
4708                ],
4709            ),
4710        ];
4711        let err = validate_upgrade_from(&entries).unwrap_err();
4712        assert_eq!(
4713            err,
4714            UpgradeError::DuplicateFrom {
4715                from: "0.1.0".into()
4716            },
4717            "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4718             offending value verbatim"
4719        );
4720    }
4721
4722    #[test]
4723    fn validate_upgrade_from_treats_pre_release_as_distinct() {
4724        // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4725        // equal under semver (pre-release version is part of the
4726        // identity), so they're distinct upgrade paths and must not
4727        // collide. A future tightening that collapses pre-release into
4728        // the release version surfaces here.
4729        let entries = vec![
4730            entry("1.0.0", vec![UpgradeInstruction::Restart]),
4731            entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4732        ];
4733        validate_upgrade_from(&entries).unwrap();
4734    }
4735
4736    #[test]
4737    fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4738        // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4739        // compares build metadata (it derives equality across all
4740        // fields including `pre` + `build`), so `1.0.0+build1` and
4741        // `1.0.0+build2` are *not* duplicates from the gate's
4742        // perspective — the operator may treat the build-metadata
4743        // suffix as a tiebreaker even though the semver spec says
4744        // build metadata is ignored for precedence
4745        // (https://semver.org/#spec-item-10). Pin the conservative
4746        // behavior here so a future switch to a build-metadata-
4747        // stripping comparator surfaces as a test failure first; that
4748        // change would require coordinating with the wasm-operator's
4749        // `:from`-match dispatch step, which is the load-bearing
4750        // semantic we'd be mirroring.
4751        let entries = vec![
4752            entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4753            entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4754        ];
4755        validate_upgrade_from(&entries).unwrap();
4756    }
4757
4758    #[test]
4759    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4760        // Order pin: a malformed `:from` on the second entry surfaces
4761        // its `FromInvalid` diagnostic, not a (less-useful)
4762        // `DuplicateFrom`. The per-entry shape pass runs *inline*
4763        // before the duplicate-key insert — parallel to
4764        // `child_versao_invalid_fires_before_duplicate_check`
4765        // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4766        // (9888b13). Without this pin a future shortcut that runs the
4767        // cross-entry gate first would surface a duplicate diagnostic
4768        // on a string that isn't even parsable as a version.
4769        let entries = vec![
4770            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4771            entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4772        ];
4773        let err = validate_upgrade_from(&entries).unwrap_err();
4774        assert!(
4775            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4776            "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4777        );
4778    }
4779
4780    #[test]
4781    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4782        // Symmetric arm: a malformed shape on the *first* entry of a
4783        // duplicate pair surfaces its per-entry diagnostic too (not
4784        // the duplicate diagnostic that would otherwise fire on the
4785        // second entry). Pinned separately so a future shortcut that
4786        // walks the duplicate-check ahead of the per-entry pass for the
4787        // first entry only — easy regression to introduce — surfaces
4788        // here.
4789        let entries = vec![
4790            entry(
4791                "0.1.0",
4792                vec![UpgradeInstruction::LoadModule {
4793                    module: String::new(),
4794                }],
4795            ),
4796            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4797        ];
4798        let err = validate_upgrade_from(&entries).unwrap_err();
4799        assert_eq!(
4800            err,
4801            UpgradeError::ModuleEmpty {
4802                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4803            },
4804            "malformed instruction on the first entry of a duplicate pair must surface its \
4805             per-entry diagnostic before the duplicate gate fires, got {err:?}"
4806        );
4807    }
4808
4809    #[test]
4810    fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4811        // Diagnostic-shape pin: when three entries carry the same
4812        // `:from`, the gate reports the *first* collision (the second
4813        // entry) and stops — the third entry's duplicate is masked by
4814        // the first surfaced one. Mirrors
4815        // `validate_duplicate_child_diagnostic_names_first_collision`
4816        // (dbf50a9) on the supervisor axis.
4817        let entries = vec![
4818            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4819            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4820            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4821        ];
4822        let err = validate_upgrade_from(&entries).unwrap_err();
4823        assert_eq!(
4824            err,
4825            UpgradeError::DuplicateFrom {
4826                from: "0.1.0".into()
4827            }
4828        );
4829    }
4830
4831    #[test]
4832    fn validate_upgrade_from_single_entry_never_duplicates() {
4833        // Boundary control: a list of one entry can never produce a
4834        // duplicate, regardless of `:from` value (any single-element
4835        // set is trivially without duplicates). Pin this so a future
4836        // off-by-one in the seen-set insert doesn't accidentally flag
4837        // a single entry as duplicating itself.
4838        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4839        validate_upgrade_from(&entries).unwrap();
4840    }
4841
4842    // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4843
4844    #[test]
4845    fn versao_gate_accepts_strict_upgrade() {
4846        // Positive control: the canonical "chain prior versions →
4847        // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4848        // `:from` strictly less than the current `:versao` under
4849        // SemVer-2 precedence. The gate must not regress this baseline.
4850        let entries = vec![
4851            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4852            entry("0.1.5", vec![UpgradeInstruction::Restart]),
4853            entry("0.1.9", vec![UpgradeInstruction::Restart]),
4854        ];
4855        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4856    }
4857
4858    #[test]
4859    fn versao_gate_accepts_empty_entries() {
4860        // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4861        // the gate is a no-op when the entries list is empty. Mirrors
4862        // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4863        validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4864    }
4865
4866    #[test]
4867    fn versao_gate_rejects_equal_from() {
4868        // Self-upgrade no-op: declaring `:from "0.2.0"` while
4869        // `:versao "0.2.0"` means "upgrade from myself to myself" —
4870        // the operator's dispatch either skips silently or
4871        // trivially "succeeds" with no observable state change.
4872        // Reject as the canonical "I forgot to bump :versao when
4873        // adding this entry" footgun.
4874        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4875        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4876        assert_eq!(
4877            err,
4878            UpgradeError::FromNotBeforeVersao {
4879                from: "0.2.0".into(),
4880                versao: "0.2.0".into(),
4881            },
4882            ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4883             values verbatim, got {err:?}"
4884        );
4885    }
4886
4887    #[test]
4888    fn versao_gate_rejects_downgrade_from() {
4889        // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4890        // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4891        // the operator's `:from`-match dispatch can never reach (it
4892        // never runs a version >= the current one). Reject as the
4893        // canonical "I copy-pasted from the next minor version and
4894        // forgot to bump :versao" footgun.
4895        let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4896        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4897        assert_eq!(
4898            err,
4899            UpgradeError::FromNotBeforeVersao {
4900                from: "0.3.0".into(),
4901                versao: "0.2.0".into(),
4902            }
4903        );
4904    }
4905
4906    #[test]
4907    fn versao_gate_accepts_prerelease_before_release() {
4908        // SemVer §11 precedence: pre-release versions are *less than*
4909        // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4910        // FROM an RC TO the GA release is the canonical authoring
4911        // shape — must pass. A regression that collapses pre-release
4912        // into the release version (treating them as equal) surfaces
4913        // here as a false-positive rejection.
4914        let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4915        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4916    }
4917
4918    #[test]
4919    fn versao_gate_rejects_release_after_prerelease() {
4920        // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4921        // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4922        // the typical "I'm on an RC of a release that already
4923        // shipped" footgun. The gate names both values verbatim
4924        // so the author can grep for either side and fix in one
4925        // edit.
4926        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4927        let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4928        assert_eq!(
4929            err,
4930            UpgradeError::FromNotBeforeVersao {
4931                from: "0.2.0".into(),
4932                versao: "0.2.0-rc.1".into(),
4933            }
4934        );
4935    }
4936
4937    #[test]
4938    fn versao_gate_rejects_build_metadata_only_difference() {
4939        // SemVer §11 explicitly excludes build metadata from
4940        // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4941        // *equal* under [`semver::Version::cmp`]. From the
4942        // operator's `:from`-match dispatch perspective this is a
4943        // self-upgrade no-op (no semantic transition between the
4944        // two), so the gate rejects it — *unlike* the peer
4945        // duplicate-`:from` gate which uses derived `PartialEq` and
4946        // treats build-metadata variants as distinct dispatch keys.
4947        // The two gates' different equality notions are deliberate:
4948        // duplicate-check is conservative (preserves operator-side
4949        // tiebreaking surface), precedence-check is permissive
4950        // (matches operator-side dispatch semantic).
4951        let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4952        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4953        assert_eq!(
4954            err,
4955            UpgradeError::FromNotBeforeVersao {
4956                from: "0.2.0+build.1".into(),
4957                versao: "0.2.0".into(),
4958            }
4959        );
4960    }
4961
4962    #[test]
4963    fn versao_gate_silently_passes_on_unparseable_versao() {
4964        // Defensive arm: a malformed `:versao` (gated by the
4965        // narrower `ManifestError::VersaoInvalid` surface at the
4966        // load-bearing call site) must not regress into a
4967        // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4968        // the precedence error over an unparseable `:versao` would
4969        // mask the more actionable root cause (the author meant to
4970        // type `"0.2.0"`, not `"v0.2.0"`).
4971        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4972        validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4973    }
4974
4975    #[test]
4976    fn versao_gate_silently_passes_on_unparseable_from() {
4977        // Symmetric defensive arm: a malformed `:from` is gated by
4978        // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4979        // upstream at the LayoutInvariants call site. Surfacing the
4980        // precedence error over an unparseable `:from` from this
4981        // gate alone would mask the narrower `FromInvalid`
4982        // diagnostic that's expected to lead — same fall-through
4983        // posture as the unparseable-`:versao` arm above. The
4984        // wiring in `LayoutInvariants::verify` runs
4985        // `validate_upgrade_from` *before* this gate, so in practice
4986        // an unparseable `:from` surfaces as `FromInvalid` first
4987        // and this gate is never reached on that input.
4988        let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4989        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4990    }
4991
4992    #[test]
4993    fn versao_gate_reports_first_offending_entry() {
4994        // Determinism pin: with multiple offending entries the gate
4995        // surfaces the *first* one in declaration order — same
4996        // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4997        // on the peer gate. Walks the entries in order; first
4998        // failing `:from >= :versao` short-circuits.
4999        let entries = vec![
5000            entry("0.1.0", vec![UpgradeInstruction::Restart]),
5001            entry("0.3.0", vec![UpgradeInstruction::Restart]),
5002            entry("0.4.0", vec![UpgradeInstruction::Restart]),
5003        ];
5004        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5005        assert_eq!(
5006            err,
5007            UpgradeError::FromNotBeforeVersao {
5008                from: "0.3.0".into(),
5009                versao: "0.2.0".into(),
5010            },
5011            "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
5012        );
5013    }
5014
5015    // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
5016
5017    #[test]
5018    fn validate_rejects_restart_mixed_with_load_module() {
5019        // The "I'll try the typed path *then* restart anyway" footgun:
5020        // an instructions list with `(:restart)` plus `(:load-module …)`
5021        // is dead code in both directions (succeed → restart discards
5022        // the work that just succeeded, defeating the typed sequence's
5023        // whole point; fail → restart never reached because the entry
5024        // already failed). The gate names the offending entry's `:from`
5025        // verbatim plus the kebab-case lisp-form of every non-`:restart`
5026        // peer so the author can grep their caixa.lisp for either side
5027        // and fix in one edit.
5028        let e = entry(
5029            "0.1.0",
5030            vec![
5031                UpgradeInstruction::LoadModule {
5032                    module: "hello-rio".into(),
5033                },
5034                UpgradeInstruction::Restart,
5035            ],
5036        );
5037        let err = e.validate().unwrap_err();
5038        assert_eq!(
5039            err,
5040            UpgradeError::RestartNotExclusive {
5041                from: "0.1.0".into(),
5042                restart_count: 1,
5043                other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
5044            },
5045            "restart + load-module mix must surface as RestartNotExclusive naming the \
5046             offending `:from` + the non-:restart kinds verbatim, got {err:?}"
5047        );
5048    }
5049
5050    #[test]
5051    fn validate_rejects_restart_mixed_with_full_typed_sequence() {
5052        // Sweep the typed-sequence universe — every non-`:restart`
5053        // variant alongside `:restart` — and assert every typed
5054        // instruction's lisp-form appears in `other_kinds` in
5055        // declaration order. The author should be able to grep for
5056        // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
5057        // `:purge`) and resolve in one pass. Drift in the `lisp_form`
5058        // mapping surfaces here.
5059        let e = entry(
5060            "0.1.0",
5061            vec![
5062                UpgradeInstruction::LoadModule {
5063                    module: "hello-rio".into(),
5064                },
5065                UpgradeInstruction::StateChange {
5066                    script: PathBuf::from("lib/m.lisp"),
5067                },
5068                UpgradeInstruction::SoftPurge {
5069                    module: "hello-rio-old".into(),
5070                },
5071                UpgradeInstruction::Purge {
5072                    module: "hello-rio-old".into(),
5073                },
5074                UpgradeInstruction::Restart,
5075            ],
5076        );
5077        let err = e.validate().unwrap_err();
5078        assert_eq!(
5079            err,
5080            UpgradeError::RestartNotExclusive {
5081                from: "0.1.0".into(),
5082                restart_count: 1,
5083                other_kinds: vec![
5084                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5085                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
5086                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5087                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5088                ],
5089            },
5090        );
5091    }
5092
5093    #[test]
5094    fn validate_rejects_restart_duplicated() {
5095        // `((:restart) (:restart))` — multiple Restart variants in one
5096        // entry. The fallback is a single semantic (restart the pod;
5097        // the new version comes up fresh); repeating it is at best
5098        // redundant, at worst suggests the author thought the second
5099        // would re-trigger after the first. The gate reports
5100        // `restart_count: 2` so the diagnostic surfaces the duplication
5101        // mode unambiguously even when `other_kinds` is empty.
5102        let e = entry(
5103            "0.1.0",
5104            vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5105        );
5106        let err = e.validate().unwrap_err();
5107        assert_eq!(
5108            err,
5109            UpgradeError::RestartNotExclusive {
5110                from: "0.1.0".into(),
5111                restart_count: 2,
5112                other_kinds: vec![],
5113            },
5114        );
5115    }
5116
5117    #[test]
5118    fn validate_accepts_sole_restart() {
5119        // Positive control: the canonical "this prior version's typed
5120        // upgrade is impossible — restart" authoring shape from the
5121        // UpgradeInstruction::Restart doc comment. `((:restart))` alone
5122        // is the entry's whole instructions list and the only valid
5123        // Restart-bearing shape.
5124        let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
5125        e.validate().unwrap();
5126    }
5127
5128    #[test]
5129    fn validate_accepts_typed_sequence_without_restart() {
5130        // Positive control: the canonical typed hot-upgrade authoring
5131        // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
5132        // `:state-change` → `:soft-purge`. Absent `:restart` is the
5133        // only shape that lets the sequence run to completion under
5134        // the wasm-operator's `:from`-match dispatch. Drift here =
5135        // a future tighten that rejects any canonical typed-only shape
5136        // surfaces as a regression at this gate.
5137        let e = entry(
5138            "0.1.0",
5139            vec![
5140                UpgradeInstruction::LoadModule {
5141                    module: "hello-rio".into(),
5142                },
5143                UpgradeInstruction::StateChange {
5144                    script: PathBuf::from("lib/m.lisp"),
5145                },
5146                UpgradeInstruction::SoftPurge {
5147                    module: "hello-rio-old".into(),
5148                },
5149            ],
5150        );
5151        e.validate().unwrap();
5152    }
5153
5154    // ── within-entry state-change-ordering invariant ───────────────────
5155
5156    #[test]
5157    fn validate_rejects_state_change_without_load() {
5158        // Fail-before-pass-after pin: a `:state-change` migrates state
5159        // into the newly-loaded code (gen_server:code_change/3 analog),
5160        // so an entry that runs it with no preceding `:load-module`
5161        // migrates state into code that was never loaded. The operator
5162        // runs instructions in declared order, so this is a build error,
5163        // not a runtime surprise (CAIXA-SDLC §III).
5164        let e = entry(
5165            "0.1.0",
5166            vec![UpgradeInstruction::StateChange {
5167                script: PathBuf::from("lib/m.lisp"),
5168            }],
5169        );
5170        let err = e.validate().unwrap_err();
5171        assert_eq!(
5172            err,
5173            UpgradeError::StateChangeWithoutPriorLoad {
5174                from: "0.1.0".into(),
5175                script: PathBuf::from("lib/m.lisp"),
5176            },
5177            "a `:state-change` with no preceding `:load-module` must surface as \
5178             StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
5179        );
5180    }
5181
5182    #[test]
5183    fn validate_rejects_state_change_before_load() {
5184        // Right-instructions-wrong-order: the load is present but runs
5185        // *after* the migration. Because the operator executes in
5186        // declared order, the migration runs before the new code is
5187        // resident — the same incoherence as the missing-load case.
5188        let e = entry(
5189            "0.1.0",
5190            vec![
5191                UpgradeInstruction::StateChange {
5192                    script: PathBuf::from("lib/m.lisp"),
5193                },
5194                UpgradeInstruction::LoadModule {
5195                    module: "hello-rio".into(),
5196                },
5197            ],
5198        );
5199        let err = e.validate().unwrap_err();
5200        assert!(
5201            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5202            "a `:state-change` ahead of its `:load-module` must surface as \
5203             StateChangeWithoutPriorLoad, got {err:?}"
5204        );
5205    }
5206
5207    #[test]
5208    fn validate_accepts_state_change_after_load() {
5209        // Positive control: the canonical `(:load-module …)
5210        // (:state-change …)` order validates. The load need not name
5211        // the same module the migration targets (StateChange carries a
5212        // script, not a module ref), so any preceding `:load-module`
5213        // satisfies "new code is resident before its migration runs".
5214        let e = entry(
5215            "0.1.0",
5216            vec![
5217                UpgradeInstruction::LoadModule {
5218                    module: "hello-rio".into(),
5219                },
5220                UpgradeInstruction::StateChange {
5221                    script: PathBuf::from("lib/m.lisp"),
5222                },
5223            ],
5224        );
5225        e.validate().unwrap();
5226    }
5227
5228    #[test]
5229    fn validate_accepts_multiple_state_changes_after_one_load() {
5230        // A single leading `:load-module` covers every subsequent
5231        // `:state-change` — the `loaded` latch stays set once the new
5232        // code is resident.
5233        let e = entry(
5234            "0.1.0",
5235            vec![
5236                UpgradeInstruction::LoadModule {
5237                    module: "hello-rio".into(),
5238                },
5239                UpgradeInstruction::StateChange {
5240                    script: PathBuf::from("lib/m1.lisp"),
5241                },
5242                UpgradeInstruction::StateChange {
5243                    script: PathBuf::from("lib/m2.lisp"),
5244                },
5245            ],
5246        );
5247        e.validate().unwrap();
5248    }
5249
5250    #[test]
5251    fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5252     {
5253        // Byte-identity pin on the
5254        // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5255        // migrate ordering dispatch against the pre-lift
5256        // `match instr { UpgradeInstruction::LoadModule { .. } =>
5257        // loaded = true, UpgradeInstruction::StateChange { script } if
5258        // !loaded => …, _ => {} }` open-coded pattern-match the site
5259        // previously carried. Asserts the two projections agree
5260        // byte-for-byte on every arm of the enum — the load-family
5261        // arm-discriminator via `is_load_module()` and the migration-
5262        // family `:script` scalar via `declared_path()` — so a future
5263        // derive regression that flipped the predicate's arm-set (a
5264        // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5265        // a byte-collision flipping a second variant to `true`) or an
5266        // accessor extension that promoted an additional variant onto
5267        // the `PathBuf`-carrying axis would trip here at caixa-core
5268        // test time rather than laundering the arm at the gate's
5269        // per-entry ordering scan far from the derive site.
5270        //
5271        // Peer of the sibling
5272        // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5273        // (c9ce91d) pin on the peer within-entry per-instruction-class
5274        // singularity gate's load-family + `String`-carrying dispatch,
5275        // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5276        // (580d0f1) pin on the paired load → cleanup ordering gate's
5277        // load-family sticky-latch dispatch, and the
5278        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5279        // pin on the peer within-entry per-instruction-class singularity
5280        // gate's migration-family script-projection dispatch — closes
5281        // the last unlifted `match`-shaped per-arm-hand-rolled load-
5282        // family arm-discriminator + migration-family script-projection
5283        // pair inside `impl UpgradeFromEntry`. The four within-entry
5284        // ordering / singularity gates now share one byte-identity pin
5285        // apiece against their respective substrate-primitive typed
5286        // dispatches on the OTP-appup closed-set enum.
5287        //
5288        // Three-arm projective coverage:
5289        //   (a) `LoadModule` satisfies `is_load_module()`, so the
5290        //       sticky-latch advances byte-equal to the pre-lift
5291        //       `UpgradeInstruction::LoadModule { .. }` arm; every
5292        //       other variant leaves the latch untouched;
5293        //   (b) a `((:state-change …))`-only entry (no preceding load)
5294        //       trips the gate on the first `StateChange` with
5295        //       `StateChangeWithoutPriorLoad` carrying the offending
5296        //       script verbatim — the migration-family script surfaces
5297        //       through `declared_path()` byte-equal to the raw
5298        //       `StateChange { script }` pattern-bound field;
5299        //   (c) a `((:load-module …) (:state-change …))` entry leaves
5300        //       the gate vacuous with `Ok(())` — the `loaded = true`
5301        //       latch on the first arm satisfies the `!loaded` guard
5302        //       negation on the second, so the `declared_path()`
5303        //       `Some(script)` fall-through does not fire — and a
5304        //       non-`StateChange`-non-`LoadModule` sequence
5305        //       (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5306        //       the gate vacuous because `declared_path()` is `None`
5307        //       on all three of those arms.
5308        //
5309        // Fail-before-pass-after verified locally: swapping the
5310        // production `if instr.is_load_module() { loaded = true; }
5311        // else if !loaded && let Some(script) = instr.declared_path()
5312        // { … }` back to `match instr { UpgradeInstruction::LoadModule
5313        // { .. } => loaded = true, UpgradeInstruction::StateChange
5314        // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5315        // passing but silently detaches the gate from the accessor's
5316        // typed dispatch — any future `is_load_module` / `declared_path`
5317        // extension (a hole in either predicate, a promotion of an
5318        // additional variant onto either axis, an operator-side
5319        // pre-resolved-path cache the accessor materializes) would
5320        // then silently disagree between this gate's raw pattern-match
5321        // and the peer per-`UpgradeInstruction` consumers that route
5322        // through the accessor pair.
5323
5324        // (a) is_load_module() partitions the arm-set byte-equal to
5325        //     the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5326        //     { .. })` and declared_path() surfaces the StateChange
5327        //     `:script` byte-equal to the raw field access.
5328        let lm = UpgradeInstruction::LoadModule {
5329            module: "hello-rio".into(),
5330        };
5331        assert!(
5332            lm.is_load_module(),
5333            "LoadModule must satisfy is_load_module() — the gate's \
5334             load-family sticky-latch relies on this partition"
5335        );
5336        assert!(
5337            lm.declared_path().is_none(),
5338            "LoadModule must not carry a declared_path — the gate's \
5339             else-if migration-family arm must not fire on load arms"
5340        );
5341        let sc = UpgradeInstruction::StateChange {
5342            script: PathBuf::from("lib/m.lisp"),
5343        };
5344        assert!(
5345            !sc.is_load_module(),
5346            "StateChange must not satisfy is_load_module() — the gate's \
5347             sticky-latch must not advance on migration arms"
5348        );
5349        assert_eq!(
5350            sc.declared_path().map(std::path::PathBuf::as_path),
5351            Some(PathBuf::from("lib/m.lisp").as_path()),
5352            "declared_path() must project the StateChange :script \
5353             byte-equal to the raw field access — accessor divergence \
5354             would silently detach the gate from the projection every \
5355             peer per-`UpgradeInstruction` consumer routes through"
5356        );
5357
5358        // (b) A `((:state-change …))`-only entry trips
5359        //     StateChangeWithoutPriorLoad byte-identical to the
5360        //     pre-lift match-pattern shape.
5361        let no_prior_load = entry(
5362            "0.1.0",
5363            vec![UpgradeInstruction::StateChange {
5364                script: PathBuf::from("lib/m.lisp"),
5365            }],
5366        );
5367        assert_eq!(
5368            no_prior_load.validate_state_change_ordering(),
5369            Err(UpgradeError::StateChangeWithoutPriorLoad {
5370                from: "0.1.0".into(),
5371                script: PathBuf::from("lib/m.lisp"),
5372            }),
5373            "a `:state-change` with no preceding `:load-module` must fire \
5374             StateChangeWithoutPriorLoad carrying the offending script \
5375             verbatim through the declared_path() accessor"
5376        );
5377
5378        // (c) `((:load-module …) (:state-change …))` leaves the gate
5379        //     vacuous; so does a non-StateChange-non-LoadModule
5380        //     sequence (SoftPurge / Purge / Restart alone).
5381        let load_before_migrate = entry(
5382            "0.1.0",
5383            vec![
5384                UpgradeInstruction::LoadModule {
5385                    module: "hello-rio".into(),
5386                },
5387                UpgradeInstruction::StateChange {
5388                    script: PathBuf::from("lib/m.lisp"),
5389                },
5390            ],
5391        );
5392        assert_eq!(
5393            load_before_migrate.validate_state_change_ordering(),
5394            Ok(()),
5395            "load-before-migrate entries must leave the ordering gate \
5396             vacuous — the `loaded = true` sticky-latch on the first arm \
5397             satisfies the `!loaded` guard negation on the else-if arm"
5398        );
5399        for instr in [
5400            UpgradeInstruction::SoftPurge {
5401                module: "x-old".into(),
5402            },
5403            UpgradeInstruction::Purge {
5404                module: "x-old".into(),
5405            },
5406            UpgradeInstruction::Restart,
5407        ] {
5408            let e = entry("0.1.0", vec![instr.clone()]);
5409            assert_eq!(
5410                e.validate_state_change_ordering(),
5411                Ok(()),
5412                "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5413                 leave the ordering gate vacuous — declared_path() is None \
5414                 on every non-StateChange arm, so the else-if migration-\
5415                 family arm never fires"
5416            );
5417        }
5418    }
5419
5420    #[test]
5421    fn validate_state_change_ordering_fires_after_restart_exclusive() {
5422        // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5423        // shape is *both* state-change-without-load and restart-mixed.
5424        // The more-fundamental `RestartNotExclusive` must win (a valid
5425        // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5426        // entry should reach the ordering gate). Guards the call order
5427        // in `validate` against silent reordering.
5428        let e = entry(
5429            "0.1.0",
5430            vec![
5431                UpgradeInstruction::StateChange {
5432                    script: PathBuf::from("lib/m.lisp"),
5433                },
5434                UpgradeInstruction::Restart,
5435            ],
5436        );
5437        let err = e.validate().unwrap_err();
5438        assert!(
5439            matches!(err, UpgradeError::RestartNotExclusive { .. }),
5440            "restart-mixed must surface before the ordering gate, got {err:?}"
5441        );
5442    }
5443
5444    // ── within-entry purge-ordering invariant ──────────────────────────
5445
5446    #[test]
5447    fn validate_rejects_soft_purge_without_load() {
5448        // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5449        // module after the new one is resident (OTP's two-phase code
5450        // load — code:load_module/1 then code:soft_purge/1), so an
5451        // entry that runs it with no preceding `:load-module` drains
5452        // the live module with no replacement. The operator runs
5453        // instructions in declared order, so this is a build error,
5454        // not a runtime surprise (CAIXA-SDLC §III).
5455        let e = entry(
5456            "0.1.0",
5457            vec![UpgradeInstruction::SoftPurge {
5458                module: "x-old".into(),
5459            }],
5460        );
5461        let err = e.validate().unwrap_err();
5462        assert_eq!(
5463            err,
5464            UpgradeError::PurgeWithoutPriorLoad {
5465                from: "0.1.0".into(),
5466                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5467                module: "x-old".into(),
5468            },
5469            "a `:soft-purge` with no preceding `:load-module` must surface as \
5470             PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5471        );
5472    }
5473
5474    #[test]
5475    fn validate_rejects_purge_without_load() {
5476        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5477        // the more catastrophic peer of `:soft-purge`; same gate, same
5478        // shape, kind-tag differs so the author can grep their
5479        // caixa.lisp for the offending `(:purge …)` form.
5480        let e = entry(
5481            "0.1.0",
5482            vec![UpgradeInstruction::Purge {
5483                module: "x-old".into(),
5484            }],
5485        );
5486        let err = e.validate().unwrap_err();
5487        assert_eq!(
5488            err,
5489            UpgradeError::PurgeWithoutPriorLoad {
5490                from: "0.1.0".into(),
5491                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5492                module: "x-old".into(),
5493            },
5494        );
5495    }
5496
5497    #[test]
5498    fn validate_rejects_soft_purge_before_load() {
5499        // Right-instructions-wrong-order: the load is present but runs
5500        // *after* the purge. Because the operator executes in declared
5501        // order, the cleanup drains the old code before the new code
5502        // is resident — same incoherence as the missing-load case,
5503        // leaving a window during which neither version is available.
5504        let e = entry(
5505            "0.1.0",
5506            vec![
5507                UpgradeInstruction::SoftPurge {
5508                    module: "x-old".into(),
5509                },
5510                UpgradeInstruction::LoadModule { module: "x".into() },
5511            ],
5512        );
5513        let err = e.validate().unwrap_err();
5514        assert!(
5515            matches!(
5516                err,
5517                UpgradeError::PurgeWithoutPriorLoad {
5518                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5519                    ..
5520                }
5521            ),
5522            "a `:soft-purge` ahead of its `:load-module` must surface as \
5523             PurgeWithoutPriorLoad, got {err:?}"
5524        );
5525    }
5526
5527    #[test]
5528    fn validate_rejects_purge_before_load() {
5529        // Symmetric arm on the `:purge` variant — the kind tag
5530        // distinguishes the diagnostic so the author lands on the
5531        // offending form directly.
5532        let e = entry(
5533            "0.1.0",
5534            vec![
5535                UpgradeInstruction::Purge {
5536                    module: "x-old".into(),
5537                },
5538                UpgradeInstruction::LoadModule { module: "x".into() },
5539            ],
5540        );
5541        let err = e.validate().unwrap_err();
5542        assert!(
5543            matches!(
5544                err,
5545                UpgradeError::PurgeWithoutPriorLoad {
5546                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5547                    ..
5548                }
5549            ),
5550            "a `:purge` ahead of its `:load-module` must surface as \
5551             PurgeWithoutPriorLoad, got {err:?}"
5552        );
5553    }
5554
5555    #[test]
5556    fn validate_accepts_soft_purge_after_load() {
5557        // Positive control: the canonical `(:load-module …)
5558        // (:soft-purge …)` order validates. The load need not name the
5559        // same module the purge targets — the cleanup typically targets
5560        // the *old* module name (e.g. `"x-old"`) and the load brings up
5561        // the *new* one (`"x"`); the gate only requires that *some*
5562        // `:load-module` precedes the purge, so the new code is resident
5563        // before the old one is drained.
5564        let e = entry(
5565            "0.1.0",
5566            vec![
5567                UpgradeInstruction::LoadModule { module: "x".into() },
5568                UpgradeInstruction::SoftPurge {
5569                    module: "x-old".into(),
5570                },
5571            ],
5572        );
5573        e.validate().unwrap();
5574    }
5575
5576    #[test]
5577    fn validate_accepts_multiple_purges_after_one_load() {
5578        // A single leading `:load-module` covers every subsequent
5579        // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5580        // the new code is resident. Same shape as
5581        // `validate_accepts_multiple_state_changes_after_one_load` on
5582        // the peer ordering gate.
5583        let e = entry(
5584            "0.1.0",
5585            vec![
5586                UpgradeInstruction::LoadModule { module: "x".into() },
5587                UpgradeInstruction::SoftPurge {
5588                    module: "x-old".into(),
5589                },
5590                UpgradeInstruction::Purge {
5591                    module: "x-oldest".into(),
5592                },
5593            ],
5594        );
5595        e.validate().unwrap();
5596    }
5597
5598    #[test]
5599    fn validate_purge_ordering_fires_after_state_change_ordering() {
5600        // Diagnostic-precedence pin: an entry like `((:state-change …)
5601        // (:soft-purge …))` is *both* state-change-without-load and
5602        // purge-without-load. The state-change gate must win — it's
5603        // the load-bearing semantic on this ordering contract, and
5604        // surfacing the purge diagnostic first would mask the more-
5605        // fundamental migration-against-stale-code defect. Guards the
5606        // call order in `validate` against silent reordering.
5607        let e = entry(
5608            "0.1.0",
5609            vec![
5610                UpgradeInstruction::StateChange {
5611                    script: PathBuf::from("lib/m.lisp"),
5612                },
5613                UpgradeInstruction::SoftPurge {
5614                    module: "x-old".into(),
5615                },
5616            ],
5617        );
5618        let err = e.validate().unwrap_err();
5619        assert!(
5620            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5621            "state-change-without-load must surface before purge-without-load, got {err:?}"
5622        );
5623    }
5624
5625    #[test]
5626    fn validate_purge_ordering_fires_after_per_instr_shape() {
5627        // Order pin: a malformed `:module` value on a `:soft-purge` (an
5628        // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5629        // diagnostic *before* the within-entry purge-ordering gate fires.
5630        // The per-instruction shape pass walks the list inline before
5631        // the ordering checks, so the narrower self-locating diagnostic
5632        // surfaces first — mirrors the empty-first cascade on every peer
5633        // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5634        // per_instr_shape` pin on the sibling ordering gate.
5635        let e = entry(
5636            "0.1.0",
5637            vec![UpgradeInstruction::SoftPurge {
5638                module: String::new(),
5639            }],
5640        );
5641        let err = e.validate().unwrap_err();
5642        assert_eq!(
5643            err,
5644            UpgradeError::ModuleEmpty {
5645                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5646            },
5647            "malformed instruction must surface its kind-tagged diagnostic before the \
5648             purge-ordering gate fires, got {err:?}"
5649        );
5650    }
5651
5652    #[test]
5653    fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5654        // The whole-list entry-point surfaces the per-entry ordering
5655        // error (mirrors
5656        // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5657        // the gate is reachable from the LayoutInvariants call site, not
5658        // only from a direct `entry.validate()`.
5659        let entries = vec![entry(
5660            "0.1.0",
5661            vec![UpgradeInstruction::Purge {
5662                module: "x-old".into(),
5663            }],
5664        )];
5665        let err = validate_upgrade_from(&entries).unwrap_err();
5666        assert!(
5667            matches!(
5668                err,
5669                UpgradeError::PurgeWithoutPriorLoad {
5670                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5671                    ..
5672                }
5673            ),
5674            "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5675        );
5676    }
5677
5678    #[test]
5679    fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5680        // The whole-list entry-point surfaces the per-entry ordering
5681        // error (mirrors `validate_restart_exclusive_threads_through_…`):
5682        // the gate is reachable from the LayoutInvariants call site, not
5683        // only from a direct `entry.validate()`.
5684        let entries = vec![entry(
5685            "0.1.0",
5686            vec![UpgradeInstruction::StateChange {
5687                script: PathBuf::from("lib/m.lisp"),
5688            }],
5689        )];
5690        let err = validate_upgrade_from(&entries).unwrap_err();
5691        assert!(
5692            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5693            "validate_upgrade_from must thread the ordering error, got {err:?}"
5694        );
5695    }
5696
5697    // ── within-entry cleanup-singularity invariant ─────────────────────
5698
5699    #[test]
5700    fn validate_rejects_duplicate_soft_purge_for_same_module() {
5701        // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5702        // its target module (code:soft_purge/1 analog); after the
5703        // first the module is gone, so a second `:soft-purge` of the
5704        // same module is at best a no-op and at worst undefined
5705        // (depending on the operator's handling of a non-resident-
5706        // module purge). Author one cleanup per module.
5707        let e = entry(
5708            "0.1.0",
5709            vec![
5710                UpgradeInstruction::LoadModule { module: "x".into() },
5711                UpgradeInstruction::SoftPurge {
5712                    module: "x-old".into(),
5713                },
5714                UpgradeInstruction::SoftPurge {
5715                    module: "x-old".into(),
5716                },
5717            ],
5718        );
5719        let err = e.validate().unwrap_err();
5720        assert_eq!(
5721            err,
5722            UpgradeError::DuplicateCleanup {
5723                from: "0.1.0".into(),
5724                module: "x-old".into(),
5725                kinds: vec![
5726                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5727                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5728                ],
5729            },
5730            "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5731             module + both kinds in declaration order, got {err:?}"
5732        );
5733    }
5734
5735    #[test]
5736    fn validate_rejects_duplicate_purge_for_same_module() {
5737        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5738        // the more catastrophic peer of `:soft-purge`; same gate, same
5739        // shape, kind-tag distinguishes so the author can grep their
5740        // caixa.lisp for the offending `(:purge …)` form.
5741        let e = entry(
5742            "0.1.0",
5743            vec![
5744                UpgradeInstruction::LoadModule { module: "x".into() },
5745                UpgradeInstruction::Purge {
5746                    module: "x-old".into(),
5747                },
5748                UpgradeInstruction::Purge {
5749                    module: "x-old".into(),
5750                },
5751            ],
5752        );
5753        let err = e.validate().unwrap_err();
5754        assert_eq!(
5755            err,
5756            UpgradeError::DuplicateCleanup {
5757                from: "0.1.0".into(),
5758                module: "x-old".into(),
5759                kinds: vec![
5760                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5761                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5762                ],
5763            },
5764        );
5765    }
5766
5767    #[test]
5768    fn validate_rejects_soft_purge_then_purge_for_same_module() {
5769        // Soft-then-hard footgun: the author wrote "drain, and if
5770        // drain doesn't clean up, force-discard", but the operator
5771        // runs declared instructions unconditionally — the `:purge`
5772        // fires whether the `:soft-purge` already discarded the
5773        // module or not, so the imagined fallback semantic is
5774        // missing. Fallback on cleanup failure is the operator's
5775        // job, not authored into the entry. Both kinds carry in
5776        // declaration order so the author can grep for either side
5777        // and pick one.
5778        let e = entry(
5779            "0.1.0",
5780            vec![
5781                UpgradeInstruction::LoadModule { module: "x".into() },
5782                UpgradeInstruction::SoftPurge {
5783                    module: "x-old".into(),
5784                },
5785                UpgradeInstruction::Purge {
5786                    module: "x-old".into(),
5787                },
5788            ],
5789        );
5790        let err = e.validate().unwrap_err();
5791        assert_eq!(
5792            err,
5793            UpgradeError::DuplicateCleanup {
5794                from: "0.1.0".into(),
5795                module: "x-old".into(),
5796                kinds: vec![
5797                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5798                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5799                ],
5800            },
5801        );
5802    }
5803
5804    #[test]
5805    fn validate_rejects_purge_then_soft_purge_for_same_module() {
5806        // Reversed-ordering arm: `:purge` discards immediately; the
5807        // trailing `:soft-purge` has no module to drain. The kinds
5808        // list reflects declaration order so the diagnostic locates
5809        // both forms in the source.
5810        let e = entry(
5811            "0.1.0",
5812            vec![
5813                UpgradeInstruction::LoadModule { module: "x".into() },
5814                UpgradeInstruction::Purge {
5815                    module: "x-old".into(),
5816                },
5817                UpgradeInstruction::SoftPurge {
5818                    module: "x-old".into(),
5819                },
5820            ],
5821        );
5822        let err = e.validate().unwrap_err();
5823        assert_eq!(
5824            err,
5825            UpgradeError::DuplicateCleanup {
5826                from: "0.1.0".into(),
5827                module: "x-old".into(),
5828                kinds: vec![
5829                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5830                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5831                ],
5832            },
5833        );
5834    }
5835
5836    #[test]
5837    fn validate_accepts_distinct_cleanup_modules() {
5838        // Positive control: `:soft-purge` and `:purge` on *different*
5839        // modules pass the gate. Mirrors
5840        // `validate_accepts_multiple_purges_after_one_load` — the
5841        // cleanup-singularity gate is keyed on (module), not on
5842        // (kind, module) pair, so distinct old-version names render
5843        // distinct cleanup targets and don't collide. Sweep both
5844        // same-class (two `:soft-purge` distinct modules) and cross-
5845        // class (`:soft-purge` then `:purge` distinct modules) so a
5846        // future tighten to a kind-only key (which would over-fire on
5847        // distinct modules) surfaces here.
5848        let two_soft = entry(
5849            "0.1.0",
5850            vec![
5851                UpgradeInstruction::LoadModule { module: "x".into() },
5852                UpgradeInstruction::SoftPurge {
5853                    module: "x-old".into(),
5854                },
5855                UpgradeInstruction::SoftPurge {
5856                    module: "x-older".into(),
5857                },
5858            ],
5859        );
5860        two_soft.validate().unwrap();
5861        let mixed = entry(
5862            "0.1.0",
5863            vec![
5864                UpgradeInstruction::LoadModule { module: "x".into() },
5865                UpgradeInstruction::SoftPurge {
5866                    module: "x-old".into(),
5867                },
5868                UpgradeInstruction::Purge {
5869                    module: "x-oldest".into(),
5870                },
5871            ],
5872        );
5873        mixed.validate().unwrap();
5874    }
5875
5876    #[test]
5877    fn validate_accepts_single_cleanup_per_module() {
5878        // Boundary control: a list with exactly one `:soft-purge` and
5879        // one `:purge` (distinct modules, the canonical "drain one,
5880        // hard-discard the other" shape) is the gate's identity
5881        // element. Pin so a future off-by-one in the duplicate-detection
5882        // scan doesn't accidentally flag a single occurrence as
5883        // duplicating itself — mirrors
5884        // `validate_upgrade_from_single_entry_never_duplicates` on
5885        // the peer cross-entry duplicate axis.
5886        let e = entry(
5887            "0.1.0",
5888            vec![
5889                UpgradeInstruction::LoadModule { module: "x".into() },
5890                UpgradeInstruction::SoftPurge {
5891                    module: "x-old".into(),
5892                },
5893                UpgradeInstruction::Purge {
5894                    module: "y-old".into(),
5895                },
5896            ],
5897        );
5898        e.validate().unwrap();
5899    }
5900
5901    #[test]
5902    fn validate_cleanup_singularity_fires_after_purge_ordering() {
5903        // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5904        // (:soft-purge "x"))` is *both* purge-without-load and
5905        // duplicate-cleanup. The more-fundamental ordering gate must
5906        // win — the missing-load defect is load-bearing (the canonical
5907        // OTP shape requires the new code be resident before any
5908        // cleanup runs), and surfacing the duplicate diagnostic first
5909        // would mask the no-replacement-window defect the ordering
5910        // gate exists to close. Guards the call order in `validate`
5911        // against silent reordering. Same posture as
5912        // `validate_purge_ordering_fires_after_state_change_ordering`
5913        // on the sibling ordering gate.
5914        let e = entry(
5915            "0.1.0",
5916            vec![
5917                UpgradeInstruction::SoftPurge {
5918                    module: "x-old".into(),
5919                },
5920                UpgradeInstruction::SoftPurge {
5921                    module: "x-old".into(),
5922                },
5923            ],
5924        );
5925        let err = e.validate().unwrap_err();
5926        assert!(
5927            matches!(
5928                err,
5929                UpgradeError::PurgeWithoutPriorLoad {
5930                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5931                    ..
5932                }
5933            ),
5934            "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5935        );
5936    }
5937
5938    #[test]
5939    fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5940        // Order pin: a malformed `:module` value on a `:soft-purge`
5941        // (an empty string) surfaces its narrower kind-tagged
5942        // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5943        // singularity gate fires. The per-instruction shape pass walks
5944        // the list inline before the singularity check, so the
5945        // narrower self-locating diagnostic surfaces first — mirrors
5946        // the empty-first cascade on every peer DNS-1123 gate and the
5947        // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5948        // the sibling ordering gate.
5949        //
5950        // Two empty-string `:soft-purge` would *otherwise* duplicate
5951        // (both modules are the same empty string), so this pin
5952        // double-locks the precedence: the per-instr shape gate must
5953        // win on the first malformed instruction before the duplicate
5954        // scan even reaches the second.
5955        let e = entry(
5956            "0.1.0",
5957            vec![
5958                UpgradeInstruction::LoadModule { module: "x".into() },
5959                UpgradeInstruction::SoftPurge {
5960                    module: String::new(),
5961                },
5962                UpgradeInstruction::SoftPurge {
5963                    module: String::new(),
5964                },
5965            ],
5966        );
5967        let err = e.validate().unwrap_err();
5968        assert_eq!(
5969            err,
5970            UpgradeError::ModuleEmpty {
5971                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5972            },
5973            "malformed instruction must surface its kind-tagged diagnostic before the \
5974             cleanup-singularity gate fires, got {err:?}"
5975        );
5976    }
5977
5978    #[test]
5979    fn validate_cleanup_singularity_reports_first_collision() {
5980        // Determinism pin: with three cleanups of the same module the
5981        // gate reports the *first* collision (the second occurrence)
5982        // and stops — the third's duplicate is masked by the first
5983        // surfaced one. Mirrors
5984        // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5985        // on the peer cross-entry duplicate axis.
5986        let e = entry(
5987            "0.1.0",
5988            vec![
5989                UpgradeInstruction::LoadModule { module: "x".into() },
5990                UpgradeInstruction::SoftPurge {
5991                    module: "x-old".into(),
5992                },
5993                UpgradeInstruction::SoftPurge {
5994                    module: "x-old".into(),
5995                },
5996                UpgradeInstruction::Purge {
5997                    module: "x-old".into(),
5998                },
5999            ],
6000        );
6001        let err = e.validate().unwrap_err();
6002        assert_eq!(
6003            err,
6004            UpgradeError::DuplicateCleanup {
6005                from: "0.1.0".into(),
6006                module: "x-old".into(),
6007                kinds: vec![
6008                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6009                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6010                ],
6011            },
6012            "the first colliding pair must surface, not the later `:purge` collision"
6013        );
6014    }
6015
6016    #[test]
6017    fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
6018        // The whole-list entry-point surfaces the per-entry singularity
6019        // error (mirrors
6020        // `validate_purge_ordering_threads_through_validate_upgrade_from`):
6021        // the gate is reachable from the LayoutInvariants call site,
6022        // not only from a direct `entry.validate()`.
6023        let entries = vec![entry(
6024            "0.1.0",
6025            vec![
6026                UpgradeInstruction::LoadModule { module: "x".into() },
6027                UpgradeInstruction::SoftPurge {
6028                    module: "x-old".into(),
6029                },
6030                UpgradeInstruction::Purge {
6031                    module: "x-old".into(),
6032                },
6033            ],
6034        )];
6035        let err = validate_upgrade_from(&entries).unwrap_err();
6036        assert!(
6037            matches!(err, UpgradeError::DuplicateCleanup { .. }),
6038            "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
6039        );
6040    }
6041
6042    #[test]
6043    fn validate_rejects_duplicate_load_module_for_same_module() {
6044        // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
6045        // §II.4): each module is loaded exactly once per upgrade entry,
6046        // the operator's dispatch table reads the module name to bind
6047        // the wasm component, and a second `(:load-module "x")` re-reads
6048        // the same module name and re-binds the same component — a
6049        // no-op the second time. systools-generated `.relup` files emit
6050        // at most one `load_module` per module per upgrade step for
6051        // this reason. Author one `(:load-module "x")` per old module.
6052        let e = entry(
6053            "0.1.0",
6054            vec![
6055                UpgradeInstruction::LoadModule { module: "x".into() },
6056                UpgradeInstruction::LoadModule { module: "x".into() },
6057            ],
6058        );
6059        let err = e.validate().unwrap_err();
6060        assert_eq!(
6061            err,
6062            UpgradeError::DuplicateLoadModule {
6063                from: "0.1.0".into(),
6064                module: "x".into(),
6065            },
6066            "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
6067             the module, got {err:?}"
6068        );
6069    }
6070
6071    #[test]
6072    fn validate_accepts_distinct_load_modules() {
6073        // Positive control: `:load-module` instructions on *different*
6074        // modules pass the gate. Mirrors
6075        // `validate_accepts_distinct_cleanup_modules` on the sibling
6076        // singularity axis — the load-singularity gate is keyed on
6077        // (module), so distinct module names render distinct load
6078        // targets and don't collide. Sweep both the bare two-load shape
6079        // and the canonical load-pair-with-cleanup shape so a future
6080        // tighten that over-fires on distinct loads surfaces here.
6081        let two_loads = entry(
6082            "0.1.0",
6083            vec![
6084                UpgradeInstruction::LoadModule { module: "x".into() },
6085                UpgradeInstruction::LoadModule { module: "y".into() },
6086            ],
6087        );
6088        two_loads.validate().unwrap();
6089        let with_cleanup = entry(
6090            "0.1.0",
6091            vec![
6092                UpgradeInstruction::LoadModule { module: "x".into() },
6093                UpgradeInstruction::LoadModule { module: "y".into() },
6094                UpgradeInstruction::SoftPurge {
6095                    module: "x-old".into(),
6096                },
6097                UpgradeInstruction::SoftPurge {
6098                    module: "y-old".into(),
6099                },
6100            ],
6101        );
6102        with_cleanup.validate().unwrap();
6103    }
6104
6105    #[test]
6106    fn validate_accepts_single_load_per_module() {
6107        // Boundary control: a list with exactly one `:load-module`
6108        // followed by the canonical `:state-change` + `:soft-purge`
6109        // sequence (the module-doc OTP shape) is the gate's identity
6110        // element. Pin so a future off-by-one in the duplicate-
6111        // detection scan doesn't accidentally flag a single occurrence
6112        // as duplicating itself — mirrors
6113        // `validate_accepts_single_cleanup_per_module` on the sibling
6114        // singularity axis.
6115        let e = entry(
6116            "0.1.0",
6117            vec![
6118                UpgradeInstruction::LoadModule { module: "x".into() },
6119                UpgradeInstruction::StateChange {
6120                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6121                },
6122                UpgradeInstruction::SoftPurge {
6123                    module: "x-old".into(),
6124                },
6125            ],
6126        );
6127        e.validate().unwrap();
6128    }
6129
6130    #[test]
6131    fn validate_load_singularity_fires_after_state_change_ordering() {
6132        // Diagnostic-precedence pin: an entry like `((:state-change
6133        // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
6134        // state-change-without-load and duplicate-load. The more-
6135        // fundamental ordering gate must win — the missing-load defect
6136        // is load-bearing (the migration runs against unloaded code),
6137        // and surfacing the duplicate diagnostic first would mask the
6138        // migrate-into-unloaded-code defect the ordering gate exists
6139        // to close. Guards the call order in `validate` against silent
6140        // reordering. Same posture as
6141        // `validate_cleanup_singularity_fires_after_purge_ordering`
6142        // on the sibling singularity gate.
6143        let e = entry(
6144            "0.1.0",
6145            vec![
6146                UpgradeInstruction::StateChange {
6147                    script: PathBuf::from("lib/m.lisp"),
6148                },
6149                UpgradeInstruction::LoadModule { module: "x".into() },
6150                UpgradeInstruction::LoadModule { module: "x".into() },
6151            ],
6152        );
6153        let err = e.validate().unwrap_err();
6154        assert!(
6155            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6156            "state-change-without-load must surface before duplicate-load, got {err:?}"
6157        );
6158    }
6159
6160    #[test]
6161    fn validate_load_singularity_fires_after_purge_ordering() {
6162        // Diagnostic-precedence pin: an entry like `((:soft-purge
6163        // "x-old") (:load-module "x") (:load-module "x"))` is *both*
6164        // purge-without-load and duplicate-load. The more-fundamental
6165        // ordering gate must win — the missing-load defect is load-
6166        // bearing (the cleanup runs against no-replacement-window),
6167        // and surfacing the duplicate diagnostic first would mask the
6168        // drain-to-nothing defect the ordering gate exists to close.
6169        // Sibling of
6170        // `validate_cleanup_singularity_fires_after_purge_ordering` on
6171        // the load-singularity axis.
6172        let e = entry(
6173            "0.1.0",
6174            vec![
6175                UpgradeInstruction::SoftPurge {
6176                    module: "x-old".into(),
6177                },
6178                UpgradeInstruction::LoadModule { module: "x".into() },
6179                UpgradeInstruction::LoadModule { module: "x".into() },
6180            ],
6181        );
6182        let err = e.validate().unwrap_err();
6183        assert!(
6184            matches!(
6185                err,
6186                UpgradeError::PurgeWithoutPriorLoad {
6187                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6188                    ..
6189                }
6190            ),
6191            "purge-without-load must surface before duplicate-load, got {err:?}"
6192        );
6193    }
6194
6195    #[test]
6196    fn validate_load_singularity_fires_after_per_instr_shape() {
6197        // Order pin: a malformed `:module` value on a `:load-module`
6198        // (an empty string) surfaces its narrower kind-tagged
6199        // `ModuleEmpty` diagnostic *before* the within-entry load-
6200        // singularity gate fires. The per-instruction shape pass walks
6201        // the list inline before the singularity check, so the
6202        // narrower self-locating diagnostic surfaces first — mirrors
6203        // the empty-first cascade on every peer DNS-1123 gate and the
6204        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6205        // pin on the sibling singularity gate.
6206        //
6207        // Two empty-string `:load-module` would *otherwise* duplicate
6208        // (both modules are the same empty string), so this pin
6209        // double-locks the precedence: the per-instr shape gate must
6210        // win on the first malformed instruction before the duplicate
6211        // scan even reaches the second.
6212        let e = entry(
6213            "0.1.0",
6214            vec![
6215                UpgradeInstruction::LoadModule {
6216                    module: String::new(),
6217                },
6218                UpgradeInstruction::LoadModule {
6219                    module: String::new(),
6220                },
6221            ],
6222        );
6223        let err = e.validate().unwrap_err();
6224        assert_eq!(
6225            err,
6226            UpgradeError::ModuleEmpty {
6227                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6228            },
6229            "malformed instruction must surface its kind-tagged diagnostic before the \
6230             load-singularity gate fires, got {err:?}"
6231        );
6232    }
6233
6234    #[test]
6235    fn validate_load_singularity_fires_before_cleanup_singularity() {
6236        // Diagnostic-precedence pin: an entry that violates *both*
6237        // singularities — duplicate load on "x" *and* duplicate cleanup
6238        // on "y-old" — must surface the load-side diagnostic first.
6239        // The load axis precedes the cleanup axis in the canonical OTP
6240        // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6241        // in [`UpgradeInstruction`] declaration order (LoadModule
6242        // before SoftPurge/Purge), so the load-side singularity is the
6243        // load-bearing diagnostic when both fire — the cleanup-side
6244        // duplicate is meaningless either way without a coherent load.
6245        // Guards the call order in `validate`: `validate_load_singularity`
6246        // runs before `validate_cleanup_singularity`.
6247        let e = entry(
6248            "0.1.0",
6249            vec![
6250                UpgradeInstruction::LoadModule { module: "x".into() },
6251                UpgradeInstruction::LoadModule { module: "x".into() },
6252                UpgradeInstruction::SoftPurge {
6253                    module: "y-old".into(),
6254                },
6255                UpgradeInstruction::SoftPurge {
6256                    module: "y-old".into(),
6257                },
6258            ],
6259        );
6260        let err = e.validate().unwrap_err();
6261        assert_eq!(
6262            err,
6263            UpgradeError::DuplicateLoadModule {
6264                from: "0.1.0".into(),
6265                module: "x".into(),
6266            },
6267            "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6268        );
6269    }
6270
6271    #[test]
6272    fn validate_load_singularity_reports_first_collision() {
6273        // Determinism pin: with three loads of the same module the gate
6274        // reports the *first* collision (the second occurrence) and
6275        // stops — the third's duplicate is masked by the first surfaced
6276        // one. Mirrors
6277        // `validate_cleanup_singularity_reports_first_collision` on the
6278        // sibling singularity axis and every peer duplicate gate's
6279        // first-collision discipline.
6280        let e = entry(
6281            "0.1.0",
6282            vec![
6283                UpgradeInstruction::LoadModule { module: "x".into() },
6284                UpgradeInstruction::LoadModule { module: "x".into() },
6285                UpgradeInstruction::LoadModule { module: "x".into() },
6286            ],
6287        );
6288        let err = e.validate().unwrap_err();
6289        assert_eq!(
6290            err,
6291            UpgradeError::DuplicateLoadModule {
6292                from: "0.1.0".into(),
6293                module: "x".into(),
6294            },
6295            "the first colliding occurrence must surface, not the later third-load collision"
6296        );
6297    }
6298
6299    #[test]
6300    fn validate_load_singularity_threads_through_validate_upgrade_from() {
6301        // The whole-list entry-point surfaces the per-entry singularity
6302        // error (mirrors
6303        // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6304        // the gate is reachable from the LayoutInvariants call site,
6305        // not only from a direct `entry.validate()`.
6306        let entries = vec![entry(
6307            "0.1.0",
6308            vec![
6309                UpgradeInstruction::LoadModule { module: "x".into() },
6310                UpgradeInstruction::LoadModule { module: "x".into() },
6311            ],
6312        )];
6313        let err = validate_upgrade_from(&entries).unwrap_err();
6314        assert!(
6315            matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6316            "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6317        );
6318    }
6319
6320    // ── within-entry state-change-singularity invariant ────────────────
6321
6322    #[test]
6323    fn validate_rejects_duplicate_state_change_for_same_script() {
6324        // `StateChange` is the `gen_server:code_change/3` analog
6325        // (INSPIRATIONS §II.4): the script folds the prior-version
6326        // state shape into the current-version shape — a one-shot
6327        // transition, not a step that composes with itself. OTP's
6328        // release_handler invokes `code_change/3` exactly once per
6329        // upgrade per gen_server; systools-generated `.relup` files
6330        // emit at most one `code_change` per gen_server per upgrade
6331        // step for this reason. A second `(:state-change "m.lisp")`
6332        // re-runs the same fold on the already-migrated state — at
6333        // best a no-op and at worst silent state corruption from
6334        // double-applied non-idempotent transforms (`add column`,
6335        // `increment counter`, `rename field`). Author one
6336        // `(:state-change "m.lisp")` per migration script per entry.
6337        let e = entry(
6338            "0.1.0",
6339            vec![
6340                UpgradeInstruction::LoadModule { module: "x".into() },
6341                UpgradeInstruction::StateChange {
6342                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6343                },
6344                UpgradeInstruction::StateChange {
6345                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6346                },
6347            ],
6348        );
6349        let err = e.validate().unwrap_err();
6350        assert_eq!(
6351            err,
6352            UpgradeError::DuplicateStateChange {
6353                from: "0.1.0".into(),
6354                script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6355            },
6356            "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6357             the script, got {err:?}"
6358        );
6359    }
6360
6361    #[test]
6362    fn validate_accepts_distinct_state_change_scripts() {
6363        // Positive control: `:state-change` instructions on *different*
6364        // scripts pass the gate. Mirrors
6365        // `validate_accepts_distinct_cleanup_modules` /
6366        // `validate_accepts_distinct_load_modules` on the sibling
6367        // singularity axes — the state-change-singularity gate is keyed
6368        // on the script PathBuf, so distinct scripts render distinct
6369        // migration targets and don't collide. Sweep both the bare two-
6370        // migration shape and the canonical load-pair-with-cleanup shape
6371        // so a future tighten that over-fires on distinct scripts
6372        // surfaces here. This positive control is the gate-level peer of
6373        // `validate_accepts_multiple_state_changes_after_one_load` (the
6374        // ordering-gate positive control on distinct scripts), pinned
6375        // here independently so a future refactor that decouples the
6376        // gates can't accidentally drop coverage on either.
6377        let two_migrations = entry(
6378            "0.1.0",
6379            vec![
6380                UpgradeInstruction::LoadModule { module: "x".into() },
6381                UpgradeInstruction::StateChange {
6382                    script: PathBuf::from("lib/m1.lisp"),
6383                },
6384                UpgradeInstruction::StateChange {
6385                    script: PathBuf::from("lib/m2.lisp"),
6386                },
6387            ],
6388        );
6389        two_migrations.validate().unwrap();
6390        let with_cleanup = entry(
6391            "0.1.0",
6392            vec![
6393                UpgradeInstruction::LoadModule { module: "x".into() },
6394                UpgradeInstruction::StateChange {
6395                    script: PathBuf::from("lib/m1.lisp"),
6396                },
6397                UpgradeInstruction::StateChange {
6398                    script: PathBuf::from("lib/m2.lisp"),
6399                },
6400                UpgradeInstruction::SoftPurge {
6401                    module: "x-old".into(),
6402                },
6403            ],
6404        );
6405        with_cleanup.validate().unwrap();
6406    }
6407
6408    #[test]
6409    fn validate_accepts_single_state_change_per_script() {
6410        // Boundary control: a list with exactly one `:state-change`
6411        // wrapped by the canonical `:load-module` + `:soft-purge`
6412        // sequence (the module-doc OTP shape) is the gate's identity
6413        // element. Pin so a future off-by-one in the duplicate-
6414        // detection scan doesn't accidentally flag a single occurrence
6415        // as duplicating itself — mirrors
6416        // `validate_accepts_single_load_per_module` /
6417        // `validate_accepts_single_cleanup_per_module` on the sibling
6418        // singularity axes.
6419        let e = entry(
6420            "0.1.0",
6421            vec![
6422                UpgradeInstruction::LoadModule { module: "x".into() },
6423                UpgradeInstruction::StateChange {
6424                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6425                },
6426                UpgradeInstruction::SoftPurge {
6427                    module: "x-old".into(),
6428                },
6429            ],
6430        );
6431        e.validate().unwrap();
6432    }
6433
6434    #[test]
6435    fn validate_state_change_singularity_fires_after_state_change_ordering() {
6436        // Diagnostic-precedence pin: an entry like `((:state-change
6437        // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6438        // without-load and duplicate-state-change. The more-fundamental
6439        // ordering gate must win — the missing-load defect is load-
6440        // bearing (the migration runs against unloaded code), and
6441        // surfacing the duplicate diagnostic first would mask the
6442        // migrate-into-unloaded-code defect the ordering gate exists to
6443        // close. Guards the call order in `validate` against silent
6444        // reordering. Same posture as
6445        // `validate_load_singularity_fires_after_state_change_ordering`
6446        // on the sibling singularity gate.
6447        //
6448        // Two same-script `:state-change` would *otherwise* duplicate
6449        // (both scripts collide on the very first `:state-change`-
6450        // without-load encountered), so this pin double-locks the
6451        // precedence: the ordering gate must win on the first un-loaded
6452        // `:state-change` before the singularity scan even reaches the
6453        // second.
6454        let e = entry(
6455            "0.1.0",
6456            vec![
6457                UpgradeInstruction::StateChange {
6458                    script: PathBuf::from("lib/m.lisp"),
6459                },
6460                UpgradeInstruction::StateChange {
6461                    script: PathBuf::from("lib/m.lisp"),
6462                },
6463            ],
6464        );
6465        let err = e.validate().unwrap_err();
6466        assert!(
6467            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6468            "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6469        );
6470    }
6471
6472    #[test]
6473    fn validate_state_change_singularity_fires_after_purge_ordering() {
6474        // Diagnostic-precedence pin: an entry like `((:soft-purge
6475        // "x-old") (:load-module "x") (:state-change "m.lisp")
6476        // (:state-change "m.lisp"))` is *both* purge-without-load and
6477        // duplicate-state-change. The more-fundamental ordering gate
6478        // must win — the missing-load defect (a cleanup that drains the
6479        // only resident version to nothing) is load-bearing, and
6480        // surfacing the duplicate diagnostic first would mask the
6481        // drain-to-nothing defect the ordering gate exists to close.
6482        // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6483        // on the state-change-singularity axis.
6484        let e = entry(
6485            "0.1.0",
6486            vec![
6487                UpgradeInstruction::SoftPurge {
6488                    module: "x-old".into(),
6489                },
6490                UpgradeInstruction::LoadModule { module: "x".into() },
6491                UpgradeInstruction::StateChange {
6492                    script: PathBuf::from("lib/m.lisp"),
6493                },
6494                UpgradeInstruction::StateChange {
6495                    script: PathBuf::from("lib/m.lisp"),
6496                },
6497            ],
6498        );
6499        let err = e.validate().unwrap_err();
6500        assert!(
6501            matches!(
6502                err,
6503                UpgradeError::PurgeWithoutPriorLoad {
6504                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6505                    ..
6506                }
6507            ),
6508            "purge-without-load must surface before duplicate-state-change, got {err:?}"
6509        );
6510    }
6511
6512    #[test]
6513    fn validate_state_change_singularity_fires_after_per_instr_shape() {
6514        // Order pin: a malformed `:script` value on a `:state-change`
6515        // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6516        // *before* the within-entry state-change-singularity gate fires.
6517        // The per-instruction shape pass walks the list inline before
6518        // the singularity check, so the narrower self-locating
6519        // diagnostic surfaces first — mirrors the empty-first cascade on
6520        // every peer path-shape gate and the
6521        // `validate_load_singularity_fires_after_per_instr_shape` /
6522        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6523        // pins on the sibling singularity gates.
6524        //
6525        // Two empty-path `:state-change` would *otherwise* duplicate
6526        // (both scripts are the same empty PathBuf), so this pin double-
6527        // locks the precedence: the per-instr shape gate must win on the
6528        // first malformed instruction before the duplicate scan even
6529        // reaches the second.
6530        let e = entry(
6531            "0.1.0",
6532            vec![
6533                UpgradeInstruction::LoadModule { module: "x".into() },
6534                UpgradeInstruction::StateChange {
6535                    script: PathBuf::new(),
6536                },
6537                UpgradeInstruction::StateChange {
6538                    script: PathBuf::new(),
6539                },
6540            ],
6541        );
6542        let err = e.validate().unwrap_err();
6543        assert_eq!(
6544            err,
6545            UpgradeError::EmptyScript,
6546            "malformed instruction must surface its narrower diagnostic before the \
6547             state-change-singularity gate fires, got {err:?}"
6548        );
6549    }
6550
6551    #[test]
6552    fn validate_state_change_singularity_fires_after_load_singularity() {
6553        // Diagnostic-precedence pin: an entry that violates *both*
6554        // singularities — duplicate load on "x" *and* duplicate
6555        // state-change on "m.lisp" — must surface the load-side
6556        // diagnostic first. The load axis precedes the migration axis
6557        // in the canonical OTP sequence (`code:load_module/1` then
6558        // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6559        // declaration order (LoadModule before StateChange), so the
6560        // load-side singularity is the load-bearing diagnostic when
6561        // both fire — the migration-side duplicate is meaningless
6562        // either way without a coherent load. Guards the call order in
6563        // `validate`: `validate_load_singularity` runs before
6564        // `validate_state_change_singularity`.
6565        let e = entry(
6566            "0.1.0",
6567            vec![
6568                UpgradeInstruction::LoadModule { module: "x".into() },
6569                UpgradeInstruction::LoadModule { module: "x".into() },
6570                UpgradeInstruction::StateChange {
6571                    script: PathBuf::from("lib/m.lisp"),
6572                },
6573                UpgradeInstruction::StateChange {
6574                    script: PathBuf::from("lib/m.lisp"),
6575                },
6576            ],
6577        );
6578        let err = e.validate().unwrap_err();
6579        assert_eq!(
6580            err,
6581            UpgradeError::DuplicateLoadModule {
6582                from: "0.1.0".into(),
6583                module: "x".into(),
6584            },
6585            "duplicate-load must surface before duplicate-state-change, got {err:?}"
6586        );
6587    }
6588
6589    #[test]
6590    fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6591        // Diagnostic-precedence pin: an entry that violates *both*
6592        // singularities — duplicate state-change on "m.lisp" *and*
6593        // duplicate cleanup on "y-old" — must surface the migration-
6594        // side diagnostic first. The migration axis precedes the
6595        // cleanup axis in the canonical OTP sequence
6596        // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6597        // [`UpgradeInstruction`] declaration order (StateChange before
6598        // SoftPurge/Purge), so the migration-side singularity is the
6599        // load-bearing diagnostic when both fire — the cleanup-side
6600        // duplicate is irrelevant once the migration has corrupted
6601        // state by double-applying. Guards the call order in
6602        // `validate`: `validate_state_change_singularity` runs before
6603        // `validate_cleanup_singularity`.
6604        let e = entry(
6605            "0.1.0",
6606            vec![
6607                UpgradeInstruction::LoadModule { module: "x".into() },
6608                UpgradeInstruction::StateChange {
6609                    script: PathBuf::from("lib/m.lisp"),
6610                },
6611                UpgradeInstruction::StateChange {
6612                    script: PathBuf::from("lib/m.lisp"),
6613                },
6614                UpgradeInstruction::SoftPurge {
6615                    module: "y-old".into(),
6616                },
6617                UpgradeInstruction::SoftPurge {
6618                    module: "y-old".into(),
6619                },
6620            ],
6621        );
6622        let err = e.validate().unwrap_err();
6623        assert_eq!(
6624            err,
6625            UpgradeError::DuplicateStateChange {
6626                from: "0.1.0".into(),
6627                script: PathBuf::from("lib/m.lisp"),
6628            },
6629            "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6630        );
6631    }
6632
6633    #[test]
6634    fn validate_state_change_singularity_reports_first_collision() {
6635        // Determinism pin: with three state-changes on the same script
6636        // the gate reports the *first* collision (the second
6637        // occurrence) and stops — the third's duplicate is masked by
6638        // the first surfaced one. Mirrors
6639        // `validate_load_singularity_reports_first_collision` /
6640        // `validate_cleanup_singularity_reports_first_collision` on the
6641        // sibling singularity axes and every peer duplicate gate's
6642        // first-collision discipline.
6643        let e = entry(
6644            "0.1.0",
6645            vec![
6646                UpgradeInstruction::LoadModule { module: "x".into() },
6647                UpgradeInstruction::StateChange {
6648                    script: PathBuf::from("lib/m.lisp"),
6649                },
6650                UpgradeInstruction::StateChange {
6651                    script: PathBuf::from("lib/m.lisp"),
6652                },
6653                UpgradeInstruction::StateChange {
6654                    script: PathBuf::from("lib/m.lisp"),
6655                },
6656            ],
6657        );
6658        let err = e.validate().unwrap_err();
6659        assert_eq!(
6660            err,
6661            UpgradeError::DuplicateStateChange {
6662                from: "0.1.0".into(),
6663                script: PathBuf::from("lib/m.lisp"),
6664            },
6665            "the first colliding occurrence must surface, not the later third-migration collision"
6666        );
6667    }
6668
6669    #[test]
6670    fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6671        // The whole-list entry-point surfaces the per-entry singularity
6672        // error (mirrors
6673        // `validate_load_singularity_threads_through_validate_upgrade_from`
6674        // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6675        // the gate is reachable from the LayoutInvariants call site,
6676        // not only from a direct `entry.validate()`.
6677        let entries = vec![entry(
6678            "0.1.0",
6679            vec![
6680                UpgradeInstruction::LoadModule { module: "x".into() },
6681                UpgradeInstruction::StateChange {
6682                    script: PathBuf::from("lib/m.lisp"),
6683                },
6684                UpgradeInstruction::StateChange {
6685                    script: PathBuf::from("lib/m.lisp"),
6686                },
6687            ],
6688        )];
6689        let err = validate_upgrade_from(&entries).unwrap_err();
6690        assert!(
6691            matches!(err, UpgradeError::DuplicateStateChange { .. }),
6692            "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6693        );
6694    }
6695
6696    #[test]
6697    fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6698        // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6699        // per-instruction `StateChange`-arm script-path projection must
6700        // route through the sibling lifted
6701        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6702        // accessor, not the raw
6703        // `match instr { UpgradeInstruction::StateChange { script } =>
6704        // script.as_path(), _ => continue }` open-coded pattern-match
6705        // the gate previously carried.
6706        //
6707        // Structurally: the gate's projection accept-set is the union
6708        // of every [`UpgradeInstruction`] variant for which
6709        // `declared_path().is_some()` — today exactly
6710        // [`UpgradeInstruction::StateChange`] per the sibling
6711        // `declared_path_only_for_state_change` pin, so a
6712        // duplicate-scripts input trips `DuplicateStateChange` and a
6713        // non-`StateChange` input (module-bearing / terminal) leaves
6714        // `seen` empty and the gate returns `Ok(())` byte-identical to
6715        // the pattern-match shape.
6716        //
6717        // Byte-equal today (`declared_path` returns `Some(script)` iff
6718        // `StateChange`, byte-for-byte from the variant's own storage);
6719        // the pin catches any future accessor extension that promotes
6720        // an additional variant onto the `PathBuf`-carrying axis — the
6721        // gate then fires on duplicate scripts from that variant too,
6722        // and the singularity discipline the sibling
6723        // `validate_load_singularity` / `validate_cleanup_singularity`
6724        // gates share on the `String`-carrying axis's per-variant
6725        // consumers extends to the promoted variant by construction.
6726        //
6727        // Peer of the sibling four per-`UpgradeInstruction` consumers
6728        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6729        // sandbox-path fan-out, the layout-side per-`StateChange`
6730        // script-existence fan-out at
6731        // `caixa-core/src/layout.rs:1017`, the cross-slot
6732        // [`validate_upgrade_from_against_behavior`] gate's per-
6733        // `StateChange` detection loop, the peer
6734        // [`UpgradeInstruction::declared_module`] `String`-axis
6735        // per-variant unifier) — this gate now shares one typed
6736        // dispatch on the substrate primitive's `PathBuf`-carrying
6737        // axis with those consumers, so a future rebrand on the axis
6738        // migrates as a single caixa-core edit rather than a
6739        // coordinated rewrite of five call sites.
6740        //
6741        // Three-arm projective coverage:
6742        //   (a) `StateChange` scripts project through `declared_path()`
6743        //       byte-equal to the raw `script.as_path()` field access;
6744        //   (b) a duplicate-`StateChange` input trips the gate on the
6745        //       second occurrence with `DuplicateStateChange` carrying
6746        //       the offending script verbatim;
6747        //   (c) a non-`StateChange`-only input (`LoadModule` /
6748        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
6749        //       vacuous with `Ok(())` — the `declared_path().is_none()`
6750        //       arm's `continue` fall-through pins.
6751        //
6752        // Fail-before-pass-after verified locally: swapping the
6753        // production `let Some(script) = instr.declared_path() else {
6754        // continue };` back to `let script = match instr {
6755        // UpgradeInstruction::StateChange { script } =>
6756        // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6757        // passing but silently detaches the gate from the accessor's
6758        // typed dispatch — any future `declared_path` extension
6759        // (promotion of an additional variant onto the axis, an
6760        // operator-side pre-resolved-path cache the accessor
6761        // materializes) would then silently disagree between this
6762        // gate's raw pattern-match and the peer four sibling consumers
6763        // that route through the accessor.
6764        use std::path::PathBuf;
6765
6766        // (a) StateChange projection byte-equal via declared_path.
6767        let sc = UpgradeInstruction::StateChange {
6768            script: PathBuf::from("lib/m.lisp"),
6769        };
6770        assert_eq!(
6771            sc.declared_path().map(std::path::PathBuf::as_path),
6772            Some(PathBuf::from("lib/m.lisp").as_path()),
6773            "declared_path() must project the StateChange :script byte-equal to the raw \
6774             field access — accessor divergence would silently detach the gate from the \
6775             projection every peer per-`UpgradeInstruction` consumer routes through"
6776        );
6777
6778        // (b) Duplicate-StateChange input trips the gate.
6779        let dup = entry(
6780            "0.1.0",
6781            vec![
6782                UpgradeInstruction::LoadModule { module: "x".into() },
6783                UpgradeInstruction::StateChange {
6784                    script: PathBuf::from("lib/m.lisp"),
6785                },
6786                UpgradeInstruction::StateChange {
6787                    script: PathBuf::from("lib/m.lisp"),
6788                },
6789            ],
6790        );
6791        assert_eq!(
6792            dup.validate_state_change_singularity(),
6793            Err(UpgradeError::DuplicateStateChange {
6794                from: "0.1.0".into(),
6795                script: PathBuf::from("lib/m.lisp"),
6796            }),
6797            "duplicate StateChange scripts must trip the gate on the second occurrence \
6798             through the declared_path accessor's Some(script) arm"
6799        );
6800
6801        // (c) Non-StateChange-only inputs leave the gate vacuous.
6802        for instrs in [
6803            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6804            vec![
6805                UpgradeInstruction::LoadModule { module: "x".into() },
6806                UpgradeInstruction::SoftPurge {
6807                    module: "x-old".into(),
6808                },
6809            ],
6810            vec![
6811                UpgradeInstruction::LoadModule { module: "x".into() },
6812                UpgradeInstruction::Purge {
6813                    module: "x-old".into(),
6814                },
6815            ],
6816            vec![UpgradeInstruction::Restart],
6817        ] {
6818            for instr in &instrs {
6819                assert!(
6820                    instr.declared_path().is_none(),
6821                    "non-StateChange variants must project None through declared_path — \
6822                     accessor divergence would let this gate silently fire on a duplicate \
6823                     module reference far from any :state-change site"
6824                );
6825            }
6826            let e = entry("0.1.0", instrs);
6827            assert_eq!(
6828                e.validate_state_change_singularity(),
6829                Ok(()),
6830                "the state-change-singularity gate must return Ok(()) on an entry whose \
6831                 instructions all project None through declared_path — the accessor's \
6832                 continue arm the pattern-match's `_ => continue` previously carried"
6833            );
6834        }
6835    }
6836
6837    // ── within-entry state-change-before-cleanup ordering invariant ──
6838
6839    #[test]
6840    fn validate_rejects_state_change_after_soft_purge() {
6841        // Fail-before-pass-after pin: `:state-change` is the
6842        // gen_server:code_change/3 analog and folds the prior-version
6843        // state shape into the current shape; `:soft-purge` drains the
6844        // prior code. The operator runs instructions in declared order,
6845        // so a `:soft-purge` ahead of a `:state-change` drains the
6846        // prior module before the migration callback runs against the
6847        // state it held — the canonical OTP error mode
6848        // "`code_change/3` invoked on a purged module" the
6849        // release_handler closes by always ordering the migration
6850        // before the cleanup.
6851        let e = entry(
6852            "0.1.0",
6853            vec![
6854                UpgradeInstruction::LoadModule { module: "x".into() },
6855                UpgradeInstruction::SoftPurge {
6856                    module: "x-old".into(),
6857                },
6858                UpgradeInstruction::StateChange {
6859                    script: PathBuf::from("lib/m.lisp"),
6860                },
6861            ],
6862        );
6863        let err = e.validate().unwrap_err();
6864        assert_eq!(
6865            err,
6866            UpgradeError::StateChangeAfterCleanup {
6867                from: "0.1.0".into(),
6868                script: PathBuf::from("lib/m.lisp"),
6869                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6870                prior_cleanup_module: "x-old".into(),
6871            },
6872            "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6873             naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6874        );
6875    }
6876
6877    #[test]
6878    fn validate_rejects_state_change_after_purge() {
6879        // Per-arm coverage: `:purge` (immediate discard, no drain) is
6880        // the more catastrophic peer of `:soft-purge` on the cleanup
6881        // axis; same gate, same shape, the `prior_cleanup_kind` field
6882        // distinguishes the diagnostic so the author can grep their
6883        // caixa.lisp for the offending `(:purge …)` form.
6884        let e = entry(
6885            "0.1.0",
6886            vec![
6887                UpgradeInstruction::LoadModule { module: "x".into() },
6888                UpgradeInstruction::Purge {
6889                    module: "x-old".into(),
6890                },
6891                UpgradeInstruction::StateChange {
6892                    script: PathBuf::from("lib/m.lisp"),
6893                },
6894            ],
6895        );
6896        let err = e.validate().unwrap_err();
6897        assert_eq!(
6898            err,
6899            UpgradeError::StateChangeAfterCleanup {
6900                from: "0.1.0".into(),
6901                script: PathBuf::from("lib/m.lisp"),
6902                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6903                prior_cleanup_module: "x-old".into(),
6904            },
6905            "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6906             `prior_cleanup_kind: \":purge\"`, got {err:?}"
6907        );
6908    }
6909
6910    #[test]
6911    fn validate_accepts_state_change_before_cleanup() {
6912        // Positive control: the canonical `(:load-module …)
6913        // (:state-change …) (:soft-purge …)` order validates — the
6914        // exact shape the module doc example and `validate_accepts_
6915        // well_formed` already pin, restated here on the new gate's
6916        // identity element so a future shortcut that runs the
6917        // singularity gates first doesn't silently mask a regression
6918        // here.
6919        let e = entry(
6920            "0.1.0",
6921            vec![
6922                UpgradeInstruction::LoadModule { module: "x".into() },
6923                UpgradeInstruction::StateChange {
6924                    script: PathBuf::from("lib/m.lisp"),
6925                },
6926                UpgradeInstruction::SoftPurge {
6927                    module: "x-old".into(),
6928                },
6929            ],
6930        );
6931        e.validate().unwrap();
6932    }
6933
6934    #[test]
6935    fn validate_accepts_cleanup_without_state_change() {
6936        // Empty-set identity: an entry that carries no `:state-change`
6937        // at all has nothing to order against the cleanup, so the gate
6938        // passes regardless of how the cleanups are placed (after the
6939        // single required `:load-module`). Mirrors the
6940        // `validate_accepts_multiple_purges_after_one_load` positive
6941        // control on the peer purge-ordering gate; metadata-only
6942        // upgrades with cleanup-but-no-migration land here.
6943        let e = entry(
6944            "0.1.0",
6945            vec![
6946                UpgradeInstruction::LoadModule { module: "x".into() },
6947                UpgradeInstruction::SoftPurge {
6948                    module: "x-old".into(),
6949                },
6950                UpgradeInstruction::Purge {
6951                    module: "x-oldest".into(),
6952                },
6953            ],
6954        );
6955        e.validate().unwrap();
6956    }
6957
6958    #[test]
6959    fn validate_accepts_state_change_without_cleanup() {
6960        // Empty-set identity on the dual axis: an entry that carries no
6961        // cleanup at all has nothing to order against the state-change,
6962        // so the gate passes — additive-upgrade shapes (load new code,
6963        // migrate state, leave old code resident for in-flight callers
6964        // to drain naturally) land here.
6965        let e = entry(
6966            "0.1.0",
6967            vec![
6968                UpgradeInstruction::LoadModule { module: "x".into() },
6969                UpgradeInstruction::StateChange {
6970                    script: PathBuf::from("lib/m.lisp"),
6971                },
6972            ],
6973        );
6974        e.validate().unwrap();
6975    }
6976
6977    #[test]
6978    fn validate_accepts_multiple_state_changes_before_cleanup() {
6979        // Coverage: every state-change must precede every cleanup, not
6980        // just the first. A chain `(load) (sc) (sc) (sp)` is the
6981        // canonical "two distinct migration scripts on a chained
6982        // upgrade" shape (one module's schema *and* another's
6983        // projection per the DuplicateStateChange diagnostic), and
6984        // it must pass when each state-change has distinct script
6985        // paths. Pinned here so a future shortcut that only checks
6986        // the first state-change doesn't silently accept a
6987        // `(load) (sc-1) (sp) (sc-2)` regression.
6988        let e = entry(
6989            "0.1.0",
6990            vec![
6991                UpgradeInstruction::LoadModule { module: "x".into() },
6992                UpgradeInstruction::StateChange {
6993                    script: PathBuf::from("lib/m1.lisp"),
6994                },
6995                UpgradeInstruction::StateChange {
6996                    script: PathBuf::from("lib/m2.lisp"),
6997                },
6998                UpgradeInstruction::SoftPurge {
6999                    module: "x-old".into(),
7000                },
7001            ],
7002        );
7003        e.validate().unwrap();
7004    }
7005
7006    #[test]
7007    fn validate_rejects_state_change_sandwiched_between_cleanups() {
7008        // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
7009        // (sp-2)` violates the gate because the state-change runs
7010        // after the first cleanup. The reported `prior_cleanup_*`
7011        // names the *first* cleanup (the load-bearing one), not the
7012        // last — mirrors every peer first-collision diagnostic
7013        // posture on this module (`validate_state_change_ordering`,
7014        // `validate_purge_ordering`, `validate_load_singularity`,
7015        // `validate_state_change_singularity`,
7016        // `validate_cleanup_singularity` all report the first
7017        // colliding instruction, not the last).
7018        let e = entry(
7019            "0.1.0",
7020            vec![
7021                UpgradeInstruction::LoadModule { module: "x".into() },
7022                UpgradeInstruction::SoftPurge {
7023                    module: "x-old".into(),
7024                },
7025                UpgradeInstruction::StateChange {
7026                    script: PathBuf::from("lib/m.lisp"),
7027                },
7028                UpgradeInstruction::Purge {
7029                    module: "y-old".into(),
7030                },
7031            ],
7032        );
7033        let err = e.validate().unwrap_err();
7034        assert_eq!(
7035            err,
7036            UpgradeError::StateChangeAfterCleanup {
7037                from: "0.1.0".into(),
7038                script: PathBuf::from("lib/m.lisp"),
7039                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7040                prior_cleanup_module: "x-old".into(),
7041            },
7042            "the first cleanup the state-change follows must surface (not the trailing one), \
7043             got {err:?}"
7044        );
7045    }
7046
7047    #[test]
7048    fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
7049        // Diagnostic-precedence pin: an entry like `((:soft-purge
7050        // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
7051        // *both* purge-without-load (the cleanup runs before the
7052        // load) and state-change-after-cleanup (the state-change
7053        // runs after the cleanup). The more-fundamental ordering
7054        // gate must win — the missing-load defect (a cleanup that
7055        // drains the only resident version to nothing) is load-
7056        // bearing, and surfacing the state-change-after-cleanup
7057        // diagnostic first would mask the drain-to-nothing defect
7058        // the peer purge-ordering gate exists to close. Guards the
7059        // call order in `validate` against silent reordering. Same
7060        // posture as `validate_purge_ordering_fires_after_state_
7061        // change_ordering` on the sibling ordering gate.
7062        //
7063        // Pin specifically uses the load-after-cleanup shape (rather
7064        // than load-less) so the state-change-ordering gate (which
7065        // would otherwise fire first on a `((:soft-purge …)
7066        // (:state-change …))` shape with no leading load) is
7067        // sidestepped: with the load present after the cleanup,
7068        // state-change-ordering passes (its `loaded` latch is set
7069        // before the state-change is encountered) but purge-ordering
7070        // still fails (the cleanup precedes the load). That isolates
7071        // the precedence between purge-ordering and this gate
7072        // cleanly.
7073        let e = entry(
7074            "0.1.0",
7075            vec![
7076                UpgradeInstruction::SoftPurge {
7077                    module: "x-old".into(),
7078                },
7079                UpgradeInstruction::LoadModule { module: "x".into() },
7080                UpgradeInstruction::StateChange {
7081                    script: PathBuf::from("lib/m.lisp"),
7082                },
7083            ],
7084        );
7085        let err = e.validate().unwrap_err();
7086        assert!(
7087            matches!(
7088                err,
7089                UpgradeError::PurgeWithoutPriorLoad {
7090                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7091                    ..
7092                }
7093            ),
7094            "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
7095        );
7096    }
7097
7098    #[test]
7099    fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
7100        // Diagnostic-precedence pin: an entry like `((:state-change
7101        // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
7102        // load (because no `:load-module` precedes the state-change)
7103        // but *not* state-change-after-cleanup (the state-change
7104        // precedes the cleanup textually). The state-change-ordering
7105        // gate must surface first regardless — the missing-load
7106        // defect on the migration axis is the load-bearing semantic
7107        // and surfacing a different ordering diagnostic would mask
7108        // the migration-against-stale-code defect. Guards the call
7109        // order in `validate` against silent reordering on a shape
7110        // that fires only the state-change-ordering gate (not this
7111        // one), pinning that the state-change-ordering gate wins
7112        // ahead of this gate's chance to look at the list.
7113        let e = entry(
7114            "0.1.0",
7115            vec![
7116                UpgradeInstruction::StateChange {
7117                    script: PathBuf::from("lib/m.lisp"),
7118                },
7119                UpgradeInstruction::SoftPurge {
7120                    module: "x-old".into(),
7121                },
7122            ],
7123        );
7124        let err = e.validate().unwrap_err();
7125        assert!(
7126            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
7127            "state-change-without-load must surface before purge-without-load (the canonical \
7128             validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
7129        );
7130    }
7131
7132    #[test]
7133    fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
7134        // Order pin: a malformed `:script` value on a `:state-change`
7135        // (an empty path) surfaces its narrower `EmptyScript`
7136        // diagnostic *before* the within-entry state-change-before-
7137        // cleanup gate fires. The per-instruction shape pass walks
7138        // the list inline before the ordering check, so the narrower
7139        // self-locating diagnostic surfaces first — mirrors the
7140        // empty-first cascade on every peer path-shape gate and the
7141        // `validate_purge_ordering_fires_after_per_instr_shape` pin
7142        // on the sibling ordering gate.
7143        let e = entry(
7144            "0.1.0",
7145            vec![
7146                UpgradeInstruction::LoadModule { module: "x".into() },
7147                UpgradeInstruction::SoftPurge {
7148                    module: "x-old".into(),
7149                },
7150                UpgradeInstruction::StateChange {
7151                    script: PathBuf::new(),
7152                },
7153            ],
7154        );
7155        let err = e.validate().unwrap_err();
7156        assert_eq!(
7157            err,
7158            UpgradeError::EmptyScript,
7159            "malformed instruction must surface its narrower diagnostic before the \
7160             state-change-before-cleanup gate fires, got {err:?}"
7161        );
7162    }
7163
7164    #[test]
7165    fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
7166        // Diagnostic-precedence pin: an entry like `((:load-module
7167        // "x") (:soft-purge "x-old") (:state-change "m.lisp")
7168        // (:state-change "m.lisp"))` violates *both* this ordering
7169        // gate (the first state-change follows the cleanup) and the
7170        // state-change-singularity gate (the same script appears
7171        // twice). The ordering gate must win — the canonical
7172        // "ordering before singularity" precedence the peer
7173        // `validate_state_change_ordering` / `validate_purge_
7174        // ordering` gates already establish over their own singularity
7175        // gates, applied uniformly across the OTP canonical-sequence
7176        // ordering axis here. Guards the call order in `validate`:
7177        // `validate_state_change_before_cleanup` runs before the
7178        // per-instruction-class singularity gates.
7179        let e = entry(
7180            "0.1.0",
7181            vec![
7182                UpgradeInstruction::LoadModule { module: "x".into() },
7183                UpgradeInstruction::SoftPurge {
7184                    module: "x-old".into(),
7185                },
7186                UpgradeInstruction::StateChange {
7187                    script: PathBuf::from("lib/m.lisp"),
7188                },
7189                UpgradeInstruction::StateChange {
7190                    script: PathBuf::from("lib/m.lisp"),
7191                },
7192            ],
7193        );
7194        let err = e.validate().unwrap_err();
7195        assert!(
7196            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7197            "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7198        );
7199    }
7200
7201    #[test]
7202    fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7203        // The whole-list entry-point surfaces the per-entry ordering
7204        // error (mirrors `validate_purge_ordering_threads_through_
7205        // validate_upgrade_from` and every peer wiring pin): the gate
7206        // is reachable from the LayoutInvariants call site, not only
7207        // from a direct `entry.validate()`.
7208        let entries = vec![entry(
7209            "0.1.0",
7210            vec![
7211                UpgradeInstruction::LoadModule { module: "x".into() },
7212                UpgradeInstruction::SoftPurge {
7213                    module: "x-old".into(),
7214                },
7215                UpgradeInstruction::StateChange {
7216                    script: PathBuf::from("lib/m.lisp"),
7217                },
7218            ],
7219        )];
7220        let err = validate_upgrade_from(&entries).unwrap_err();
7221        assert!(
7222            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7223            "validate_upgrade_from must thread the state-change-before-cleanup error, \
7224             got {err:?}"
7225        );
7226    }
7227
7228    #[test]
7229    fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7230        // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7231        // per-instruction `StateChange`-arm script-path projection must
7232        // route through the sibling lifted
7233        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7234        // accessor, not the raw
7235        // `if let UpgradeInstruction::StateChange { script } = instr`
7236        // open-coded pattern-match the gate previously carried inside
7237        // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7238        //
7239        // Structurally: the gate's projection accept-set is the union
7240        // of every [`UpgradeInstruction`] variant for which
7241        // `declared_path().is_some()` — today exactly
7242        // [`UpgradeInstruction::StateChange`] per the sibling
7243        // `declared_path_only_for_state_change` pin, so a
7244        // state-change-after-cleanup input trips
7245        // `StateChangeAfterCleanup` and a non-`StateChange` input
7246        // (module-bearing / terminal) leaves the sticky-once latch
7247        // sweep quiet byte-identical to the pattern-match shape.
7248        //
7249        // Byte-equal today (`declared_path` returns `Some(script)` iff
7250        // `StateChange`, byte-for-byte from the variant's own storage);
7251        // the pin catches any future accessor extension that promotes
7252        // an additional variant onto the `PathBuf`-carrying axis — the
7253        // gate then fires on migrate-after-cleanup for that variant too,
7254        // and the migrate→cleanup ordering discipline the peer
7255        // [`validate_state_change_singularity`] /
7256        // [`validate_upgrade_from_against_behavior`] gates share on the
7257        // same axis extends to the promoted variant by construction.
7258        //
7259        // Peer of the sibling four per-`UpgradeInstruction` consumers
7260        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7261        // sandbox-path fan-out, the layout-side per-`StateChange`
7262        // script-existence fan-out at
7263        // `caixa-core/src/layout.rs:1058`, the within-entry
7264        // [`UpgradeFromEntry::validate_state_change_singularity`]
7265        // per-`StateChange` script-projection fan-out, the cross-slot
7266        // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7267        // detection loop) — the fifth (and last unlifted inside
7268        // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7269        // the `PathBuf`-carrying axis to now route through the accessor.
7270        // Same shape as the sibling
7271        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7272        // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7273        // pins extended onto the within-entry migrate→cleanup ordering
7274        // gate.
7275        //
7276        // Three-arm projective coverage:
7277        //   (a) `StateChange` scripts project through `declared_path()`
7278        //       byte-equal to the raw `script.clone()` field access
7279        //       the diagnostic previously carried;
7280        //   (b) a `:state-change`-after-cleanup input trips the gate
7281        //       with `StateChangeAfterCleanup` carrying the offending
7282        //       script + the prior cleanup's kind/module verbatim;
7283        //   (c) a non-`StateChange`-only input (`LoadModule` /
7284        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7285        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7286        //       arm's fall-through pins.
7287        //
7288        // Fail-before-pass-after verified structurally: swapping the
7289        // production
7290        //   `else if let Some(script) = instr.declared_path() && … { … }`
7291        // back to
7292        //   `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7293        // keeps arms (a)-(c) passing but silently detaches this within-
7294        // entry ordering gate from the accessor's typed dispatch — any
7295        // future `declared_path` extension (promotion of an additional
7296        // variant onto the axis, an operator-side pre-resolved-path
7297        // cache the accessor materializes) would then silently disagree
7298        // between this gate's raw pattern-match and the peer four
7299        // sibling consumers that route through the accessor.
7300
7301        // (a) StateChange projection byte-equal via declared_path.
7302        let sc = UpgradeInstruction::StateChange {
7303            script: PathBuf::from("lib/m.lisp"),
7304        };
7305        assert_eq!(
7306            sc.declared_path().cloned(),
7307            Some(PathBuf::from("lib/m.lisp")),
7308            "declared_path() must project the StateChange :script byte-equal to the raw \
7309             field access — accessor divergence would silently detach this within-entry \
7310             migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7311             consumer routes through"
7312        );
7313
7314        // (b) StateChange-after-cleanup trips the gate through the accessor.
7315        let after = entry(
7316            "0.1.0",
7317            vec![
7318                UpgradeInstruction::LoadModule { module: "x".into() },
7319                UpgradeInstruction::SoftPurge {
7320                    module: "x-old".into(),
7321                },
7322                UpgradeInstruction::StateChange {
7323                    script: PathBuf::from("lib/m.lisp"),
7324                },
7325            ],
7326        );
7327        assert_eq!(
7328            after.validate(),
7329            Err(UpgradeError::StateChangeAfterCleanup {
7330                from: "0.1.0".into(),
7331                script: PathBuf::from("lib/m.lisp"),
7332                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7333                prior_cleanup_module: "x-old".into(),
7334            }),
7335            "a :state-change following a cleanup must trip the gate through the declared_path \
7336             accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7337             kind/module verbatim byte-identical to the pattern-match shape"
7338        );
7339
7340        // (c) Non-StateChange-only inputs leave the gate vacuous.
7341        for instrs in [
7342            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7343            vec![
7344                UpgradeInstruction::LoadModule { module: "x".into() },
7345                UpgradeInstruction::SoftPurge {
7346                    module: "x-old".into(),
7347                },
7348            ],
7349            vec![
7350                UpgradeInstruction::LoadModule { module: "x".into() },
7351                UpgradeInstruction::Purge {
7352                    module: "x-old".into(),
7353                },
7354            ],
7355            vec![UpgradeInstruction::Restart],
7356        ] {
7357            for instr in &instrs {
7358                assert!(
7359                    instr.declared_path().is_none(),
7360                    "non-StateChange variants must project None through declared_path — \
7361                     accessor divergence would let this within-entry ordering gate silently \
7362                     fire on a cleanup-only sequence far from any :state-change site"
7363                );
7364            }
7365            let e = entry("0.1.0", instrs);
7366            assert_eq!(
7367                e.validate(),
7368                Ok(()),
7369                "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7370                 instructions all project None through declared_path — the accessor's \
7371                 None arm the pattern-match's implicit fall-through previously carried"
7372            );
7373        }
7374    }
7375
7376    #[test]
7377    fn validate_restart_order_independent() {
7378        // Position-agnostic: `(:restart)` leading or trailing the
7379        // mixed sequence surfaces the same RestartNotExclusive shape.
7380        // Mirrors OTP appup's order-insensitive
7381        // `restart_emulator | restart_new_emulator` terminal rule —
7382        // the position of the restart instruction in the script is
7383        // irrelevant; what matters is the script *contains* it
7384        // alongside other instructions at all. The gate must not
7385        // gain a false positive by depending on instruction ordering.
7386        let leading = entry(
7387            "0.1.0",
7388            vec![
7389                UpgradeInstruction::Restart,
7390                UpgradeInstruction::LoadModule { module: "x".into() },
7391            ],
7392        );
7393        let trailing = entry(
7394            "0.1.0",
7395            vec![
7396                UpgradeInstruction::LoadModule { module: "x".into() },
7397                UpgradeInstruction::Restart,
7398            ],
7399        );
7400        let middle = entry(
7401            "0.1.0",
7402            vec![
7403                UpgradeInstruction::LoadModule { module: "a".into() },
7404                UpgradeInstruction::Restart,
7405                UpgradeInstruction::SoftPurge {
7406                    module: "a-old".into(),
7407                },
7408            ],
7409        );
7410        for e in [&leading, &trailing, &middle] {
7411            assert!(
7412                matches!(
7413                    e.validate().unwrap_err(),
7414                    UpgradeError::RestartNotExclusive {
7415                        restart_count: 1,
7416                        ..
7417                    }
7418                ),
7419                "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7420                 instruction order, got {:?}",
7421                e.validate()
7422            );
7423        }
7424    }
7425
7426    #[test]
7427    fn validate_restart_exclusive_fires_after_per_instr_shape() {
7428        // Order pin: a malformed `:module` value on a Module-bearing
7429        // instruction (an empty string) surfaces its narrower
7430        // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7431        // entry restart-exclusivity gate fires. The per-instruction
7432        // shape pass walks the list inline before the restart-
7433        // exclusive check, so the narrower self-locating diagnostic
7434        // surfaces first — mirrors the empty-first cascade on every
7435        // peer DNS-1123 gate (`validate_module`,
7436        // `validate_membro_caixa`, `validate_placement_cluster`) and
7437        // the `*_invalid_fires_before_duplicate_check` arm-ordering
7438        // pins on every typed-graph axis. Without this pin a future
7439        // shortcut that runs the restart-exclusive check ahead of
7440        // per-instruction shape would surface a less-actionable
7441        // RestartNotExclusive over an instruction list that's also
7442        // malformed at the per-instruction layer.
7443        let e = entry(
7444            "0.1.0",
7445            vec![
7446                UpgradeInstruction::LoadModule {
7447                    module: String::new(),
7448                },
7449                UpgradeInstruction::Restart,
7450            ],
7451        );
7452        let err = e.validate().unwrap_err();
7453        assert_eq!(
7454            err,
7455            UpgradeError::ModuleEmpty {
7456                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7457            },
7458            "malformed instruction must surface its kind-tagged diagnostic before the \
7459             restart-exclusivity gate fires, got {err:?}"
7460        );
7461    }
7462
7463    fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7464        // Helper for the cross-slot composition gate's pass arm: a
7465        // BehaviorSpec carrying just the `:on-state-change` callback,
7466        // the runtime hook the per-version `(:state-change "…")`
7467        // instruction is delivered through during hot upgrade. Mirrors
7468        // the canonical authoring shape pinned in the module doc.
7469        crate::BehaviorSpec {
7470            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7471            ..Default::default()
7472        }
7473    }
7474
7475    #[test]
7476    fn behavior_gate_rejects_state_change_without_any_behavior() {
7477        // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7478        // caixa carries no `:behavior` at all surfaces the missing-
7479        // callback diagnostic naming the offending entry's `:from` +
7480        // script. The "I added the upgrade path but never declared
7481        // `:behavior`" footgun: `:behavior` is optional at the typed
7482        // root, the typed `:upgrade-from` slot validates on its own
7483        // merits, and the operator's hot-upgrade dispatch reaches for
7484        // a callback that doesn't exist.
7485        let entries = vec![entry(
7486            "0.1.0",
7487            vec![
7488                UpgradeInstruction::LoadModule { module: "x".into() },
7489                UpgradeInstruction::StateChange {
7490                    script: PathBuf::from("lib/m.lisp"),
7491                },
7492            ],
7493        )];
7494        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7495        assert_eq!(
7496            err,
7497            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7498                from: "0.1.0".into(),
7499                script: PathBuf::from("lib/m.lisp"),
7500            },
7501        );
7502    }
7503
7504    #[test]
7505    fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7506        // `:behavior` declared with *other* callbacks set
7507        // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7508        // None still surfaces the missing-callback diagnostic — only
7509        // the `:on-state-change` axis matters for this gate. The
7510        // "I declared `:behavior` but missed the migration callback"
7511        // footgun: a caixa that registers its lifecycle hooks but
7512        // forgets the migration delivery path leaves the
7513        // `:state-change` instruction with no runtime hook to
7514        // dispatch through.
7515        let entries = vec![entry(
7516            "0.1.0",
7517            vec![
7518                UpgradeInstruction::LoadModule { module: "x".into() },
7519                UpgradeInstruction::StateChange {
7520                    script: PathBuf::from("lib/m.lisp"),
7521                },
7522            ],
7523        )];
7524        let b = crate::BehaviorSpec {
7525            on_init: Some(PathBuf::from("lib/init.lisp")),
7526            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7527            ..Default::default()
7528        };
7529        let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7530        assert_eq!(
7531            err,
7532            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7533                from: "0.1.0".into(),
7534                script: PathBuf::from("lib/m.lisp"),
7535            },
7536            "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7537             the missing migration hook"
7538        );
7539    }
7540
7541    #[test]
7542    fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7543        // The canonical composition shape: a per-version
7544        // `(:state-change "lib/m.lisp")` instruction paired with the
7545        // `:behavior :on-state-change "lib/migrations.lisp"` callback
7546        // it is delivered through at hot-upgrade time. Pins the gate's
7547        // pass arm — drift here = a future tighten that rejects the
7548        // canonical OTP-shape composition surfaces as a regression at
7549        // this positive-control pin.
7550        let entries = vec![entry(
7551            "0.1.0",
7552            vec![
7553                UpgradeInstruction::LoadModule { module: "x".into() },
7554                UpgradeInstruction::StateChange {
7555                    script: PathBuf::from("lib/m.lisp"),
7556                },
7557            ],
7558        )];
7559        let b = behavior_with_state_change_callback();
7560        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7561    }
7562
7563    #[test]
7564    fn behavior_gate_accepts_entries_without_any_state_change() {
7565        // Empty-set identity: entries carrying no `:state-change`
7566        // instruction at all (load + cleanup only — the metadata-only
7567        // upgrade shape the module doc names, "On any failure, the
7568        // current version stays load-bearing — a typed atomic
7569        // upgrade") leave the gate vacuous. The composition only
7570        // requires a callback when the per-version script exists; a
7571        // load + cleanup pair has no migration to deliver, so the
7572        // absence of `:on-state-change` is coherent.
7573        let entries = vec![entry(
7574            "0.1.0",
7575            vec![
7576                UpgradeInstruction::LoadModule { module: "x".into() },
7577                UpgradeInstruction::SoftPurge {
7578                    module: "x-old".into(),
7579                },
7580            ],
7581        )];
7582        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7583    }
7584
7585    #[test]
7586    fn behavior_gate_accepts_restart_only_entry() {
7587        // The terminal-fallback `((:restart))` shape carries no
7588        // `:state-change` — the operator restarts the pod and the
7589        // new version comes up fresh against its initial state, no
7590        // migration. Pinned alongside the metadata-only positive
7591        // control above as the second empty-state-change shape.
7592        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7593        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7594    }
7595
7596    #[test]
7597    fn behavior_gate_accepts_empty_entries_list() {
7598        // Empty `:upgrade-from` (a caixa with no declared upgrade
7599        // paths — the v0.1.0 caixa before any upgrade entries are
7600        // added) trivially passes the gate. Pinned so the gate
7601        // doesn't accidentally fire on a caixa that hasn't yet
7602        // declared any upgrades.
7603        let entries: Vec<UpgradeFromEntry> = vec![];
7604        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7605    }
7606
7607    #[test]
7608    fn behavior_gate_reports_first_state_change_in_first_entry() {
7609        // First-collision determinism: with multiple `:state-change`
7610        // instructions across multiple entries, the gate reports the
7611        // *first* one encountered in declaration order — the entry's
7612        // declaration order first, then the within-entry instruction
7613        // order. Mirrors every peer first-collision diagnostic posture
7614        // on this module (`validate_state_change_ordering`,
7615        // `validate_purge_ordering`, the singularity gates), so a
7616        // future shortcut that walks the list in reverse or returns
7617        // the last collision surfaces as a regression here.
7618        let entries = vec![
7619            entry(
7620                "0.1.0",
7621                vec![
7622                    UpgradeInstruction::LoadModule { module: "x".into() },
7623                    UpgradeInstruction::StateChange {
7624                        script: PathBuf::from("lib/m1.lisp"),
7625                    },
7626                    UpgradeInstruction::StateChange {
7627                        script: PathBuf::from("lib/m2.lisp"),
7628                    },
7629                ],
7630            ),
7631            entry(
7632                "0.1.5",
7633                vec![
7634                    UpgradeInstruction::LoadModule { module: "x".into() },
7635                    UpgradeInstruction::StateChange {
7636                        script: PathBuf::from("lib/m3.lisp"),
7637                    },
7638                ],
7639            ),
7640        ];
7641        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7642        assert_eq!(
7643            err,
7644            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7645                from: "0.1.0".into(),
7646                script: PathBuf::from("lib/m1.lisp"),
7647            },
7648            "the first :state-change in the first entry must surface, not later collisions"
7649        );
7650    }
7651
7652    #[test]
7653    fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7654        // Cross-entry pin: a first entry with no `:state-change` (just
7655        // a load + cleanup) leaves the gate's per-entry walk continuing
7656        // to the second entry, where the offending instruction lives.
7657        // The diagnostic names the *second* entry's `:from` because
7658        // that's where the missing-callback shape is exposed — pinned
7659        // so a shortcut that bails on the first entry without a
7660        // `:state-change` (rather than continuing) doesn't mask the
7661        // defect in a later entry.
7662        let entries = vec![
7663            entry(
7664                "0.1.0",
7665                vec![
7666                    UpgradeInstruction::LoadModule { module: "x".into() },
7667                    UpgradeInstruction::SoftPurge {
7668                        module: "x-old".into(),
7669                    },
7670                ],
7671            ),
7672            entry(
7673                "0.1.5",
7674                vec![
7675                    UpgradeInstruction::LoadModule { module: "x".into() },
7676                    UpgradeInstruction::StateChange {
7677                        script: PathBuf::from("lib/m.lisp"),
7678                    },
7679                ],
7680            ),
7681        ];
7682        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7683        assert_eq!(
7684            err,
7685            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7686                from: "0.1.5".into(),
7687                script: PathBuf::from("lib/m.lisp"),
7688            },
7689            "the offending entry's `:from` must surface even when an earlier entry carries no \
7690             :state-change"
7691        );
7692    }
7693
7694    #[test]
7695    fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7696        // Positive control: a multi-entry `:upgrade-from` (chained
7697        // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7698        // a `:state-change` passes when the callback is declared once
7699        // at the caixa root. The callback is a single per-caixa
7700        // runtime hook; one declaration covers every entry's
7701        // `:state-change`, mirroring OTP's
7702        // `release_handler:install_release/1` which dispatches every
7703        // appup's `code_change` instruction through the single
7704        // `gen_server:code_change/3` callback registered on the
7705        // module.
7706        let entries = vec![
7707            entry(
7708                "0.1.0",
7709                vec![
7710                    UpgradeInstruction::LoadModule { module: "x".into() },
7711                    UpgradeInstruction::StateChange {
7712                        script: PathBuf::from("lib/m1.lisp"),
7713                    },
7714                ],
7715            ),
7716            entry(
7717                "0.1.5",
7718                vec![
7719                    UpgradeInstruction::LoadModule { module: "x".into() },
7720                    UpgradeInstruction::StateChange {
7721                        script: PathBuf::from("lib/m2.lisp"),
7722                    },
7723                ],
7724            ),
7725        ];
7726        let b = behavior_with_state_change_callback();
7727        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7728    }
7729
7730    #[test]
7731    fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7732        // Symmetry pin: the gate's pass arm doesn't depend on the
7733        // entry actually carrying a `:state-change` — if no
7734        // `:state-change` is declared, the gate is vacuous regardless
7735        // of the callback (an `:on-state-change` declared without a
7736        // matching per-version script is fine, the callback is the
7737        // runtime default for any *future* migration the author hasn't
7738        // yet added). Pins that a caixa author can declare the
7739        // callback ahead of any migration without the gate
7740        // complaining.
7741        let entries = vec![entry(
7742            "0.1.0",
7743            vec![
7744                UpgradeInstruction::LoadModule { module: "x".into() },
7745                UpgradeInstruction::SoftPurge {
7746                    module: "x-old".into(),
7747                },
7748            ],
7749        )];
7750        let b = behavior_with_state_change_callback();
7751        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7752    }
7753
7754    #[test]
7755    fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7756        // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7757        // per-instruction `StateChange`-arm script-path projection must
7758        // route through the sibling lifted
7759        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7760        // accessor, not the raw
7761        // `if let UpgradeInstruction::StateChange { script } = instr`
7762        // open-coded pattern-match the cross-slot gate previously
7763        // carried at caixa-core/src/upgrade.rs:1365.
7764        //
7765        // Structurally: the gate's projection accept-set is the union
7766        // of every [`UpgradeInstruction`] variant for which
7767        // `declared_path().is_some()` — today exactly
7768        // [`UpgradeInstruction::StateChange`] per the sibling
7769        // `declared_path_only_for_state_change` pin, so a
7770        // `:state-change`-carrying entry without an `:on-state-change`
7771        // callback trips `StateChangeWithoutOnStateChangeCallback` and
7772        // a non-`StateChange` entry (load-only / cleanup-only /
7773        // restart-only / empty-`:instructions`) leaves the per-entry
7774        // walk continuing past every non-projecting instruction
7775        // byte-identical to the pattern-match shape.
7776        //
7777        // Byte-equal today (`declared_path` returns `Some(script)` iff
7778        // `StateChange`, byte-for-byte from the variant's own storage);
7779        // the pin catches any future accessor extension that promotes
7780        // an additional variant onto the `PathBuf`-carrying axis — the
7781        // gate then fires on scripts from that variant too, and the
7782        // cross-slot composition discipline the sibling per-
7783        // `UpgradeInstruction` consumers share on the `PathBuf`-
7784        // carrying axis extends to the promoted variant by
7785        // construction.
7786        //
7787        // Peer of the sibling four per-`UpgradeInstruction` consumers
7788        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7789        // sandbox-path fan-out, the layout-side per-`StateChange`
7790        // script-existence fan-out at
7791        // `caixa-core/src/layout.rs:1058`, the within-entry
7792        // [`UpgradeFromEntry::validate_state_change_singularity`]
7793        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7794        // peer [`UpgradeInstruction::declared_module`] `String`-axis
7795        // per-variant unifier) — the fourth (and last) per-
7796        // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7797        // to now route through the accessor. Same shape as the
7798        // sibling
7799        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7800        // pin extended onto the cross-slot composition gate.
7801        //
7802        // Three-arm projective coverage:
7803        //   (a) `StateChange` scripts project through `declared_path()`
7804        //       byte-equal to the raw `script.clone()` field access
7805        //       the diagnostic previously carried;
7806        //   (b) a `:state-change`-carrying entry with `behavior: None`
7807        //       trips the gate with `StateChangeWithoutOnStateChangeCallback`
7808        //       carrying the offending script verbatim;
7809        //   (c) a non-`StateChange`-only entry (`LoadModule` /
7810        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7811        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7812        //       arm's fall-through pins.
7813        //
7814        // Fail-before-pass-after verified structurally: swapping the
7815        // production
7816        //   `if let Some(script) = instr.declared_path() { … }`
7817        // back to
7818        //   `if let UpgradeInstruction::StateChange { script } = instr { … }`
7819        // keeps arms (a)-(c) passing but silently detaches the gate
7820        // from the accessor's typed dispatch — any future
7821        // `declared_path` extension (promotion of an additional
7822        // variant onto the axis, an operator-side pre-resolved-path
7823        // cache the accessor materializes) would then silently
7824        // disagree between this cross-slot gate's raw pattern-match
7825        // and the peer four sibling consumers that route through the
7826        // accessor.
7827
7828        // (a) StateChange projection byte-equal via declared_path.
7829        let sc = UpgradeInstruction::StateChange {
7830            script: PathBuf::from("lib/m.lisp"),
7831        };
7832        assert_eq!(
7833            sc.declared_path().cloned(),
7834            Some(PathBuf::from("lib/m.lisp")),
7835            "declared_path() must project the StateChange :script byte-equal to the raw \
7836             field access — accessor divergence would silently detach this cross-slot \
7837             composition gate from the projection every peer per-`UpgradeInstruction` \
7838             consumer routes through"
7839        );
7840
7841        // (b) StateChange-carrying entry with behavior: None trips gate.
7842        let entries = vec![entry(
7843            "0.1.0",
7844            vec![
7845                UpgradeInstruction::LoadModule { module: "x".into() },
7846                UpgradeInstruction::StateChange {
7847                    script: PathBuf::from("lib/m.lisp"),
7848                },
7849            ],
7850        )];
7851        assert_eq!(
7852            validate_upgrade_from_against_behavior(&entries, None),
7853            Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7854                from: "0.1.0".into(),
7855                script: PathBuf::from("lib/m.lisp"),
7856            }),
7857            "a :state-change-carrying entry with behavior: None must trip the gate through \
7858             the declared_path accessor's Some(script) arm — carrying the offending script \
7859             verbatim byte-identical to the pattern-match shape"
7860        );
7861
7862        // (c) Non-StateChange-only inputs leave the gate vacuous.
7863        for instrs in [
7864            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7865            vec![
7866                UpgradeInstruction::LoadModule { module: "x".into() },
7867                UpgradeInstruction::SoftPurge {
7868                    module: "x-old".into(),
7869                },
7870            ],
7871            vec![
7872                UpgradeInstruction::LoadModule { module: "x".into() },
7873                UpgradeInstruction::Purge {
7874                    module: "x-old".into(),
7875                },
7876            ],
7877            vec![UpgradeInstruction::Restart],
7878        ] {
7879            for instr in &instrs {
7880                assert!(
7881                    instr.declared_path().is_none(),
7882                    "non-StateChange variants must project None through declared_path — \
7883                     accessor divergence would let this cross-slot composition gate silently \
7884                     fire on a module reference far from any :state-change site"
7885                );
7886            }
7887            let entries = vec![entry("0.1.0", instrs)];
7888            assert_eq!(
7889                validate_upgrade_from_against_behavior(&entries, None),
7890                Ok(()),
7891                "the cross-slot composition gate must return Ok(()) on an entry whose \
7892                 instructions all project None through declared_path — the accessor's \
7893                 None arm the pattern-match's implicit fall-through previously carried"
7894            );
7895        }
7896    }
7897
7898    #[test]
7899    fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7900        // Wiring pin: the within-entry restart-exclusivity gate fires
7901        // through [`validate_upgrade_from`] (which delegates to
7902        // [`UpgradeFromEntry::validate`] per entry) before the cross-
7903        // entry duplicate-`:from` gate would have a chance to run on
7904        // the malformed entry. Pinned here so a future refactor that
7905        // walks the cross-entry gate first doesn't accidentally
7906        // surface a DuplicateFrom over an entry that's also malformed
7907        // at the within-entry restart-exclusivity layer.
7908        let entries = vec![
7909            entry(
7910                "0.1.0",
7911                vec![
7912                    UpgradeInstruction::LoadModule { module: "x".into() },
7913                    UpgradeInstruction::Restart,
7914                ],
7915            ),
7916            entry("0.1.0", vec![UpgradeInstruction::Restart]),
7917        ];
7918        let err = validate_upgrade_from(&entries).unwrap_err();
7919        assert!(
7920            matches!(
7921                err,
7922                UpgradeError::RestartNotExclusive {
7923                    restart_count: 1,
7924                    ..
7925                }
7926            ),
7927            "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7928             duplicate-`:from` gate fires, got {err:?}"
7929        );
7930    }
7931
7932    // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7933
7934    #[test]
7935    fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7936        // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7937        // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7938        // name the exact camelCase JSON keys the `#[serde(rename_all =
7939        // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7940        // test-side probe across the caixa-core / caixa-flux renderer
7941        // test fixtures navigates into each element of the rendered
7942        // `:upgrade-from` overlay sequence by consulting one of these two
7943        // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7944        // and pin that each canonical byte-sequence appears verbatim in
7945        // the JSON — a future accidental `rename_all = "snake_case"` /
7946        // `"kebab-case"` / verbatim-field-name flip at the derive
7947        // attribute (any of which would silently break every test-side
7948        // probe that reaches for one of the two consts) surfaces here as
7949        // a build-time test failure at `upgrade.rs`, not as an apply-time
7950        // `.get(<stale-canonical-const>)` returning `None` far from the
7951        // derive-attr drift's commit. Same discipline the sibling
7952        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7953        // (d8b8b4f) and
7954        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7955        // (21fe462) pins established on the peer `:limits` / `:behavior`
7956        // sub-slot axes: one canonical byte-string per typed sub-key
7957        // axis, pinned to the load-bearing serde derivation at the type
7958        // itself.
7959        let e = UpgradeFromEntry {
7960            from: "0.1.0".into(),
7961            instructions: vec![UpgradeInstruction::LoadModule {
7962                module: "hello-rio".into(),
7963            }],
7964        };
7965        let json = serde_json::to_string(&e).unwrap();
7966        for key in [
7967            crate::render::M2_UPGRADE_FROM_KEY_FROM,
7968            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7969        ] {
7970            let quoted = format!("\"{key}\"");
7971            assert!(
7972                json.contains(&quoted),
7973                "serialized UpgradeFromEntry must carry the lifted \
7974                 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7975                 the JSON emission (got: {json})",
7976            );
7977        }
7978    }
7979
7980    #[test]
7981    fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7982        // Cross-axis drift-detection pin: a future collapse of the two
7983        // canonical sub-key byte-strings onto the same value (e.g. an
7984        // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7985        // to also read `"from"`) would silently reroute every test-side
7986        // probe on one axis onto the sibling axis's per-entry field and
7987        // pass every propagation-probe test that expected only the stale
7988        // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7989        // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7990        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7991        let all = [
7992            crate::render::M2_UPGRADE_FROM_KEY_FROM,
7993            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7994        ];
7995        for (i, a) in all.iter().enumerate() {
7996            for b in all.iter().skip(i + 1) {
7997                assert_ne!(
7998                    a, b,
7999                    "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
8000                     canonical byte-sequences — got `{a}` == `{b}`",
8001                );
8002            }
8003        }
8004    }
8005
8006    #[test]
8007    fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
8008        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8009        // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
8010        // tagged variant-discriminator key axis: the
8011        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
8012        // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
8013        // attribute on [`UpgradeInstruction`] emits, and every downstream
8014        // consumer that navigates the serialized instruction blob to
8015        // route by variant (the caixa-core reflection-vs-serde round-trip
8016        // check in `dispatcher_registration.rs` that probes
8017        // `v.get("kind")` against every variant's expected kebab-case
8018        // tag, the future M4 admission-webhook path, any wasm-operator
8019        // dispatch step consuming the serialized instruction blob) reads
8020        // through the same `&'static str`. Serialize every variant and
8021        // pin that the const's byte-sequence appears verbatim as the
8022        // tag-slot JSON key with the expected kebab-case value — a
8023        // future accidental `tag = "type"` / `tag = "op"` /
8024        // `tag = "instruction"` rebrand at the derive attribute (any of
8025        // which would silently break every consumer probe reaching for
8026        // the stale-tag-key const) surfaces here as a build-time test
8027        // failure at `upgrade.rs`, not as an apply-time
8028        // `.get(<stale-tag-key>)` returning `None` far from the derive-
8029        // attr drift's commit.
8030        //
8031        // Same "one canonical byte-string per typed axis" discipline the
8032        // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8033        // pin (36ffe65) established on the peer `:upgrade-from` per-entry
8034        // outer-container axis — this pin extends the discipline one
8035        // altitude deeper onto the per-instruction *tag* axis inside
8036        // each element of the `:instructions` list, completing the
8037        // typed coverage of the `:upgrade-from :instructions` dual
8038        // (key = "kind" + five variant-value tags): the five
8039        // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
8040        // per-variant kebab-case *values*; this pin pins the tag *key*
8041        // above them.
8042        let samples: [(UpgradeInstruction, &'static str); 5] = [
8043            (
8044                UpgradeInstruction::LoadModule {
8045                    module: "hello-rio".into(),
8046                },
8047                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
8048            ),
8049            (
8050                UpgradeInstruction::StateChange {
8051                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8052                },
8053                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
8054            ),
8055            (
8056                UpgradeInstruction::SoftPurge {
8057                    module: "hello-rio-old".into(),
8058                },
8059                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
8060            ),
8061            (
8062                UpgradeInstruction::Purge {
8063                    module: "hello-rio-old".into(),
8064                },
8065                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
8066            ),
8067            (
8068                UpgradeInstruction::Restart,
8069                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
8070            ),
8071        ];
8072        for (sample, expected_value) in &samples {
8073            let v: serde_json::Value = serde_json::to_value(sample).unwrap();
8074            let got = v
8075                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
8076                .and_then(|k| k.as_str());
8077            assert_eq!(
8078                got,
8079                Some(*expected_value),
8080                "serialized {sample:?} must carry the lifted \
8081                 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
8082                 ({:?}) verbatim as the tag-slot JSON key, holding the \
8083                 expected kebab-case value {expected_value:?} (got: {v})",
8084                crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
8085            );
8086        }
8087    }
8088
8089    #[test]
8090    fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
8091        // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
8092        // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8093        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8094        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8095        // whitespace / colons / dots) — the canonical shape a serde
8096        // internally-tagged discriminator key takes across every peer
8097        // enum in this crate. A future flip to a non-camelCase byte at
8098        // the const surfaces here at build time. Peer of
8099        // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
8100        // sibling per-entry outer-container axis.
8101        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8102        assert!(
8103            !key.is_empty(),
8104            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
8105        );
8106        let first = key.chars().next().unwrap();
8107        assert!(
8108            first.is_ascii_lowercase(),
8109            "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
8110             byte (got {key:?}, leads with {first:?})",
8111        );
8112        assert!(
8113            key.chars().all(|c| c.is_ascii_alphanumeric()),
8114            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
8115             — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8116        );
8117    }
8118
8119    #[test]
8120    fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
8121        // Cross-axis drift-detection pin: the tag-slot key
8122        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
8123        // disjoint from every per-variant data-field key the
8124        // internally-tagged serialization also emits (`"module"` for
8125        // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
8126        // future accidental rebrand that collapses `tag = "kind"` onto
8127        // one of the data-field names (e.g. `tag = "module"`) would
8128        // silently corrupt every serialized LoadModule blob (the
8129        // module string and the variant tag would collide on the same
8130        // JSON key) and every consumer probe would either misread the
8131        // tag or fail to distinguish variants. Pin the disjointness at
8132        // build time. Same cross-axis discipline the sibling
8133        // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
8134        // (36ffe65) established on the outer container's own
8135        // `from`/`instructions` pair.
8136        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8137        // Enumerate every per-variant data-field key across all five
8138        // variants of [`UpgradeInstruction`], routing through the two
8139        // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
8140        // that name the same per-variant data-field JSON keys the
8141        // `variant_fields` reflection in
8142        // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
8143        // per-variant struct-field rebrand (`module` → `component`,
8144        // `script` → `path`) lands as an edit to exactly one const and
8145        // reaches this disjointness pin by construction — the two axes
8146        // (tag-slot key on one side, per-variant data-field keys on the
8147        // other) share one source of truth per axis.
8148        for data_field in [
8149            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8150            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8151        ] {
8152            assert_ne!(
8153                key, data_field,
8154                "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
8155                 must be disjoint from every UpgradeInstruction per-variant \
8156                 data-field key — got tag-key {key:?} colliding with \
8157                 data-field {data_field:?}, which would silently corrupt \
8158                 the internally-tagged serialization",
8159            );
8160        }
8161    }
8162
8163    #[test]
8164    fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
8165        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8166        // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
8167        // data-field JSON key axis: the two
8168        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
8169        // `_SCRIPT`) name the exact per-variant field JSON keys the
8170        // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
8171        // [`UpgradeInstruction`] emits alongside the tag-slot key from the
8172        // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
8173        // const — the `module: String` struct-field on
8174        // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
8175        // struct-field on `StateChange` are promoted to sibling JSON keys
8176        // at the same nesting level as the tag by the internally-tagged
8177        // serialization, and every downstream consumer that navigates the
8178        // serialized instruction blob to reach the payload (the caixa-core
8179        // reflection round-trip in `dispatcher_registration.rs` that
8180        // consults `variant_fields`, the sibling disjointness pin below,
8181        // any future wasm-operator upgrade-dispatch step consuming the
8182        // serialized instruction blob to route the per-module load /
8183        // soft-purge / purge action or the per-script state-change action)
8184        // reads through the same `&'static str`. Serialize one Module-
8185        // bearing variant and one Script-bearing variant, then pin that
8186        // each const's byte-sequence appears verbatim in the JSON emission
8187        // — a future accidental struct-field rebrand (`module: String` →
8188        // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8189        // either variant surfaces here as a build-time test failure at
8190        // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8191        // returning `None` far from the field-name drift's commit.
8192        //
8193        // Same "one canonical byte-string per typed axis" discipline the
8194        // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8195        // pin established on the peer tag-slot key axis on the same
8196        // enum — this pin extends the discipline onto the per-variant
8197        // data-field key axis, completing the `:upgrade-from :instructions`
8198        // variant-JSON dual (tag key + tag values + per-variant field keys)
8199        // fully into caixa-core.
8200        let module_sample = UpgradeInstruction::LoadModule {
8201            module: "hello-rio".into(),
8202        };
8203        let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8204        assert_eq!(
8205            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8206                .and_then(|k| k.as_str()),
8207            Some("hello-rio"),
8208            "serialized {module_sample:?} must carry the lifted \
8209             M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8210             ({:?}) verbatim as the data-field JSON key holding the \
8211             module string (got: {v})",
8212            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8213        );
8214
8215        let script_sample = UpgradeInstruction::StateChange {
8216            script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8217        };
8218        let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8219        assert_eq!(
8220            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8221                .and_then(|k| k.as_str()),
8222            Some("lib/migrations/v01-to-v02.lisp"),
8223            "serialized {script_sample:?} must carry the lifted \
8224             M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8225             ({:?}) verbatim as the data-field JSON key holding the \
8226             script path (got: {v})",
8227            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8228        );
8229    }
8230
8231    #[test]
8232    fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8233        // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8234        // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8235        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8236        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8237        // whitespace / colons / dots) — the canonical shape a Rust
8238        // struct-field name promoted to a JSON key by serde takes on this
8239        // internally-tagged variant surface, matching the sibling
8240        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8241        // shape. A future flip to a non-camelCase byte at either const
8242        // (an accidental `rename_all` regime interleave, or a struct-
8243        // field flip like `module` → `module_name`) surfaces here at
8244        // build time. Peer of
8245        // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8246        // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8247        // the sibling wire-key axes.
8248        for key in [
8249            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8250            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8251        ] {
8252            assert!(
8253                !key.is_empty(),
8254                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8255            );
8256            let first = key.chars().next().unwrap();
8257            assert!(
8258                first.is_ascii_lowercase(),
8259                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8260                 byte (got {key:?}, leads with {first:?})",
8261            );
8262            assert!(
8263                key.chars().all(|c| c.is_ascii_alphanumeric()),
8264                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8265                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8266            );
8267        }
8268    }
8269
8270    #[test]
8271    fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8272        // Cross-axis drift-detection pin: a future collapse of the two
8273        // canonical per-variant data-field byte-strings onto the same
8274        // value (e.g. an accidental copy-paste flip of
8275        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8276        // `"module"`) would silently reroute every test-side probe on one
8277        // variant's payload onto the sibling variant's payload and pass
8278        // every propagation-probe test that expected only the stale
8279        // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8280        // on the sibling per-entry outer-container axis, and of
8281        // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8282        // on the sibling tag-slot key ↔ per-variant data-field key axis.
8283        let all = [
8284            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8285            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8286        ];
8287        for (i, a) in all.iter().enumerate() {
8288            for b in all.iter().skip(i + 1) {
8289                assert_ne!(
8290                    a, b,
8291                    "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8292                     canonical byte-sequences — got `{a}` == `{b}`",
8293                );
8294            }
8295        }
8296    }
8297
8298    #[test]
8299    fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8300        // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8301        // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8302        // `kebab-case` hyphens, no `PascalCase` leading capital, no
8303        // whitespace / colons / dots) — the canonical shape the
8304        // `#[serde(rename_all = "camelCase")]` derive produces on
8305        // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8306        // at the derive surfaces both here (this test fails on the
8307        // stale-constant shape) and at
8308        // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8309        // (that test fails on the mismatch between const and derive).
8310        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8311        // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8312        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8313        for key in [
8314            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8315            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8316        ] {
8317            assert!(
8318                !key.is_empty(),
8319                "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8320            );
8321            let first = key.chars().next().unwrap();
8322            assert!(
8323                first.is_ascii_lowercase(),
8324                "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8325                 byte (got {key:?}, leads with {first:?})",
8326            );
8327            assert!(
8328                key.chars().all(|c| c.is_ascii_alphanumeric()),
8329                "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8330                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8331            );
8332        }
8333    }
8334
8335    #[test]
8336    fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8337        // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8338        // OTP-appup variant-tag axis: the five canonical author-facing
8339        // kebab-case labels (`:load-module` / `:state-change` /
8340        // `:soft-purge` / `:purge` / `:restart`) the substrate's
8341        // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8342        // from and every downstream consumer probes for verbatim. Same
8343        // scalar-value discipline the peer
8344        // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8345        // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8346        // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8347        // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8348        // (be40492) established for the sibling M2 / M3 / Supervisor
8349        // top-level and sub-slot author-facing-label axes. Fail-before-
8350        // pass-after locally verified by mutating
8351        // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8352        // pin fires as expected; restoring passes.
8353        //
8354        // A future OTP-lineage per-variant rebrand (e.g.
8355        // `:load-module` → `:load` matching Erlang's abbreviated
8356        // `code:load_module` name, `:state-change` → `:code-change`
8357        // matching Erlang's verbatim `code_change/3` callback,
8358        // `:soft-purge` → `:drain` matching a hypothetical operator-side
8359        // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8360        // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8361        // matching a supervisor-tree vocabulary alignment) lands as an
8362        // edit to exactly one const, and every consumer that reaches for
8363        // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8364        // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8365        // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8366        // `prior_cleanup_kind:` diagnostic field, the
8367        // [`LayoutError::UpgradeViolation`] `issue:` probe in
8368        // `layout.rs`) picks it up at build time rather than at runtime
8369        // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8370        // far from the rename's commit.
8371        assert_eq!(
8372            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8373            ":load-module"
8374        );
8375        assert_eq!(
8376            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8377            ":state-change"
8378        );
8379        assert_eq!(
8380            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8381            ":soft-purge"
8382        );
8383        assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8384        assert_eq!(
8385            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8386            ":restart"
8387        );
8388    }
8389
8390    #[test]
8391    fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8392        // Cross-arm drift-detection pin on the M2
8393        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8394        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8395        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8396        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8397        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8398        // closed-set OTP-appup variant-tag pentad: a future collapse
8399        // of two canonical variant byte-strings onto the same value
8400        // (an accidental copy-paste flip of
8401        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8402        // to also read `":purge"`, a per-arm rebrand that lands one
8403        // const without touching its paired peer) would silently
8404        // reroute every downstream OTP-appup dispatcher's per-
8405        // instruction branch onto the sibling arm's runtime
8406        // behavior and pass every propagation-probe test that
8407        // expected only the stale arm's tag — a `:soft-purge`
8408        // instruction (drain-then-swap: existing callers finish
8409        // under the old module, new callers land on the new one)
8410        // would come up under the `:purge` reconcile branch
8411        // (drop-existing: every in-flight caller terminates
8412        // immediately) on every hot-upgrade cycle, so a rolling
8413        // module swap would silently downgrade to a hard cutover
8414        // against its declared appup discipline, with no field
8415        // naming the instruction-tag drift root cause. Every
8416        // [`crate::UpgradeError`] diagnostic that surfaces the tag
8417        // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8418        // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8419        // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8420        // `prior_cleanup_kind:` field, the
8421        // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8422        // `layout.rs`) would emit the sibling arm's stale bytes at
8423        // the operator's console, far from the source rebrand
8424        // commit. Peer of the sibling
8425        // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8426        // (09ffb2d) /
8427        // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8428        // (ccdf955) /
8429        // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8430        // (d739850) distinctness pins on the sibling OTP-shape /
8431        // caixa-kind closed-set typed-enum discriminator axes —
8432        // the fifth closed-set OTP-appup / typed-enum axis to
8433        // converge on the same
8434        // "pairwise-distinct-by-construction" discipline, and the
8435        // canonical companion to the peer
8436        // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8437        // (ff980bb) distinctness pin on the sibling internally-
8438        // tagged-JSON per-variant data-field-key axis (the tag axis
8439        // this pin covers vs. the data-field-key axis its peer
8440        // covers — two paired axes on the same
8441        // [`crate::UpgradeInstruction`] typed enum surface).
8442        //
8443        // Fail-before-pass-after locally verified by mutating
8444        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8445        // to also read `":purge"` — this pin fires as expected;
8446        // restoring passes.
8447        let all = [
8448            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8449            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8450            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8451            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8452            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8453        ];
8454        for (i, a) in all.iter().enumerate() {
8455            for (j, b) in all.iter().enumerate() {
8456                if i != j {
8457                    assert_ne!(
8458                        a, b,
8459                        "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8460                         distinct — got duplicate {a:?} at indices {i} and {j}",
8461                    );
8462                }
8463            }
8464        }
8465    }
8466
8467    #[test]
8468    fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8469        // Production-through-const pin: the five per-variant labels
8470        // [`UpgradeInstruction::lisp_form`] returns route through the
8471        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8472        // so a future rebrand that reaches the const but not the
8473        // dispatch (or vice versa) surfaces here at build time rather
8474        // than at runtime as a downstream
8475        // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8476        // diagnostic drift far from the rename's commit. Mirror of the
8477        // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8478        // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8479        // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8480        // (f49c8b0) production-through-const pins on the sibling M3 /
8481        // M2 top-level slot axes.
8482        //
8483        // Fail-before-pass-after locally verified by mutating
8484        // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8485        // `":purge-drift"` — this pin fires as expected; restoring
8486        // passes.
8487        let cases: &[(UpgradeInstruction, &'static str)] = &[
8488            (
8489                UpgradeInstruction::LoadModule { module: "x".into() },
8490                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8491            ),
8492            (
8493                UpgradeInstruction::StateChange {
8494                    script: PathBuf::from("lib/m.lisp"),
8495                },
8496                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8497            ),
8498            (
8499                UpgradeInstruction::SoftPurge {
8500                    module: "x-old".into(),
8501                },
8502                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8503            ),
8504            (
8505                UpgradeInstruction::Purge {
8506                    module: "x-old".into(),
8507                },
8508                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8509            ),
8510            (
8511                UpgradeInstruction::Restart,
8512                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8513            ),
8514        ];
8515        for (instr, expected) in cases {
8516            assert_eq!(
8517                instr.lisp_form(),
8518                *expected,
8519                "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8520                 const (expected {expected:?})",
8521            );
8522        }
8523    }
8524
8525    #[test]
8526    fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8527        // Return-lifetime pin on the substrate primitive: because
8528        // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8529        // (threaded verbatim from the paired
8530        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8531        // roster's program-lifetime storage), the label survives
8532        // dropping the borrow through `self` — a downstream logger
8533        // that stashes the tag in a `&'static`-bounded position
8534        // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8535        // accept-set, a static formatter's `%s` argument) reads it
8536        // without re-borrowing through the instruction reference. A
8537        // future refactor that accidentally narrows the return to
8538        // `&str` (lifetime-bound to `&self`) — say by projecting through
8539        // an owned `String` intermediate — would fail this compile-time
8540        // pin at build time far from the runtime-side lifetime
8541        // regression at every downstream `&'static str` consumer. Peer
8542        // pin discipline the sibling
8543        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8544        // `pub const _: &str = "..."` shape already carries at the
8545        // paired wire-form axis.
8546        //
8547        // The pin fires by taking the label from an instruction that
8548        // goes out of scope before the label is read — if
8549        // `lisp_form` returned a `&str` tied to `&self`, this would
8550        // fail to compile with "borrowed value does not live long
8551        // enough". Fail-before-pass-after locally verified: narrowing
8552        // the signature to `fn lisp_form(&self) -> &str { … }`
8553        // reproduces the compile error.
8554        fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8555            instr.lisp_form()
8556        }
8557        let label = {
8558            let instr = UpgradeInstruction::LoadModule {
8559                module: "ephemeral".into(),
8560            };
8561            stash_label_as_static(&instr)
8562            // instr drops here; label must survive
8563        };
8564        assert_eq!(
8565            label,
8566            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8567            "the &'static str return must survive the borrowed \
8568             UpgradeInstruction going out of scope — a lifetime narrowing \
8569             to &str would fail this pin at build time",
8570        );
8571    }
8572
8573    #[test]
8574    fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8575        // Const-position pin on the substrate primitive: because
8576        // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8577        // consumers can call it in `const` contexts — a `const`
8578        // declaration threading the label through, a `static` lookup
8579        // table pre-computed at compile time, a `match` arm's
8580        // `const`-eligible branch label. `pub` matters here: a
8581        // `pub(crate) const fn` would compile in-crate const contexts
8582        // but no external caixa-<target> renderer or feira verb could
8583        // reach the projection in a const context. Fail-before-pass-
8584        // after locally verified: reverting the visibility to
8585        // `pub(crate) const fn` (or removing `pub`) makes this pin
8586        // fail to compile at the const-context call site below.
8587        const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8588        assert_eq!(
8589            RESTART_LABEL,
8590            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8591            "const-position dispatch on Restart must yield the lifted \
8592             M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8593        );
8594    }
8595
8596    #[test]
8597    fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8598        // The canonical per-`:upgrade-from :instructions` OTP-appup
8599        // migration-instruction-list slice-shape pin:
8600        // [`UpgradeFromEntry::instructions`] must return the
8601        // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8602        // a `&[UpgradeInstruction]` slice-view over the same backing
8603        // buffer the raw `self.instructions.as_slice()` field access
8604        // borrows from, byte-equal across every representative fixture
8605        // in the accept-set — the empty slice (the "no-op upgrade" /
8606        // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8607        // field's own docstring names), the singleton slice on every
8608        // variant of the [`UpgradeInstruction`] arm-space
8609        // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8610        // `Restart` — the five OTP-appup runtime-primitive variants),
8611        // and multi-instruction cohorts (the canonical
8612        // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8613        // load + state-migration triad the module doc names as the
8614        // "runs the instructions in order" example).
8615        //
8616        // Pins against a future silent detour that returned
8617        // `&Vec<UpgradeInstruction>` (which would type-check but leak
8618        // the storage-side `Vec`'s grow/push/reserve surface no
8619        // consumer of the typed view reaches for), a fresh-allocated
8620        // `Vec<UpgradeInstruction>` copy (which would type-check via
8621        // a coercion but silently break every downstream caller that
8622        // relied on the slice sharing the backing buffer's identity),
8623        // or an out-of-order or length-drifted projection (which
8624        // would silently split the paired within-entry cross-
8625        // instruction ordering gates' inputs from the peer per-
8626        // instruction shape-check loop's input, one seven-gate cohort
8627        // silently drifting from the peer gate's actual traversal
8628        // input).
8629        //
8630        // Peer of the sibling
8631        // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8632        // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8633        // `:contratos` edge-list axis, extended onto the M2 per-
8634        // `:upgrade-from :instructions` migration-instruction-list
8635        // axis — the fifth `&[T]`-return byte-equal pin, closing the
8636        // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8637        let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8638            Vec::new(),
8639            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8640            vec![UpgradeInstruction::StateChange {
8641                script: PathBuf::from("lib/m.lisp"),
8642            }],
8643            vec![UpgradeInstruction::SoftPurge {
8644                module: "x-old".into(),
8645            }],
8646            vec![UpgradeInstruction::Purge {
8647                module: "x-old".into(),
8648            }],
8649            vec![UpgradeInstruction::Restart],
8650            vec![
8651                UpgradeInstruction::LoadModule { module: "x".into() },
8652                UpgradeInstruction::StateChange {
8653                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8654                },
8655                UpgradeInstruction::SoftPurge {
8656                    module: "x-old".into(),
8657                },
8658            ],
8659        ];
8660        for instructions in fixtures {
8661            let e = UpgradeFromEntry {
8662                from: "0.1.0".into(),
8663                instructions: instructions.clone(),
8664            };
8665            assert_eq!(
8666                e.instructions(),
8667                e.instructions.as_slice(),
8668                "UpgradeFromEntry::instructions must project the raw \
8669                 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8670                 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8671                 (fixture: {instructions:?})",
8672            );
8673            assert_eq!(
8674                e.instructions().len(),
8675                instructions.len(),
8676                "UpgradeFromEntry::instructions length must match the raw \
8677                 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8678            );
8679        }
8680    }
8681
8682    #[test]
8683    fn validate_reads_through_lifted_instructions_accessor() {
8684        // Three-consumer coherence pin on the lifted
8685        // [`UpgradeFromEntry::instructions`] slice-return accessor:
8686        // exercises three of the nine paired production consumers of
8687        // the per-`:upgrade-from :instructions` OTP-appup migration-
8688        // instruction-list surface through end-to-end validate() paths
8689        // that require the accessor to reach each of the fixture's
8690        // instructions.
8691        //
8692        // (1) The per-instruction shape-check fan-out
8693        // ([`UpgradeFromEntry::validate`]'s `for instr in
8694        // self.instructions()` loop): pass the well-formed load →
8695        // state-change → soft-purge triad — `validate()` must accept
8696        // it, which requires the accessor to project every entry so
8697        // each `instr.validate()` fires.
8698        //
8699        // (2) The within-entry state-change-ordering gate
8700        // ([`Self::validate_state_change_ordering`]): pass a
8701        // `((:state-change …))` singleton — `validate()` must return
8702        // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8703        // requires the accessor to reach the state-change so the
8704        // no-prior-load probe fires.
8705        //
8706        // (3) The within-entry per-module cleanup-singularity gate
8707        // ([`Self::validate_cleanup_singularity`]): pass a
8708        // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8709        // "x-old"))` cohort — `validate()` must return
8710        // [`UpgradeError::DuplicateCleanup`], which requires the
8711        // accessor to iterate the whole list so the second `SoftPurge`
8712        // matches the first via the `seen` set.
8713        //
8714        // Peer of the sibling
8715        // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8716        // three-consumer coherence pin on the M3 per-`:contratos`
8717        // edge-list axis, extended onto the M2 per-`:upgrade-from
8718        // :instructions` migration-instruction-list axis.
8719
8720        // (1) accept the well-formed OTP two-phase code-load triad
8721        let well_formed = entry(
8722            "0.1.0",
8723            vec![
8724                UpgradeInstruction::LoadModule { module: "x".into() },
8725                UpgradeInstruction::StateChange {
8726                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8727                },
8728                UpgradeInstruction::SoftPurge {
8729                    module: "x-old".into(),
8730                },
8731            ],
8732        );
8733        assert!(
8734            well_formed.validate().is_ok(),
8735            "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8736             the per-instruction shape-check fan-out requires the accessor to reach every entry"
8737        );
8738
8739        // (2) refuse a `((:state-change …))` singleton — the
8740        // state-change-without-prior-load gate must fire, which
8741        // requires the accessor to reach the single instruction.
8742        let no_prior_load = entry(
8743            "0.1.0",
8744            vec![UpgradeInstruction::StateChange {
8745                script: PathBuf::from("lib/m.lisp"),
8746            }],
8747        );
8748        match no_prior_load.validate() {
8749            Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8750            other => panic!(
8751                "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8752                 — the within-entry state-change-ordering gate must reach the single \
8753                 instruction through the lifted accessor; got: {other:?}"
8754            ),
8755        }
8756
8757        // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8758        // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8759        // singularity gate must fire on the second `SoftPurge`, which
8760        // requires the accessor to iterate the whole list.
8761        let duplicate_cleanup = entry(
8762            "0.1.0",
8763            vec![
8764                UpgradeInstruction::LoadModule { module: "x".into() },
8765                UpgradeInstruction::SoftPurge {
8766                    module: "x-old".into(),
8767                },
8768                UpgradeInstruction::SoftPurge {
8769                    module: "x-old".into(),
8770                },
8771            ],
8772        );
8773        match duplicate_cleanup.validate() {
8774            Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8775                assert_eq!(
8776                    module, "x-old",
8777                    "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8778                     cleanup-singularity gate must iterate through the lifted accessor to \
8779                     match the second SoftPurge against the first via the `seen` set"
8780                );
8781            }
8782            other => panic!(
8783                "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8784                 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8785                 iterate the whole list through the lifted accessor; got: {other:?}"
8786            ),
8787        }
8788
8789        // Path::new suppresses the unused-import warning if the
8790        // outer module trims `use std::path::Path;` in a future edit.
8791        let _ = Path::new("lib/m.lisp");
8792    }
8793
8794    // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8795    // macro definition (see the paired doc-block above the macro
8796    // definition) — every generated `<ctor>(from: &str, script: &Path)
8797    // -> Self` constructor folds the uniform `Self::<Variant> { from:
8798    // from.to_string(), script: script.to_path_buf() }` two-field
8799    // struct-literal onto one substrate primitive. The three per-variant
8800    // equivalence pins below (fail-before-pass-after by construction — a
8801    // byte-mismatched macro arm would trip its equivalence pin first)
8802    // lock each generated constructor to its struct-literal peer under
8803    // `PartialEq`, so every wire-up in
8804    // [`UpgradeFromEntry::validate_state_change_ordering`],
8805    // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8806    // [`validate_state_change_on_state_change_callback`] on that
8807    // variant produces a byte-equal `UpgradeError` to the pre-lift
8808    // open-coded struct-literal. The cross-axis pin that follows
8809    // (non-default `(from, script)` pair) routes both constructor input
8810    // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8811    // not silently collapse onto a fixed `from` / `script` value.
8812    //
8813    // Peer of the sibling `empty_child_version_ctor_matches_struct_
8814    // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8815    // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8816    // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8817    // to_string` equivalence + cross-axis pins the sibling
8818    // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8819    // established on the peer `SupervisorError` envelope; extended
8820    // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8821    // two-slot envelope so every substrate-primitive ctor family in
8822    // caixa-core guarantees the same-shape fold every wire-up on the
8823    // family reads through one dispatch.
8824
8825    #[test]
8826    fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8827        let from = "0.1.0";
8828        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8829        assert_eq!(
8830            UpgradeError::state_change_without_prior_load(from, script),
8831            UpgradeError::StateChangeWithoutPriorLoad {
8832                from: from.to_string(),
8833                script: script.to_path_buf(),
8834            },
8835            "generated state_change_without_prior_load ctor must produce \
8836             byte-equal UpgradeError to the open-coded struct-literal \
8837             wrap on the same (&str, &Path) fixture",
8838        );
8839    }
8840
8841    #[test]
8842    fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
8843        let from = "0.1.0";
8844        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8845        assert_eq!(
8846            UpgradeError::duplicate_state_change(from, script),
8847            UpgradeError::DuplicateStateChange {
8848                from: from.to_string(),
8849                script: script.to_path_buf(),
8850            },
8851            "generated duplicate_state_change ctor must produce byte-equal \
8852             UpgradeError to the open-coded struct-literal wrap on the \
8853             same (&str, &Path) fixture",
8854        );
8855    }
8856
8857    #[test]
8858    fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
8859        let from = "0.1.0";
8860        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8861        assert_eq!(
8862            UpgradeError::state_change_without_on_state_change_callback(from, script),
8863            UpgradeError::StateChangeWithoutOnStateChangeCallback {
8864                from: from.to_string(),
8865                script: script.to_path_buf(),
8866            },
8867            "generated state_change_without_on_state_change_callback ctor \
8868             must produce byte-equal UpgradeError to the open-coded \
8869             struct-literal wrap on the same (&str, &Path) fixture",
8870        );
8871    }
8872
8873    #[test]
8874    fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
8875        // Cross-axis pin: sweep both constructor input axes (`from:
8876        // &str`, `script: &Path`) through non-default fixtures against
8877        // every generated arm in the [`upgrade_from_script_ctors!`]
8878        // macro, so any wrapper-side lowercase / trim / truncate /
8879        // re-order / fixed-path substitution on the two-field
8880        // construction surfaces here rather than at a downstream
8881        // diagnostic-shape mismatch. Also exercises the `&Path`
8882        // parameter under both `&Path` (direct `Path::new`) and
8883        // `&PathBuf` (via Deref coercion), matching the two shapes the
8884        // three wire-up sites thread through — the ordering /
8885        // callback-declaration gates hand a `&PathBuf` from
8886        // `instr.declared_path()`; the uniqueness gate hands a `&Path`
8887        // from `script.as_path()`. Peer of the sibling
8888        // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
8889        // cross-axis pin on the peer `SupervisorError` `{ caixa:
8890        // String }` envelope.
8891        let from = "1.2.3-rc.1";
8892        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8893        let script_ref: &Path = script_owned.as_path();
8894        for script in [script_ref, &script_owned as &Path] {
8895            assert_eq!(
8896                UpgradeError::state_change_without_prior_load(from, script),
8897                UpgradeError::StateChangeWithoutPriorLoad {
8898                    from: from.to_string(),
8899                    script: script.to_path_buf(),
8900                },
8901            );
8902            assert_eq!(
8903                UpgradeError::duplicate_state_change(from, script),
8904                UpgradeError::DuplicateStateChange {
8905                    from: from.to_string(),
8906                    script: script.to_path_buf(),
8907                },
8908            );
8909            assert_eq!(
8910                UpgradeError::state_change_without_on_state_change_callback(from, script),
8911                UpgradeError::StateChangeWithoutOnStateChangeCallback {
8912                    from: from.to_string(),
8913                    script: script.to_path_buf(),
8914                },
8915            );
8916        }
8917    }
8918
8919    // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
8920    // macro definition (see the paired doc-block above the macro
8921    // definition) — every generated `<ctor>(script: &Path) -> Self`
8922    // constructor folds the uniform `Self::<Variant> { script:
8923    // script.to_path_buf() }` one-field struct-literal onto one substrate
8924    // primitive. The three per-variant equivalence pins below
8925    // (fail-before-pass-after by construction — a byte-mismatched macro
8926    // arm would trip its equivalence pin first) lock each generated
8927    // constructor to its struct-literal peer under `PartialEq`, so every
8928    // closure passed to [`crate::render::require_sandboxed_lisp_path`]
8929    // at [`UpgradeInstruction::validate`] on that variant produces a
8930    // byte-equal `UpgradeError` to the pre-lift open-coded
8931    // struct-literal. The cross-axis pin that follows (non-default
8932    // `script` path, both `&Path` and `&PathBuf` shapes) routes the
8933    // constructor input axis through `.to_path_buf()`, so the fold does
8934    // not silently collapse onto a fixed `script` value or drop the
8935    // Deref-coercion arm the wire-up sites depend on.
8936    //
8937    // Peer of the sibling
8938    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
8939    // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
8940    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
8941    // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
8942    // equivalence + cross-axis pins the sibling
8943    // [`upgrade_from_script_ctors!`] family (8e67041) established on the
8944    // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
8945    // extended here onto the `{ script: PathBuf }` one-slot envelope
8946    // shape so every substrate-primitive ctor family on `UpgradeError`
8947    // guarantees the same-shape fold every wire-up on the family reads
8948    // through one dispatch.
8949
8950    #[test]
8951    fn absolute_script_ctor_matches_struct_literal_wrap() {
8952        let script = Path::new("/etc/nope.lisp");
8953        assert_eq!(
8954            UpgradeError::absolute_script(script),
8955            UpgradeError::AbsoluteScript {
8956                script: script.to_path_buf(),
8957            },
8958            "generated absolute_script ctor must produce byte-equal \
8959             UpgradeError to the open-coded struct-literal wrap on the \
8960             same &Path fixture",
8961        );
8962    }
8963
8964    #[test]
8965    fn parent_escape_script_ctor_matches_struct_literal_wrap() {
8966        let script = Path::new("../oops.lisp");
8967        assert_eq!(
8968            UpgradeError::parent_escape_script(script),
8969            UpgradeError::ParentEscapeScript {
8970                script: script.to_path_buf(),
8971            },
8972            "generated parent_escape_script ctor must produce byte-equal \
8973             UpgradeError to the open-coded struct-literal wrap on the \
8974             same &Path fixture",
8975        );
8976    }
8977
8978    #[test]
8979    fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8980        let script = Path::new("lib/migrations.rs");
8981        assert_eq!(
8982            UpgradeError::non_lisp_extension_script(script),
8983            UpgradeError::NonLispExtensionScript {
8984                script: script.to_path_buf(),
8985            },
8986            "generated non_lisp_extension_script ctor must produce \
8987             byte-equal UpgradeError to the open-coded struct-literal \
8988             wrap on the same &Path fixture",
8989        );
8990    }
8991
8992    #[test]
8993    fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8994        // Cross-axis pin: sweep the constructor input axis (`script:
8995        // &Path`) through a non-default fixture against every generated
8996        // arm in the [`upgrade_script_only_ctors!`] macro, so any
8997        // wrapper-side lowercase / trim / truncate / re-order /
8998        // fixed-path substitution on the one-field construction
8999        // surfaces here rather than at a downstream diagnostic-shape
9000        // mismatch. Also exercises the `&Path` parameter under both
9001        // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
9002        // coercion), matching the shape the three closures at
9003        // [`UpgradeInstruction::validate`] thread through — the
9004        // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
9005        // each closure, so the Deref-coercion arm the ctor advertises
9006        // must actually route through `.to_path_buf()` and not
9007        // silently swap in a fixed path.
9008        //
9009        // Peer of the sibling
9010        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9011        // cross-axis pin on the sibling `{ from, script }` two-slot
9012        // envelope shape.
9013        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9014        let script_ref: &Path = script_owned.as_path();
9015        for script in [script_ref, &script_owned as &Path] {
9016            assert_eq!(
9017                UpgradeError::absolute_script(script),
9018                UpgradeError::AbsoluteScript {
9019                    script: script.to_path_buf(),
9020                },
9021            );
9022            assert_eq!(
9023                UpgradeError::parent_escape_script(script),
9024                UpgradeError::ParentEscapeScript {
9025                    script: script.to_path_buf(),
9026                },
9027            );
9028            assert_eq!(
9029                UpgradeError::non_lisp_extension_script(script),
9030                UpgradeError::NonLispExtensionScript {
9031                    script: script.to_path_buf(),
9032                },
9033            );
9034        }
9035    }
9036
9037    // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
9038    // macro definition (see the paired doc-block above the macro
9039    // definition) — every generated `<ctor>(from: &str, <axis>: &str)
9040    // -> Self` constructor folds the uniform `Self::<Variant> { from:
9041    // from.to_string(), <axis>: <axis>.to_string() }` two-field
9042    // struct-literal onto one substrate primitive. The three per-variant
9043    // equivalence pins below (fail-before-pass-after by construction — a
9044    // byte-mismatched macro arm would trip its equivalence pin first)
9045    // lock each generated constructor to its struct-literal peer under
9046    // `PartialEq`, so every wire-up in
9047    // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
9048    // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
9049    // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
9050    // `:from < :versao` gate on that variant produces a byte-equal
9051    // `UpgradeError` to the pre-lift open-coded struct-literal. The
9052    // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
9053    // pair) routes both constructor input axes through `.to_string()`
9054    // in declared field order, so the fold does not silently swap `from`
9055    // and the middle `<axis>` field, or silently collapse onto a fixed
9056    // `from` / `<axis>` value on any one variant.
9057    //
9058    // Peer of the sibling `state_change_without_prior_load_ctor_matches_
9059    // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
9060    // struct_literal_wrap` / `state_change_without_on_state_change_
9061    // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
9062    // ctors_route_from_and_script_verbatim` equivalence + cross-axis
9063    // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
9064    // established on the sibling `{ from: String, script: PathBuf }`
9065    // two-slot envelope shape; extended here onto the `{ from: String,
9066    // <axis>: String }` two-slot envelope shape so every substrate-
9067    // primitive ctor family on `UpgradeError` guarantees the same-shape
9068    // fold every wire-up on the family reads through one dispatch. Also
9069    // mirror-symmetric peer of the sibling
9070    // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9071    // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
9072    // <axis>: String }` two-slot envelope shape.
9073
9074    #[test]
9075    fn from_invalid_ctor_matches_struct_literal_wrap() {
9076        let from = "not-a-semver";
9077        let reason = "unexpected character '-' at position 3";
9078        assert_eq!(
9079            UpgradeError::from_invalid(from, reason),
9080            UpgradeError::FromInvalid {
9081                from: from.to_string(),
9082                reason: reason.to_string(),
9083            },
9084            "generated from_invalid ctor must produce byte-equal \
9085             UpgradeError to the open-coded struct-literal wrap on the \
9086             same (&str, &str) fixture",
9087        );
9088    }
9089
9090    #[test]
9091    fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
9092        let from = "0.2.0";
9093        let versao = "0.1.0";
9094        assert_eq!(
9095            UpgradeError::from_not_before_versao(from, versao),
9096            UpgradeError::FromNotBeforeVersao {
9097                from: from.to_string(),
9098                versao: versao.to_string(),
9099            },
9100            "generated from_not_before_versao ctor must produce byte-equal \
9101             UpgradeError to the open-coded struct-literal wrap on the \
9102             same (&str, &str) fixture",
9103        );
9104    }
9105
9106    #[test]
9107    fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
9108        let from = "0.1.0";
9109        let module = "hello-rio";
9110        assert_eq!(
9111            UpgradeError::duplicate_load_module(from, module),
9112            UpgradeError::DuplicateLoadModule {
9113                from: from.to_string(),
9114                module: module.to_string(),
9115            },
9116            "generated duplicate_load_module ctor must produce byte-equal \
9117             UpgradeError to the open-coded struct-literal wrap on the \
9118             same (&str, &str) fixture",
9119        );
9120    }
9121
9122    #[test]
9123    fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
9124        // Cross-axis routing pin: sweep the two constructor input axes
9125        // (`from: &str`, `<axis>: &str`) through distinct-per-axis
9126        // fixtures against every generated arm in the
9127        // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
9128        // lowercase / trim / truncate at codegen time — a silent field
9129        // swap between `from` and the middle `<axis>` field, or a
9130        // `<axis>` axis silently rerouted through the wrong field on any
9131        // one variant — surfaces here rather than at a downstream
9132        // diagnostic-shape mismatch. Peer of the sibling
9133        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9134        // (8e67041) cross-axis pin on the same envelope's sibling
9135        // `{ from: String, script: PathBuf }` two-slot family, and of the
9136        // sibling
9137        // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9138        // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
9139        // String, <axis>: String }` two-slot envelope. Distinct-per-
9140        // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
9141        // that would still pass a same-fixture-per-axis pin. Both
9142        // `&str`-literal and `&String` (via Deref coercion) carriers
9143        // are exercised because the three wire-up sites hand a mix of
9144        // both (the `from_invalid` site hands `&e.to_string()` — an
9145        // owned `String` — for `reason`; the `duplicate_load_module`
9146        // site hands a `&str` slice for `module`; the
9147        // `from_not_before_versao` site hands the caller-supplied
9148        // `versao: &str` for `versao`).
9149        let from = "0.1.0";
9150        let axis = "distinct-axis-value";
9151        let from_owned: String = from.to_string();
9152        let axis_owned: String = axis.to_string();
9153        for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
9154            assert_eq!(
9155                UpgradeError::from_invalid(from_in, axis_in),
9156                UpgradeError::FromInvalid {
9157                    from: from.to_string(),
9158                    reason: axis.to_string(),
9159                },
9160                "from_invalid must route `from` → `from`, `axis` → `reason` \
9161                 in declared field order",
9162            );
9163            assert_eq!(
9164                UpgradeError::from_not_before_versao(from_in, axis_in),
9165                UpgradeError::FromNotBeforeVersao {
9166                    from: from.to_string(),
9167                    versao: axis.to_string(),
9168                },
9169                "from_not_before_versao must route `from` → `from`, \
9170                 `axis` → `versao` in declared field order",
9171            );
9172            assert_eq!(
9173                UpgradeError::duplicate_load_module(from_in, axis_in),
9174                UpgradeError::DuplicateLoadModule {
9175                    from: from.to_string(),
9176                    module: axis.to_string(),
9177                },
9178                "duplicate_load_module must route `from` → `from`, \
9179                 `axis` → `module` in declared field order",
9180            );
9181        }
9182    }
9183
9184    // Per-variant equivalence + accessor-fidelity + cross-axis pins for
9185    // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
9186    // the paired doc-block above the ctor definition) — the fold of the
9187    // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9188    // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9189    // duplicate gate onto one substrate primitive on the
9190    // [`UpgradeError`] envelope, projecting through the paired
9191    // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9192    // primitive. A byte-mismatched ctor body would trip the equivalence
9193    // pin first, ahead of any downstream diagnostic-shape drift.
9194    //
9195    // Peer of the sibling standalone-ctor equivalence pins on the peer
9196    // one-off variants across caixa-core:
9197    // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9198    // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9199    // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9200    // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9201    // envelope, the sibling
9202    // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9203    // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9204    // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9205    // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9206
9207    #[test]
9208    fn duplicate_from_ctor_matches_struct_literal_wrap() {
9209        // Equivalence pin: the ctor produces byte-equal
9210        // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9211        // struct-literal that read the same `from` field through
9212        // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9213        // addition / reordering / string-conversion tweak on the
9214        // variant. Same equivalence-pin shape as the sibling
9215        // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9216        // (b30edfe) on the paired two-slot `{ caixa, wit }`
9217        // envelope inside `impl AplicacaoSpec`.
9218        let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9219        let lifted = UpgradeError::duplicate_from(&entry);
9220        let struct_literal = UpgradeError::DuplicateFrom {
9221            from: entry.prior_versao().to_string(),
9222        };
9223        assert_eq!(lifted, struct_literal);
9224    }
9225
9226    #[test]
9227    fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9228        // Routing pin sweeping a non-default `:from` value
9229        // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9230        // release and build metadata) through the paired
9231        // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9232        // wrapper-side lowercase / trim / truncate on the one-field
9233        // construction surfaces here rather than at a downstream
9234        // diagnostic-shape drift. Peer of the sibling
9235        // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9236        // (b30edfe) routing pin on the sibling two-slot envelope.
9237        //
9238        // The pre-release + build-metadata carrier value is deliberately
9239        // chosen to exercise the `.to_string()` path against a `:from`
9240        // shape [`semver::Version::PartialEq`] treats as distinct from
9241        // its release-only sibling (per the
9242        // `validate_upgrade_from_treats_pre_release_as_distinct` and
9243        // build-metadata-tightening-note doc-block on
9244        // [`validate_upgrade_from`]) — so any silent normalization at
9245        // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9246        // `.split_once('-')` collapse) would drop bytes from the
9247        // rendered diagnostic and surface here.
9248        let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9249        let built = UpgradeError::duplicate_from(&entry);
9250        match built {
9251            UpgradeError::DuplicateFrom { from } => {
9252                assert_eq!(
9253                    from, "1.2.3-rc.4+build.5",
9254                    "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9255                     preserving pre-release + build-metadata bytes"
9256                );
9257            }
9258            other => panic!("expected DuplicateFrom, got {other:?}"),
9259        }
9260    }
9261
9262    #[test]
9263    fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9264        // Accessor-fidelity pin: the ctor's `from` slot keys off the
9265        // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9266        // the pre-lift open-coded body's field selection), not any
9267        // stringified rendering of the full entry (e.g. the
9268        // `impl Display for UpgradeFromEntry` output, if one were later
9269        // added, or a `format!("{:?}", entry)` debug dump). Pins the
9270        // projection axis so a silent swap at the ctor body — say, a
9271        // future refactor that projects through `entry.instructions()`
9272        // in shape (dropping the `:from` axis entirely) or through a
9273        // whole-entry `format!` — surfaces here rather than at a
9274        // downstream diagnostic mis-attribution far from the duplicate
9275        // gate's owner.
9276        //
9277        // A future consumer that constructs the ctor against a not-yet-
9278        // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9279        // CR admission webhook re-checking a per-`:upgrade-from`-patched
9280        // candidate before the cross-entry duplicate gate re-fires, a
9281        // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9282        // `(:from …)` introduced by a cluster-local `:upgrade-from`
9283        // override) needs the pre-lift projection axis pinned.
9284        //
9285        // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9286        // paired with a distinctive multi-instruction sequence so a
9287        // silent swap that projects through the whole-entry rendering
9288        // instead of the paired scalar accessor would land debug bytes
9289        // from the `:instructions` list into the `from` slot and trip
9290        // the assertion here.
9291        let entry = entry(
9292            "0.2.0-alpha.7",
9293            vec![
9294                UpgradeInstruction::LoadModule {
9295                    module: "distinctive-load-target".into(),
9296                },
9297                UpgradeInstruction::StateChange {
9298                    script: PathBuf::from("lib/distinctive-migrate.lisp"),
9299                },
9300                UpgradeInstruction::Restart,
9301            ],
9302        );
9303        let built = UpgradeError::duplicate_from(&entry);
9304        match built {
9305            UpgradeError::DuplicateFrom { from } => {
9306                assert_eq!(
9307                    from, "0.2.0-alpha.7",
9308                    "from slot must project UpgradeFromEntry::prior_versao() \
9309                     (not any whole-entry rendering)"
9310                );
9311            }
9312            other => panic!("expected DuplicateFrom, got {other:?}"),
9313        }
9314    }
9315
9316    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9317    // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9318    // ctor (see the paired doc-block above the ctor definition) — the
9319    // fold of the last open-coded three-slot `{ from: String, kind:
9320    // &'static str, module: String }` struct-literal wire-up on
9321    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9322    // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9323    // load-family sticky-latch dispatch onto one substrate primitive.
9324    // A byte-mismatched ctor body would trip the equivalence pin first,
9325    // ahead of any downstream diagnostic-shape drift.
9326    //
9327    // Peer of the sibling standalone-ctor equivalence + routing pins on
9328    // the sibling one-off variants across `UpgradeError`
9329    // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9330    // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9331    // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9332    // the paired one-slot `{ from: String }` envelope) and across
9333    // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9334    // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9335    // String }` `AplicacaoError` envelope).
9336
9337    #[test]
9338    fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9339        // Equivalence pin: the ctor produces byte-equal
9340        // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9341        // open-coded three-field struct-literal on the same `(&str,
9342        // &'static str, &str)` fixture. Guards any future field-
9343        // addition / reordering / string-conversion tweak on the
9344        // variant. Same equivalence-pin shape as the sibling
9345        // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9346        // on the peer one-slot `{ from: String }` envelope.
9347        let from = "0.1.0";
9348        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9349        let module = "hello-rio-old";
9350        assert_eq!(
9351            UpgradeError::purge_without_prior_load(from, kind, module),
9352            UpgradeError::PurgeWithoutPriorLoad {
9353                from: from.to_string(),
9354                kind,
9355                module: module.to_string(),
9356            },
9357            "generated purge_without_prior_load ctor must produce \
9358             byte-equal UpgradeError to the open-coded struct-literal \
9359             wrap on the same (&str, &'static str, &str) fixture",
9360        );
9361    }
9362
9363    #[test]
9364    fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9365        // Cross-axis routing pin: sweep the three constructor input
9366        // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9367        // through distinct-per-axis fixtures across every cleanup-family
9368        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9369        // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9370        // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9371        // wrapper-side lowercase / trim / truncate / silent axis-swap
9372        // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9373        // three-field construction surfaces at assert time rather than
9374        // at a downstream diagnostic consumer that reads the fields
9375        // back and gets a different value than the one it stored. Both
9376        // `&str`-literal and `&String` (via Deref coercion) carriers
9377        // are exercised for `from` / `module` because the sole wire-up
9378        // hands `self.prior_versao()` (a `&str` accessor) and
9379        // `instr.declared_module().expect(…)` (also a `&str`) — the
9380        // ctor must accept both shapes without a pre-conversion.
9381        let kinds: [&'static str; 2] = [
9382            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9383            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9384        ];
9385        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9386        let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9387        for kind in kinds {
9388            for from in froms {
9389                for module in modules {
9390                    let from_owned: String = from.to_string();
9391                    let module_owned: String = module.to_string();
9392                    for (from_in, module_in) in
9393                        [(from, module), (from_owned.as_str(), module_owned.as_str())]
9394                    {
9395                        assert_eq!(
9396                            UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9397                            UpgradeError::PurgeWithoutPriorLoad {
9398                                from: from.to_string(),
9399                                kind,
9400                                module: module.to_string(),
9401                            },
9402                            "purge_without_prior_load must route from → from, \
9403                             kind → kind, module → module in declared field \
9404                             order verbatim on ({from:?}, {kind:?}, {module:?})",
9405                        );
9406                    }
9407                }
9408            }
9409        }
9410    }
9411
9412    #[test]
9413    fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9414        // End-to-end wire-up pin: build an entry whose declared
9415        // `:instructions` list places a `:soft-purge` (and separately a
9416        // `:purge`) before any `:load-module` so
9417        // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9418        // sticky-latch dispatch surfaces
9419        // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9420        // observed `Err` byte-equals the substrate-primitive
9421        // [`UpgradeError::purge_without_prior_load`] ctor's output on
9422        // the same fixture. A future silent de-lift of the wire-up back
9423        // to the open-coded struct-literal (or a silent axis-swap on
9424        // the three-field construction at the wire-up site) trips at
9425        // caixa-core test time rather than at a downstream diagnostic
9426        // consumer far from the wire-up commit. Same end-to-end-wire-up
9427        // discipline as the sibling
9428        // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9429        // on the peer cross-entry duplicate-`:from` gate; both key off
9430        // exactly one typed dispatch on the substrate primitive.
9431        let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9432            (
9433                "0.1.0",
9434                UpgradeInstruction::SoftPurge {
9435                    module: "hello-rio-old".into(),
9436                },
9437                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9438                "hello-rio-old",
9439            ),
9440            (
9441                "1.2.3-rc.1",
9442                UpgradeInstruction::Purge {
9443                    module: "cache-v2-ancient".into(),
9444                },
9445                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9446                "cache-v2-ancient",
9447            ),
9448        ];
9449        for (from, instr, kind, module) in cases {
9450            let e = entry(from, vec![instr]);
9451            let observed = e.validate().unwrap_err();
9452            assert_eq!(
9453                observed,
9454                UpgradeError::purge_without_prior_load(from, kind, module),
9455                "validate_purge_ordering must route its refusal through \
9456                 UpgradeError::purge_without_prior_load(from, kind, \
9457                 module) on a bare-cleanup {kind:?} entry, byte-equal \
9458                 to the pre-lift open-coded struct-literal wrap on the \
9459                 same fixture",
9460            );
9461        }
9462    }
9463
9464    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9465    // the standalone [`UpgradeError::state_change_after_cleanup`]
9466    // inherent ctor (see the paired doc-block above the ctor
9467    // definition) — the fold of the last open-coded four-slot `{ from:
9468    // String, script: PathBuf, prior_cleanup_kind: &'static str,
9469    // prior_cleanup_module: String }` struct-literal wire-up on
9470    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9471    // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9472    // migrate-family sticky-latch dispatch onto one substrate primitive.
9473    // A byte-mismatched ctor body would trip the equivalence pin first,
9474    // ahead of any downstream diagnostic-shape drift. Peer of the
9475    // sibling standalone-ctor equivalence + routing pins on the sibling
9476    // one-off variants across `UpgradeError`
9477    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9478    // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9479    // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9480    // on the paired three-slot `{ from, kind, module }` envelope;
9481    // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9482    // one-slot `{ from }` envelope).
9483
9484    #[test]
9485    fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9486        // Equivalence pin: the ctor produces byte-equal
9487        // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9488        // open-coded four-field struct-literal on the same `(&str,
9489        // &Path, &'static str, &str)` fixture. Guards any future
9490        // field-addition / reordering / string-conversion tweak on the
9491        // variant. Same equivalence-pin shape as the sibling
9492        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9493        // (9752da1) on the peer three-slot envelope.
9494        let from = "0.1.0";
9495        let script = Path::new("lib/m.lisp");
9496        let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9497        let prior_cleanup_module = "x-old";
9498        assert_eq!(
9499            UpgradeError::state_change_after_cleanup(
9500                from,
9501                script,
9502                prior_cleanup_kind,
9503                prior_cleanup_module,
9504            ),
9505            UpgradeError::StateChangeAfterCleanup {
9506                from: from.to_string(),
9507                script: script.to_path_buf(),
9508                prior_cleanup_kind,
9509                prior_cleanup_module: prior_cleanup_module.to_string(),
9510            },
9511            "generated state_change_after_cleanup ctor must produce \
9512             byte-equal UpgradeError to the open-coded struct-literal \
9513             wrap on the same (&str, &Path, &'static str, &str) fixture",
9514        );
9515    }
9516
9517    #[test]
9518    fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9519        // Cross-axis routing pin: sweep the four constructor input
9520        // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9521        // &'static str`, `prior_cleanup_module: &str`) through
9522        // distinct-per-axis fixtures across every cleanup-family
9523        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9524        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9525        // build-metadata, zero), sibling-`.lisp` script-path shapes
9526        // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9527        // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9528        // lowercase / trim / truncate / silent axis-swap
9529        // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9530        // `from`, `prior_cleanup_kind` misrouted onto
9531        // `prior_cleanup_module`) on the four-field construction
9532        // surfaces at assert time rather than at a downstream diagnostic
9533        // consumer that reads the fields back and gets a different value
9534        // than the one it stored. Both `&str`-literal and `&String` (via
9535        // Deref coercion) carriers are exercised for `from` /
9536        // `prior_cleanup_module` because the sole wire-up hands
9537        // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9538        // (also `&str`, from `declared_module().expect(…)`) — the ctor
9539        // must accept both shapes without a pre-conversion. Both
9540        // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9541        // are exercised for `script` because the sole wire-up hands a
9542        // `&PathBuf` sticky-latch projection from `declared_path()`'s
9543        // `Option<&PathBuf>` return — the ctor must accept both shapes
9544        // without a pre-conversion.
9545        let kinds: [&'static str; 2] = [
9546            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9547            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9548        ];
9549        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9550        let scripts: [&str; 3] = [
9551            "m.lisp",
9552            "lib/migrations.lisp",
9553            "lib/migrations/v01/step-1.lisp",
9554        ];
9555        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9556        for kind in kinds {
9557            for from in froms {
9558                for script_str in scripts {
9559                    for module in modules {
9560                        let from_owned: String = from.to_string();
9561                        let module_owned: String = module.to_string();
9562                        let script_path = Path::new(script_str);
9563                        let script_pathbuf = PathBuf::from(script_str);
9564                        for (from_in, module_in, script_in) in [
9565                            (from, module, script_path),
9566                            (
9567                                from_owned.as_str(),
9568                                module_owned.as_str(),
9569                                script_pathbuf.as_path(),
9570                            ),
9571                        ] {
9572                            assert_eq!(
9573                                UpgradeError::state_change_after_cleanup(
9574                                    from_in, script_in, kind, module_in,
9575                                ),
9576                                UpgradeError::StateChangeAfterCleanup {
9577                                    from: from.to_string(),
9578                                    script: PathBuf::from(script_str),
9579                                    prior_cleanup_kind: kind,
9580                                    prior_cleanup_module: module.to_string(),
9581                                },
9582                                "state_change_after_cleanup must route from → from, \
9583                                 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9584                                 prior_cleanup_module → prior_cleanup_module in declared \
9585                                 field order verbatim on ({from:?}, {script_str:?}, \
9586                                 {kind:?}, {module:?})",
9587                            );
9588                        }
9589                    }
9590                }
9591            }
9592        }
9593    }
9594
9595    #[test]
9596    fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9597        // End-to-end wire-up pin: build an entry whose declared
9598        // `:instructions` list places a `:soft-purge` (and separately a
9599        // `:purge`) before a `:state-change` so
9600        // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9601        // migrate-family sticky-latch dispatch surfaces
9602        // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9603        // observed `Err` byte-equals the substrate-primitive
9604        // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9605        // the same fixture. A future silent de-lift of the wire-up back
9606        // to the open-coded struct-literal (or a silent axis-swap on
9607        // the four-field construction at the wire-up site) trips at
9608        // caixa-core test time rather than at a downstream diagnostic
9609        // consumer far from the wire-up commit. Same end-to-end-wire-up
9610        // discipline as the sibling
9611        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9612        // on the peer load → cleanup ordering gate; both key off
9613        // exactly one typed dispatch on the substrate primitive. Every
9614        // entry here front-loads a `:load-module` so the sole surviving
9615        // ordering refusal is the migrate → cleanup one this gate
9616        // owns — the peer `validate_purge_ordering` load → cleanup gate
9617        // returns `Ok(())` on these fixtures, so the migrate-after-
9618        // cleanup arm is the only path to an `Err`.
9619        let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9620            (
9621                "0.1.0",
9622                UpgradeInstruction::SoftPurge {
9623                    module: "hello-rio-old".into(),
9624                },
9625                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9626                "hello-rio-old",
9627                "lib/migrations/v01.lisp",
9628            ),
9629            (
9630                "1.2.3-rc.1",
9631                UpgradeInstruction::Purge {
9632                    module: "cache-v2-ancient".into(),
9633                },
9634                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9635                "cache-v2-ancient",
9636                "lib/migrations/v02.lisp",
9637            ),
9638        ];
9639        for (from, cleanup, kind, module, script_str) in cases {
9640            let script = PathBuf::from(script_str);
9641            let e = entry(
9642                from,
9643                vec![
9644                    UpgradeInstruction::LoadModule {
9645                        module: "hello-rio".into(),
9646                    },
9647                    cleanup,
9648                    UpgradeInstruction::StateChange {
9649                        script: script.clone(),
9650                    },
9651                ],
9652            );
9653            let observed = e.validate().unwrap_err();
9654            assert_eq!(
9655                observed,
9656                UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9657                "validate_state_change_before_cleanup must route its \
9658                 refusal through \
9659                 UpgradeError::state_change_after_cleanup(from, script, \
9660                 prior_cleanup_kind, prior_cleanup_module) on a \
9661                 `:state-change` after a bare-cleanup {kind:?} entry, \
9662                 byte-equal to the pre-lift open-coded struct-literal \
9663                 wrap on the same fixture",
9664            );
9665        }
9666    }
9667
9668    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9669    // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9670    // (see the paired doc-block above the ctor definition) — the fold of
9671    // the last open-coded three-slot `{ from: String, module: String,
9672    // kinds: Vec<&'static str> }` struct-literal wire-up on
9673    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9674    // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9675    // cleanup-family dedup arm onto one substrate primitive. A byte-
9676    // mismatched ctor body would trip the equivalence pin first, ahead of
9677    // any downstream diagnostic-shape drift. Peer of the sibling
9678    // standalone-ctor equivalence + routing pins on the sibling one-off
9679    // variants across `UpgradeError`
9680    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9681    // paired three-slot `{ from, kind, module }` envelope for the sibling
9682    // load → cleanup ordering axis;
9683    // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9684    // the paired four-slot `{ from, script, prior_cleanup_kind,
9685    // prior_cleanup_module }` envelope for the migrate → cleanup
9686    // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9687    // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9688    // `:from` gate).
9689
9690    #[test]
9691    fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9692        // Equivalence pin: the ctor produces byte-equal
9693        // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9694        // three-field struct-literal on the same `(&str, &str,
9695        // Vec<&'static str>)` fixture. Guards any future field-addition /
9696        // reordering / string-conversion tweak on the variant. Same
9697        // equivalence-pin shape as the sibling
9698        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9699        // (9752da1) on the peer three-slot envelope.
9700        let from = "0.1.0";
9701        let module = "x-old";
9702        let kinds: Vec<&'static str> = vec![
9703            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9704            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9705        ];
9706        assert_eq!(
9707            UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9708            UpgradeError::DuplicateCleanup {
9709                from: from.to_string(),
9710                module: module.to_string(),
9711                kinds,
9712            },
9713            "generated duplicate_cleanup ctor must produce byte-equal \
9714             UpgradeError to the open-coded struct-literal wrap on the \
9715             same (&str, &str, Vec<&'static str>) fixture",
9716        );
9717    }
9718
9719    #[test]
9720    fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9721        // Cross-axis routing pin: sweep the three constructor input axes
9722        // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9723        // through distinct-per-axis fixtures across every ordered pair of
9724        // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9725        // four `(prior_kind, kind)` combinations `validate_cleanup_
9726        // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9727        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9728        // build-metadata, zero) + DNS-1123 module shapes (leaf,
9729        // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9730        // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9731        // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9732        // owned `Vec<&'static str>`) on the three-field construction
9733        // surfaces at assert time rather than at a downstream diagnostic
9734        // consumer that reads the fields back and gets a different value
9735        // than the one it stored. Both `&str`-literal and `&String` (via
9736        // Deref coercion) carriers are exercised for `from` / `module`
9737        // because the sole wire-up hands `self.prior_versao()` (a `&str`
9738        // accessor) and `module` (also `&str`, from `declared_module().
9739        // expect(…)`) — the ctor must accept both shapes without a
9740        // pre-conversion.
9741        let all_kinds: [&'static str; 2] = [
9742            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9743            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9744        ];
9745        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9746        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9747        for prior_kind in all_kinds {
9748            for kind in all_kinds {
9749                for from in froms {
9750                    for module in modules {
9751                        let from_owned: String = from.to_string();
9752                        let module_owned: String = module.to_string();
9753                        for (from_in, module_in) in
9754                            [(from, module), (from_owned.as_str(), module_owned.as_str())]
9755                        {
9756                            let kinds: Vec<&'static str> = vec![prior_kind, kind];
9757                            assert_eq!(
9758                                UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
9759                                UpgradeError::DuplicateCleanup {
9760                                    from: from.to_string(),
9761                                    module: module.to_string(),
9762                                    kinds,
9763                                },
9764                                "duplicate_cleanup must route from → from, \
9765                                 module → module, kinds → kinds in declared \
9766                                 field order verbatim on ({from:?}, \
9767                                 {module:?}, [{prior_kind:?}, {kind:?}])",
9768                            );
9769                        }
9770                    }
9771                }
9772            }
9773        }
9774    }
9775
9776    #[test]
9777    fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
9778        // End-to-end wire-up pin: build an entry whose declared
9779        // `:instructions` list front-loads a `:load-module` (so the
9780        // sibling `validate_purge_ordering` load → cleanup gate returns
9781        // `Ok(())` on the fixture) and then places two cleanup
9782        // instructions targeting the same module so
9783        // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9784        // cleanup-family dedup arm surfaces
9785        // `UpgradeError::DuplicateCleanup`, then pin that the observed
9786        // `Err` byte-equals the substrate-primitive
9787        // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
9788        // fixture. A future silent de-lift of the wire-up back to the
9789        // open-coded struct-literal (or a silent axis-swap on the three-
9790        // field construction at the wire-up site, or a kinds-pair
9791        // reorder) trips at caixa-core test time rather than at a
9792        // downstream diagnostic consumer far from the wire-up commit.
9793        // Same end-to-end-wire-up discipline as the sibling
9794        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9795        // on the peer load → cleanup ordering gate and
9796        // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
9797        // on the peer migrate → cleanup boundary; all three key off
9798        // exactly one typed dispatch on the substrate primitive.
9799        let cases: [(
9800            &str,
9801            UpgradeInstruction,
9802            UpgradeInstruction,
9803            &str,
9804            [&'static str; 2],
9805        ); 4] = [
9806            (
9807                "0.1.0",
9808                UpgradeInstruction::SoftPurge {
9809                    module: "hello-rio-old".into(),
9810                },
9811                UpgradeInstruction::SoftPurge {
9812                    module: "hello-rio-old".into(),
9813                },
9814                "hello-rio-old",
9815                [
9816                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9817                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9818                ],
9819            ),
9820            (
9821                "1.2.3-rc.1",
9822                UpgradeInstruction::Purge {
9823                    module: "cache-v2-ancient".into(),
9824                },
9825                UpgradeInstruction::Purge {
9826                    module: "cache-v2-ancient".into(),
9827                },
9828                "cache-v2-ancient",
9829                [
9830                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9831                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9832                ],
9833            ),
9834            (
9835                "0.2.0-alpha.7+build.5",
9836                UpgradeInstruction::SoftPurge {
9837                    module: "x-old".into(),
9838                },
9839                UpgradeInstruction::Purge {
9840                    module: "x-old".into(),
9841                },
9842                "x-old",
9843                [
9844                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9845                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9846                ],
9847            ),
9848            (
9849                "0.0.0",
9850                UpgradeInstruction::Purge {
9851                    module: "x-old".into(),
9852                },
9853                UpgradeInstruction::SoftPurge {
9854                    module: "x-old".into(),
9855                },
9856                "x-old",
9857                [
9858                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9859                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9860                ],
9861            ),
9862        ];
9863        for (from, first, second, module, kinds) in cases {
9864            let e = entry(
9865                from,
9866                vec![
9867                    UpgradeInstruction::LoadModule {
9868                        module: "hello-rio".into(),
9869                    },
9870                    first,
9871                    second,
9872                ],
9873            );
9874            let observed = e.validate().unwrap_err();
9875            assert_eq!(
9876                observed,
9877                UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
9878                "validate_cleanup_singularity must route its refusal \
9879                 through UpgradeError::duplicate_cleanup(from, module, \
9880                 kinds) on a two-cleanup {kinds:?} entry targeting the \
9881                 same module, byte-equal to the pre-lift open-coded \
9882                 struct-literal wrap on the same fixture",
9883            );
9884        }
9885    }
9886
9887    #[test]
9888    fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
9889        // Equivalence pin: the ctor produces byte-equal
9890        // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
9891        // three-field struct-literal on the same `(&str, usize,
9892        // Vec<&'static str>)` fixture. Guards any future field-addition /
9893        // reordering / string-conversion tweak on the variant. Same
9894        // equivalence-pin shape as the sibling
9895        // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
9896        // on the peer three-slot envelope.
9897        let from = "0.1.0";
9898        let restart_count: usize = 1;
9899        let other_kinds: Vec<&'static str> =
9900            vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
9901        assert_eq!(
9902            UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9903            UpgradeError::RestartNotExclusive {
9904                from: from.to_string(),
9905                restart_count,
9906                other_kinds,
9907            },
9908            "generated restart_not_exclusive ctor must produce byte-equal \
9909             UpgradeError to the open-coded struct-literal wrap on the \
9910             same (&str, usize, Vec<&'static str>) fixture",
9911        );
9912    }
9913
9914    #[test]
9915    fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
9916        // Cross-axis routing pin: sweep the three constructor input axes
9917        // (`from: &str`, `restart_count: usize`, `other_kinds:
9918        // Vec<&'static str>`) through distinct-per-axis fixtures across a
9919        // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
9920        // pre-release + build-metadata, zero) × non-degenerate
9921        // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
9922        // pure-duplication shape, 3 — the deeply-duplicated shape) ×
9923        // ordered `other_kinds` lisp-form lists spanning the four
9924        // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
9925        // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
9926        // empty (the `((:restart) (:restart))` shape), singleton
9927        // (`((:load-module …) (:restart))`), and the full typed sequence
9928        // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
9929        // …) (:restart))`) — so any wrapper-side silent lowercase / trim
9930        // / truncate / silent axis-swap (`from` ↔ swap onto
9931        // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
9932        // duplicate on the four-element owned `Vec<&'static str>`,
9933        // `other_kinds` reorder against declared instruction order) on
9934        // the three-field construction surfaces at assert time rather
9935        // than at a downstream diagnostic consumer that reads the fields
9936        // back and gets a different value than the one it stored. Both
9937        // `&str`-literal and `&String` (via Deref coercion) carriers are
9938        // exercised for `from` because the sole wire-up hands
9939        // `self.prior_versao()` (a `&str` accessor).
9940        let all_typed_kinds: [&'static str; 4] = [
9941            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9942            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9943            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9944            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9945        ];
9946        let other_kinds_matrix: [Vec<&'static str>; 3] =
9947            [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
9948        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9949        let restart_counts: [usize; 3] = [1, 2, 3];
9950        for other_kinds in &other_kinds_matrix {
9951            for restart_count in restart_counts {
9952                for from in froms {
9953                    let from_owned: String = from.to_string();
9954                    for from_in in [from, from_owned.as_str()] {
9955                        assert_eq!(
9956                            UpgradeError::restart_not_exclusive(
9957                                from_in,
9958                                restart_count,
9959                                other_kinds.clone(),
9960                            ),
9961                            UpgradeError::RestartNotExclusive {
9962                                from: from.to_string(),
9963                                restart_count,
9964                                other_kinds: other_kinds.clone(),
9965                            },
9966                            "restart_not_exclusive must route from → from, \
9967                             restart_count → restart_count, other_kinds → \
9968                             other_kinds in declared field order verbatim \
9969                             on ({from:?}, {restart_count:?}, \
9970                             {other_kinds:?})",
9971                        );
9972                    }
9973                }
9974            }
9975        }
9976    }
9977
9978    #[test]
9979    fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
9980        // End-to-end wire-up pin: sweep the three canonical exclusivity-
9981        // violation shapes the `validate_restart_exclusive` gate can
9982        // refuse — restart + one typed instruction (`restart_count: 1,
9983        // other_kinds: [load-module]`), restart + full typed sequence
9984        // (`restart_count: 1, other_kinds: [load-module, state-change,
9985        // soft-purge, purge]`), and duplicated restart only
9986        // (`restart_count: 2, other_kinds: []`) — and pin that each
9987        // observed `Err` byte-equals the substrate-primitive
9988        // [`UpgradeError::restart_not_exclusive`] ctor's output on the
9989        // same fixture. A future silent de-lift of the wire-up back to
9990        // the open-coded struct-literal (or a silent axis-swap on the
9991        // three-field construction at the wire-up site, or an
9992        // `other_kinds` reorder / drop) trips at caixa-core test time
9993        // rather than at a downstream diagnostic consumer far from the
9994        // wire-up commit. Same end-to-end-wire-up discipline as the
9995        // sibling
9996        // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
9997        // (10a5b48) on the peer per-module cleanup-singularity axis and
9998        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9999        // on the peer load → cleanup ordering gate; all three key off
10000        // exactly one typed dispatch on the substrate primitive.
10001        let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
10002            (
10003                "0.1.0",
10004                vec![
10005                    UpgradeInstruction::LoadModule {
10006                        module: "hello-rio".into(),
10007                    },
10008                    UpgradeInstruction::Restart,
10009                ],
10010                1,
10011                vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
10012            ),
10013            (
10014                "1.2.3-rc.1",
10015                vec![
10016                    UpgradeInstruction::LoadModule {
10017                        module: "hello-rio".into(),
10018                    },
10019                    UpgradeInstruction::StateChange {
10020                        script: PathBuf::from("lib/m.lisp"),
10021                    },
10022                    UpgradeInstruction::SoftPurge {
10023                        module: "hello-rio-old".into(),
10024                    },
10025                    UpgradeInstruction::Purge {
10026                        module: "hello-rio-old".into(),
10027                    },
10028                    UpgradeInstruction::Restart,
10029                ],
10030                1,
10031                vec![
10032                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10033                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10034                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10035                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10036                ],
10037            ),
10038            (
10039                "0.0.0",
10040                vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
10041                2,
10042                vec![],
10043            ),
10044        ];
10045        for (from, instructions, restart_count, other_kinds) in cases {
10046            let e = entry(from, instructions);
10047            let observed = e.validate().unwrap_err();
10048            assert_eq!(
10049                observed,
10050                UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10051                "validate_restart_exclusive must route its refusal \
10052                 through UpgradeError::restart_not_exclusive(from, \
10053                 restart_count, other_kinds) on a mixed-`(:restart)` \
10054                 entry, byte-equal to the pre-lift open-coded struct-\
10055                 literal wrap on the same fixture",
10056            );
10057        }
10058    }
10059
10060    #[test]
10061    fn module_invalid_ctor_matches_struct_literal_wrap() {
10062        // Fail-before-pass-after equivalence pin on
10063        // [`UpgradeError::module_invalid`] — the constructor must
10064        // produce a byte-equal `UpgradeError` to the pre-lift open-
10065        // coded `Self::ModuleInvalid { kind, module: module.to_string(),
10066        // reason }` struct-literal on the same `(:load-module …)` /
10067        // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
10068        // mismatched constructor body (a stray `.trim()`, a rebased
10069        // field order, a `String::new()` reason substitution) would
10070        // trip this pin first, byte-for-byte against the sibling
10071        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
10072        // [`crate::SupervisorError::child_caixa_invalid`] /
10073        // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
10074        // discipline on the peer three-slot `{ *, reason: String }`
10075        // invalid-arm ctor family.
10076        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10077        let module = "Hello-Rio";
10078        let reason = "must be lowercase alphanumeric or `-`";
10079        assert_eq!(
10080            UpgradeError::module_invalid(kind, module, reason),
10081            UpgradeError::ModuleInvalid {
10082                kind,
10083                module: module.to_string(),
10084                reason: reason.to_string(),
10085            },
10086            "generated module_invalid ctor must produce byte-equal \
10087             UpgradeError to the open-coded struct-literal wrap on the \
10088             same (kind, module, reason) fixture",
10089        );
10090    }
10091
10092    #[test]
10093    fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10094        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10095        // input across every [`UpgradeInstruction::declared_module`]-
10096        // bearing variant's canonical
10097        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10098        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10099        // canonical `":phantom"` fourth arm proving the ctor does not
10100        // silently clamp `kind` to the three-arm roster. The
10101        // `reason: impl Into<String>` bound accepts both `&str`
10102        // literals and the [`String`] the underlying
10103        // [`crate::render::is_dns_1123_label`] predicate returns via
10104        // `.into()`, matching the peer
10105        // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
10106        // sweep on the sibling `:contratos` per-edge envelope.
10107        let module = "Hello-Rio";
10108        let reason = "must be lowercase alphanumeric or `-`";
10109        for kind in [
10110            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10111            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10112            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10113            ":phantom",
10114        ] {
10115            assert_eq!(
10116                UpgradeError::module_invalid(kind, module, reason),
10117                UpgradeError::ModuleInvalid {
10118                    kind,
10119                    module: module.to_string(),
10120                    reason: reason.to_string(),
10121                },
10122                "module_invalid ctor must thread kind={kind:?} verbatim",
10123            );
10124        }
10125    }
10126
10127    #[test]
10128    fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
10129        // End-to-end wire-up pin: [`validate_module`]'s
10130        // [`crate::render::require_valid_dns_1123_label`] invalid-arm
10131        // must emit a diagnostic byte-equal to the ctor's output on the
10132        // same `(kind, module)` fixture — the fold's invariant that
10133        // [`validate_module`]'s cascade reaches the
10134        // [`UpgradeError::ModuleInvalid`] envelope through the
10135        // substrate primitive [`UpgradeError::module_invalid`] rather
10136        // than the pre-lift open-coded struct-literal. Sweep every
10137        // [`UpgradeInstruction::declared_module`]-bearing variant
10138        // against a canonical footgun (`"Hello-Rio"` — the uppercase-
10139        // lead footgun the peer `validate_rejects_non_dns_1123_module`
10140        // test above already carries) so every wire-up on the invalid-
10141        // arm cascade lands on the ctor's output. Matches the peer
10142        // sibling end-to-end pin
10143        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
10144        // carries on `validate_contrato_caixa`'s
10145        // `require_valid_dns_1123_label` invalid-arm.
10146        let module = "Hello-Rio";
10147        let cases: &[(UpgradeInstruction, &'static str)] = &[
10148            (
10149                UpgradeInstruction::LoadModule {
10150                    module: module.to_string(),
10151                },
10152                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10153            ),
10154            (
10155                UpgradeInstruction::SoftPurge {
10156                    module: module.to_string(),
10157                },
10158                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10159            ),
10160            (
10161                UpgradeInstruction::Purge {
10162                    module: module.to_string(),
10163                },
10164                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10165            ),
10166        ];
10167        for (instr, expected_kind) in cases {
10168            let observed = instr.validate().unwrap_err();
10169            let UpgradeError::ModuleInvalid {
10170                reason: observed_reason,
10171                ..
10172            } = &observed
10173            else {
10174                panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
10175            };
10176            assert_eq!(
10177                observed,
10178                UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
10179                "validate_module must route its invalid-arm refusal \
10180                 through UpgradeError::module_invalid(kind, module, \
10181                 reason) on {instr:?}, byte-equal to the pre-lift open-\
10182                 coded struct-literal wrap on the same fixture",
10183            );
10184        }
10185    }
10186
10187    #[test]
10188    fn module_empty_ctor_matches_struct_literal_wrap() {
10189        // Fail-before-pass-after equivalence pin on
10190        // [`UpgradeError::module_empty`] — the constructor must produce
10191        // a byte-equal `UpgradeError` to the pre-lift open-coded
10192        // `Self::ModuleEmpty { kind }` struct-literal on the same
10193        // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10194        // axis-tag fixture. A byte-mismatched constructor body (a stray
10195        // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10196        // one of the three canonical arms, a fixed-slot substitution)
10197        // would trip this pin first, matching the sibling
10198        // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10199        // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10200        // pin discipline on the peer one-slot `{ *: &'static str }`
10201        // empty-arm ctor family.
10202        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10203        assert_eq!(
10204            UpgradeError::module_empty(kind),
10205            UpgradeError::ModuleEmpty { kind },
10206            "generated module_empty ctor must produce byte-equal \
10207             UpgradeError to the open-coded struct-literal wrap on the \
10208             same kind fixture",
10209        );
10210    }
10211
10212    #[test]
10213    fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10214        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10215        // input across every [`UpgradeInstruction::declared_module`]-
10216        // bearing variant's canonical
10217        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10218        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10219        // canonical `":phantom"` fourth arm proving the ctor does not
10220        // silently clamp `kind` to the three-arm roster (a future
10221        // fourth `declared_module`-bearing `UpgradeInstruction` variant
10222        // lands on this ctor without a per-arm rewrite). Matches the
10223        // sibling [`Self::module_invalid`] cross-axis sweep at
10224        // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10225        // on the paired three-slot invalid-arm envelope so both arms of
10226        // the [`validate_module`] two-closure cascade carry the same
10227        // axis-invariance guarantee.
10228        for kind in [
10229            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10230            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10231            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10232            ":phantom",
10233        ] {
10234            assert_eq!(
10235                UpgradeError::module_empty(kind),
10236                UpgradeError::ModuleEmpty { kind },
10237                "module_empty ctor must thread kind={kind:?} verbatim",
10238            );
10239        }
10240    }
10241
10242    #[test]
10243    fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10244        // End-to-end wire-up pin: [`validate_module`]'s
10245        // [`crate::render::require_valid_dns_1123_label`] empty-arm
10246        // must emit a diagnostic byte-equal to the ctor's output on the
10247        // same `(kind, "")` fixture — the fold's invariant that
10248        // [`validate_module`]'s cascade reaches the
10249        // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10250        // primitive [`UpgradeError::module_empty`] rather than the
10251        // pre-lift open-coded struct-literal. Sweep every
10252        // [`UpgradeInstruction::declared_module`]-bearing variant
10253        // against the empty-string module value so every wire-up on the
10254        // empty-arm cascade lands on the ctor's output. Closes the pair
10255        // on the [`validate_module`] two-closure cascade the sibling
10256        // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10257        // (3d0d64a) already anchors on the invalid-arm.
10258        let cases: &[(UpgradeInstruction, &'static str)] = &[
10259            (
10260                UpgradeInstruction::LoadModule {
10261                    module: String::new(),
10262                },
10263                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10264            ),
10265            (
10266                UpgradeInstruction::SoftPurge {
10267                    module: String::new(),
10268                },
10269                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10270            ),
10271            (
10272                UpgradeInstruction::Purge {
10273                    module: String::new(),
10274                },
10275                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10276            ),
10277        ];
10278        for (instr, expected_kind) in cases {
10279            assert_eq!(
10280                instr.validate().unwrap_err(),
10281                UpgradeError::module_empty(expected_kind),
10282                "validate_module must route its empty-arm refusal \
10283                 through UpgradeError::module_empty(kind) on {instr:?}, \
10284                 byte-equal to the pre-lift open-coded struct-literal \
10285                 wrap on the same fixture",
10286            );
10287        }
10288    }
10289
10290    /// Fixture roster covering every [`UpgradeInstruction`] arm — a
10291    /// concrete-instance witness per variant so the four
10292    /// canonical-projection-triple pin tests below sweep the same five
10293    /// arms without duplicating the arm-shape declaration at each
10294    /// probe site. A future arm addition (a `Discard` peer the
10295    /// `code:delete/1` analog might inspire, a `SoftPurge` split into
10296    /// `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-down policy
10297    /// grows a two-arm shape) extends this fixture list as a single
10298    /// edit; the pin sweeps below then reach the new arm by iteration
10299    /// rather than a hand-authored per-arm probe.
10300    fn upgrade_instruction_arm_roster() -> Vec<(UpgradeInstruction, &'static str)> {
10301        vec![
10302            (
10303                UpgradeInstruction::LoadModule {
10304                    module: "hello-rio".into(),
10305                },
10306                "load-module",
10307            ),
10308            (
10309                UpgradeInstruction::StateChange {
10310                    script: std::path::PathBuf::from("lib/migrations/v01-to-v02.lisp"),
10311                },
10312                "state-change",
10313            ),
10314            (
10315                UpgradeInstruction::SoftPurge {
10316                    module: "hello-rio-old".into(),
10317                },
10318                "soft-purge",
10319            ),
10320            (
10321                UpgradeInstruction::Purge {
10322                    module: "hello-rio-old".into(),
10323                },
10324                "purge",
10325            ),
10326            (UpgradeInstruction::Restart, "restart"),
10327        ]
10328    }
10329
10330    #[test]
10331    fn upgrade_instruction_as_str_returns_canonical_kebab_wire_bytes() {
10332        // Fail-before-pass-after pin on the [`UpgradeInstruction::as_str`]
10333        // canonical-projection accessor: the five match arms each return
10334        // the un-prefixed kebab wire byte-string every serde-carried CR /
10335        // structured-log / fleet-catalog identity consumer converges onto.
10336        // A future variant rename or a per-arm typo (e.g. dropping the
10337        // hyphen from `"load-module"` → `"loadmodule"`) trips at
10338        // caixa-core test time rather than surfacing as a downstream K8s-
10339        // CR round-trip miss where the paired `Deserialize` derive
10340        // rejects the drifted arm on every apply.
10341        for (variant, expected) in upgrade_instruction_arm_roster() {
10342            assert_eq!(
10343                variant.as_str(),
10344                expected,
10345                "UpgradeInstruction::{variant:?}.as_str() must return the \
10346                 canonical un-prefixed kebab wire byte-string"
10347            );
10348        }
10349    }
10350
10351    #[test]
10352    fn upgrade_instruction_as_str_matches_discriminant_derive() {
10353        // Load-bearing pin on the two-source alignment: the hand-authored
10354        // [`UpgradeInstruction::as_str`] match arms must byte-equal the
10355        // [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
10356        // per-arm output for every variant. `.discriminant()` is the
10357        // fleet-wide dispatcher-catalog identity (registered under
10358        // `"caixa.upgrade-instruction"` by the sibling
10359        // `gen_platform::register_dispatcher!` macro invocation at
10360        // upgrade.rs:88); [`Self::as_str`] is the standard-library
10361        // `AsRef<str>` / [`std::fmt::Display`]-routed diagnostic byte-
10362        // string. Both must stay aligned so a consumer that reaches
10363        // through either path lands on the same per-arm byte-string.
10364        // A future rename on either side (a per-arm serde-attribute
10365        // drift silently splitting the derive's kebab output from the
10366        // hand-authored arms, a hand-authored typo on the [`Self::as_str`]
10367        // match arm silently splitting the standard-library-routed path
10368        // from the catalog identity) trips here at caixa-core test time
10369        // rather than as a divergent per-consumer dispatch at some future
10370        // downstream site.
10371        for (variant, _expected) in upgrade_instruction_arm_roster() {
10372            assert_eq!(
10373                variant.as_str(),
10374                variant.discriminant(),
10375                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10376                 the gen_platform::Discriminant-derived discriminant() \
10377                 output — the two axes are the substrate's kebab-case wire \
10378                 identity and must stay aligned by construction"
10379            );
10380        }
10381    }
10382
10383    #[test]
10384    fn upgrade_instruction_as_str_matches_serialize_wire_kind_tag() {
10385        // Load-bearing pin on the derive-to-hand alignment on the *wire*
10386        // axis: the hand-authored [`UpgradeInstruction::as_str`] match
10387        // arms must byte-equal the JSON tag the un-`rename`d
10388        // `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
10389        // emits under the paired
10390        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag key.
10391        // A future accidental `rename_all = "snake_case"` /
10392        // `"UPPERCASE"` attribute drift at the derive surface, or a
10393        // per-variant `#[serde(rename = "…")]` overlay silently
10394        // targeting one arm, would silently split the wire byte-shape
10395        // every K8s-CR / tatara-lisp round-trip / fleet-catalog
10396        // consumer reads through the two paths — pinning the identity
10397        // here makes any such drift a caixa-core-test-time failure.
10398        // Sibling in shape to
10399        // [`crate::kind::tests::caixa_kind_wire_name_matches_serialize_wire_byte_string`]
10400        // on the top-level [`crate::CaixaKind`] axis.
10401        for (variant, _expected) in upgrade_instruction_arm_roster() {
10402            let json = serde_json::to_value(&variant).expect("serialize must succeed");
10403            let kind_tag = json
10404                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
10405                .and_then(serde_json::Value::as_str)
10406                .unwrap_or_else(|| {
10407                    panic!(
10408                        "serialized UpgradeInstruction::{variant:?} must \
10409                         carry the M2_UPGRADE_INSTRUCTION_KEY_KIND tag as \
10410                         a JSON string"
10411                    )
10412                });
10413            assert_eq!(
10414                variant.as_str(),
10415                kind_tag,
10416                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10417                 the serde-derived JSON \"kind\" tag — a mismatch means \
10418                 either the derive attributes drifted or the as_str match \
10419                 arms drifted; either way downstream K8s-CR round-trip \
10420                 silently splits from the accessor-routed source of truth"
10421            );
10422        }
10423    }
10424
10425    #[test]
10426    fn upgrade_instruction_display_routes_through_as_str_helper() {
10427        // Fail-before-pass-after pin on the two-path convergence: pre-
10428        // lift [`UpgradeInstruction`] carried no [`std::fmt::Display`]
10429        // surface at all — every consumer past the wire format had to
10430        // pick between [`Self::lisp_form`] returning the tatara-lisp
10431        // author-surface with `:` prefix or `format!("{v:?}")` on the
10432        // `Debug` derive returning the PascalCase variant name plus
10433        // struct-literal fields. Wiring [`std::fmt::Display`] through
10434        // [`Self::as_str`] closes the drift footgun: every
10435        // `format!("{v}")` call reaches the same kebab wire byte-string
10436        // the [`Self::as_str`] helper returns, so a future variant
10437        // rename lands at exactly one place. Pin the routing here so a
10438        // future `impl std::fmt::Display for UpgradeInstruction`
10439        // reimplementation that hand-rolls the arms instead of
10440        // delegating to [`Self::as_str`] fails at caixa-core build
10441        // time. Peer of the sibling
10442        // [`crate::supervisor::tests::restart_strategy_display_routes_through_as_str_helper`]
10443        // /
10444        // [`crate::supervisor::tests::restart_policy_display_routes_through_as_str_helper`]
10445        // /
10446        // [`crate::kind::tests::caixa_kind_display_routes_through_as_str_helper`]
10447        // pins on the sibling closed-set typed-enum discriminator axes.
10448        for (variant, _expected) in upgrade_instruction_arm_roster() {
10449            assert_eq!(
10450                variant.to_string(),
10451                variant.as_str(),
10452                "UpgradeInstruction::{variant:?} Display must route \
10453                 through UpgradeInstruction::as_str (single source of \
10454                 truth: the kebab wire byte-string per arm)"
10455            );
10456        }
10457    }
10458
10459    #[test]
10460    fn upgrade_instruction_display_matches_as_str_and_not_lisp_form() {
10461        // Two-axis-split pin: the tatara-lisp author-surface form
10462        // ([`UpgradeInstruction::lisp_form`], with `:` prefix) and the
10463        // wire form ([`UpgradeInstruction::as_str`], without `:`
10464        // prefix) are structurally distinct by design. The pin here
10465        // makes the split load-bearing: a future accidental collapse
10466        // of either axis onto the other (routing `Display` through
10467        // [`Self::lisp_form`] via a mistaken match-arm re-inlining, or
10468        // routing [`Self::lisp_form`] through [`Self::as_str`] and
10469        // dropping the `:` prefix) would trip here at caixa-core
10470        // build time rather than silently merging the two axes at
10471        // some future consumer's per-instruction dispatch step. Peer
10472        // of the sibling
10473        // [`crate::kind::tests::caixa_kind_display_matches_as_str_and_not_serialize_wire`]
10474        // pin on the top-level [`crate::CaixaKind`] two-axis surface.
10475        for (variant, _expected) in upgrade_instruction_arm_roster() {
10476            let display = variant.to_string();
10477            let lisp = variant.lisp_form();
10478            assert_eq!(
10479                display,
10480                variant.as_str(),
10481                "UpgradeInstruction::{variant:?} Display must byte-equal \
10482                 as_str (kebab wire form, no `:` prefix)"
10483            );
10484            assert_ne!(
10485                display, lisp,
10486                "UpgradeInstruction::{variant:?} Display / as_str (wire \
10487                 kebab form) must stay structurally distinct from \
10488                 lisp_form (tatara-lisp author-surface with `:` prefix) — \
10489                 collapsing the two axes would break the tatara-lisp \
10490                 grep-and-fix workflow that keys off the `:` prefix"
10491            );
10492            assert!(
10493                lisp.starts_with(':'),
10494                "UpgradeInstruction::{variant:?}.lisp_form() must open \
10495                 with a `:` prefix (tatara-lisp author-surface form)"
10496            );
10497            assert!(
10498                !display.starts_with(':'),
10499                "UpgradeInstruction::{variant:?} Display must not open \
10500                 with a `:` prefix (wire form is un-prefixed kebab-case)"
10501            );
10502        }
10503    }
10504
10505    #[test]
10506    fn upgrade_instruction_as_ref_str_routes_through_as_str_accessor() {
10507        // Byte-parity pin on the standard-library `impl AsRef<str>`
10508        // route: every arm's `<UpgradeInstruction as
10509        // AsRef<str>>::as_ref(&v)` must byte-equal `v.as_str()`. Any
10510        // future silent detour that routes the impl through a
10511        // divergent projection (a per-arm inline `match self { … }`
10512        // re-inlining that opens a compile-time link to the un-lifted
10513        // arm-literal, a swap onto [`Self::lisp_form`] that would
10514        // collide the wire axis with the tatara-lisp author-surface
10515        // axis) trips here at caixa-core test time rather than at a
10516        // downstream `impl AsRef<str>`-bound consumer's silent split.
10517        // Peer of the sibling
10518        // [`crate::supervisor::tests::restart_strategy_as_ref_str_routes_through_as_str_accessor`]
10519        // /
10520        // [`crate::kind::tests::caixa_kind_as_ref_str_routes_through_as_str_accessor`]
10521        // pins.
10522        for (variant, _expected) in upgrade_instruction_arm_roster() {
10523            assert_eq!(
10524                <UpgradeInstruction as AsRef<str>>::as_ref(&variant),
10525                variant.as_str(),
10526                "UpgradeInstruction::{variant:?} AsRef<str> must route \
10527                 through UpgradeInstruction::as_str"
10528            );
10529        }
10530    }
10531
10532    #[test]
10533    fn upgrade_instruction_as_str_is_const_fn() {
10534        // Const-context pin: [`UpgradeInstruction::as_str`] must remain
10535        // `const fn`. Downstream consumers reaching for the accessor
10536        // from a `const` context (a module-scope `const _:() =
10537        // assert!(<variant>.as_str().len() > 0)` invariant pin, a
10538        // `const fn` per-instruction wire-shape audit table an M4
10539        // admission webhook materializes at build time) rely on the
10540        // const-ness. A future accidental downgrade to non-`const`
10541        // (an added runtime helper reachable only from a non-`const`
10542        // context, a manual hand-rolled `impl` that shadows this
10543        // method) trips at caixa-core build time rather than
10544        // surfacing as a downstream `const`-context regression far
10545        // from the accessor declaration. Peer of the sibling
10546        // [`crate::kind::tests::caixa_kind_wire_name_is_const_fn`] pin
10547        // on the top-level [`crate::CaixaKind`] axis.
10548        const RESTART_WIRE: &str = UpgradeInstruction::Restart.as_str();
10549        assert_eq!(RESTART_WIRE, "restart");
10550    }
10551}