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 exhaustive accept-set on the OTP-appup
1616    /// tatara-lisp author-surface form axis — the closed five-arm
1617    /// roster of every `:` -prefixed instruction kind tag
1618    /// [`Self::lisp_form`] emits, routed byte-for-byte through the
1619    /// paired [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`]
1620    /// / [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1621    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1622    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1623    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] lifted
1624    /// `pub const` roster the [`Self::lisp_form`] emitter walks.
1625    ///
1626    /// The fieldless-enum peer discipline [`crate::CaixaKind::ALL`] /
1627    /// [`crate::supervisor::RestartStrategy::ALL`] /
1628    /// [`crate::supervisor::RestartPolicy::ALL`] /
1629    /// [`crate::aplicacao::PlacementStrategy::ALL`] /
1630    /// [`crate::aplicacao::RateLimitUnit::ALL`] /
1631    /// [`crate::dep::DepList::ALL`] /
1632    /// [`crate::dialeto::CaixaDialeto::ALL`] carry as `&'static [Self]`
1633    /// exhaustive-iteration surfaces cannot land on this enum
1634    /// verbatim: [`UpgradeInstruction`] is a discriminated union
1635    /// carrying per-variant data ([`String`] `:module` on the
1636    /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1637    /// arms, [`std::path::PathBuf`] `:script` on [`Self::StateChange`]),
1638    /// so a `&'static [Self]` roster would demand static-lifetime
1639    /// dummy instances at build time that leak the "no canonical
1640    /// value" defect at every consumer. The closed set that *is*
1641    /// exhaustively enumerable on this enum is the per-arm lisp-form
1642    /// tag byte-string — the discriminant axis. Lifting it here as
1643    /// `&'static [&'static str]` closes the exhaustive-iteration
1644    /// surface on the axis that admits one, matching the peer
1645    /// fieldless-enum discipline through the discriminator projection
1646    /// rather than the variant enumeration.
1647    ///
1648    /// Consumers today (and future): an M4 `mesh.pleme.io/v1alpha1/Caixa`
1649    /// CR admission-webhook rejection body naming the accepted
1650    /// `:upgrade-from :instructions (…)` kind-tag set verbatim, a
1651    /// future `feira lint --upgrade-from` per-instruction author-time
1652    /// audit surface listing accepted tags on an unknown-tag miss, a
1653    /// future `caixa-actions` renderer that surfaces the accepted
1654    /// appup instruction vocabulary in a workflow annotation, an LSP
1655    /// hover completion source that offers the accepted-tag set on a
1656    /// partial `:upgrade-from :instructions (` author position — every
1657    /// consumer that wants to enumerate the closed OTP-appup
1658    /// kind-tag set outside caixa-core now reaches for one lifted
1659    /// substrate-primitive roster rather than open-coding a
1660    /// `[":load-module", ":state-change", ":soft-purge", ":purge",
1661    /// ":restart"]` array-literal whose arm-set has no compile-time
1662    /// link back to the typed [`UpgradeInstruction`] enum. A future
1663    /// variant addition (a `Discard` peer the `code:delete/1` analog
1664    /// might inspire, a `SoftPurge` split into `SoftPurgeCoop` /
1665    /// `SoftPurgeForce` as the drain-cool-down policy grows a two-arm
1666    /// shape) extends this roster as a single edit — paired with the
1667    /// [`Self::lisp_form`] match's compiler-checked exhaustiveness on
1668    /// the new arm — and every consumer picks up the new tag by
1669    /// construction rather than a coordinated array-literal rewrite
1670    /// across every downstream site.
1671    ///
1672    /// Distinct axis from the un-prefixed kebab wire-form
1673    /// [`Self::as_str`] emits (`"load-module"` / `"state-change"` /
1674    /// `"soft-purge"` / `"purge"` / `"restart"` — the serde-carried
1675    /// JSON `"kind"` tag and the fleet-wide dispatcher-catalog identity
1676    /// under `"caixa.upgrade-instruction"`): the two-axis split the
1677    /// sibling
1678    /// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
1679    /// pin already makes load-bearing is preserved here by
1680    /// construction — this roster lands on the tatara-lisp author-
1681    /// surface form (`:` -prefixed) that every `feira lint`
1682    /// diagnostic and per-arm [`UpgradeError`] `list:` payload
1683    /// carries verbatim, not the kebab wire byte-string. A future
1684    /// author-facing rebrand (an Elixir/Phoenix hot-reload
1685    /// convergence collapsing `:load-module` under `:reload`, an M4-
1686    /// side rename of `:state-change` onto Erlang's own `code_change/3`
1687    /// verbatim) lands at one match arm in [`Self::lisp_form`] plus
1688    /// one edit to the corresponding
1689    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const, and this
1690    /// roster (routed through the same consts) migrates in lockstep.
1691    ///
1692    /// Length is pinned load-bearing at 5 by
1693    /// [`tests::upgrade_instruction_lisp_forms_covers_every_arm`] via
1694    /// the shared `upgrade_instruction_arm_roster()` fixture, and
1695    /// every entry is pinned to a member of the roster on every arm
1696    /// so a silent skew between the [`Self::lisp_form`] match's
1697    /// arm-set and this const's arm-set trips at caixa-core test time
1698    /// rather than at a downstream admission-webhook rejection body's
1699    /// accepted-set enumeration miss.
1700    pub const LISP_FORMS: &'static [&'static str] = &[
1701        crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1702        crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1703        crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1704        crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1705        crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1706    ];
1707
1708    /// Substrate-canonical exhaustive accept-set on the OTP-appup
1709    /// peer wire-form axis — the closed five-arm roster of every
1710    /// un-prefixed kebab-case byte-string [`Self::as_str`] emits,
1711    /// routed byte-for-byte through the paired
1712    /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE`] /
1713    /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE`] /
1714    /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE`] /
1715    /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE`] /
1716    /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART`] lifted
1717    /// `pub const` roster the [`Self::as_str`] emitter walks — and
1718    /// byte-for-byte the same five strings the
1719    /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
1720    /// carries on every K8s-CR JSON / YAML round-trip and the
1721    /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1722    /// returns for fleet-wide dispatcher-catalog registration under
1723    /// `"caixa.upgrade-instruction"`.
1724    ///
1725    /// Peer to the sibling [`Self::LISP_FORMS`] roster on the
1726    /// tatara-lisp author-surface form axis (`:` -prefixed): the
1727    /// two-axis split the sibling
1728    /// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
1729    /// pin already makes load-bearing on the scalar-accessor pair is
1730    /// now extended onto the roster pair. `LISP_FORMS` / `WIRE_FORMS`
1731    /// carry per-arm byte-strings that are byte-distinct by design
1732    /// (one opens with `:`, the other does not); the paired
1733    /// [`tests::upgrade_instruction_wire_forms_covers_every_arm`] and
1734    /// [`tests::upgrade_instruction_lisp_and_wire_forms_are_length_aligned`]
1735    /// pins keep the two rosters in structural lockstep so a variant
1736    /// addition on either axis trips at caixa-core test time rather
1737    /// than silently splitting the two rosters at some downstream
1738    /// consumer's accepted-set enumeration.
1739    ///
1740    /// Consumers today (and future): an M4
1741    /// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection
1742    /// body enumerating the accepted wire-form `"kind"` tag-set
1743    /// verbatim on an unknown-tag miss (the un-prefixed kebab bytes
1744    /// the JSON payload carries, not the `:` -prefixed tatara-lisp
1745    /// form), a future `caixa-actions` renderer that surfaces the
1746    /// accepted appup instruction wire-vocabulary in a workflow
1747    /// annotation the CI pipeline reads verbatim, a fleet-side
1748    /// operator's per-cluster catalog enumeration listing the
1749    /// registered dispatcher identities under
1750    /// `"caixa.upgrade-instruction"` (each dispatcher's identity
1751    /// being a member of this roster by construction of the paired
1752    /// `.discriminant()` derive), an M4-side per-instruction structured
1753    /// log audit table keyed off the wire-form byte-string — every
1754    /// consumer that wants to enumerate the closed OTP-appup wire
1755    /// kind-tag set outside caixa-core now reaches for one lifted
1756    /// substrate-primitive roster rather than open-coding a
1757    /// `["load-module", "state-change", "soft-purge", "purge",
1758    /// "restart"]` array-literal whose arm-set has no compile-time
1759    /// link back to the typed [`UpgradeInstruction`] enum. A future
1760    /// variant addition (a `Discard` peer the `code:delete/1` analog
1761    /// might inspire, a `SoftPurge` split into `SoftPurgeCoop` /
1762    /// `SoftPurgeForce` peers as the drain-cool-down policy grows a
1763    /// two-arm shape) extends this roster as a single edit — paired
1764    /// with the [`Self::as_str`] match's compiler-checked
1765    /// exhaustiveness on the new arm — and every consumer picks up
1766    /// the new tag by construction rather than a coordinated
1767    /// array-literal rewrite across every downstream site.
1768    ///
1769    /// Length is pinned load-bearing at 5 by
1770    /// [`tests::upgrade_instruction_wire_forms_covers_every_arm`] via
1771    /// the shared `upgrade_instruction_arm_roster()` fixture, and
1772    /// every entry is pinned to a member of the roster on every arm
1773    /// so a silent skew between the [`Self::as_str`] match's arm-set
1774    /// and this const's arm-set trips at caixa-core test time rather
1775    /// than at a downstream admission-webhook rejection body's
1776    /// accepted-set enumeration miss. The paired
1777    /// [`tests::upgrade_instruction_lisp_and_wire_forms_are_length_aligned`]
1778    /// pin further gates the peer-axis alignment.
1779    pub const WIRE_FORMS: &'static [&'static str] = &[
1780        crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
1781        crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
1782        crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
1783        crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
1784        crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
1785    ];
1786
1787    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup kind-tag
1788    /// projection every consumer that renders / classifies / grepping-
1789    /// projects an instruction's lisp form keys off — returns the
1790    /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1791    /// straight through the paired
1792    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1793    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1794    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1795    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1796    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1797    /// roster the substrate already carries at the wire-form axis.
1798    ///
1799    /// Consumers today: [`Self::validate`] threads the label through the
1800    /// per-variant [`UpgradeError::ModuleEmpty`] /
1801    /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1802    /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1803    /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1804    /// `(:purge …)` and fix it in one edit; every within-entry cross-
1805    /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1806    /// same accessor's `&'static str` return in place of hand-rolling
1807    /// the per-arm match.
1808    ///
1809    /// Promoted from `pub(self)` to `pub`: every future consumer that
1810    /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1811    /// by its OTP-appup lisp form outside caixa-core — a deferred
1812    /// wasm-operator `install_release/1` per-instruction dispatch
1813    /// logger tagging each executed instruction under its kebab-case
1814    /// kind, a `feira lint --upgrade-from` per-instruction author-time
1815    /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1816    /// webhook naming the offending instruction's kind in its rejection
1817    /// body, a future `caixa-actions` renderer that surfaces the
1818    /// declared appup instruction list in a workflow annotation, an
1819    /// LSP hover projecting the per-instruction kind onto a text-
1820    /// document diagnostic — reaches this projection through one call
1821    /// on the substrate primitive rather than open-coding the same
1822    /// five-arm match plus per-arm const imports at every consumer.
1823    /// A future variant addition (a `Discard` peer the `code:delete/1`
1824    /// analog inspires, an M4 `SoftPurge` split into
1825    /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1826    /// policy grows a two-arm shape) reaches every consumer at one edit
1827    /// — this method's match — rather than fanning out through hand-
1828    /// rolled per-arm dispatch across every downstream site.
1829    ///
1830    /// Peer of the sibling substrate-canonical arm-family accessors on
1831    /// the same closed-set enum: [`Self::declared_module`] on the
1832    /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1833    /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1834    /// [`Self::StateChange`] / [`Self::Restart`]),
1835    /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1836    /// (`Some(_)` for [`Self::StateChange`]), and
1837    /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1838    /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1839    /// generated per-variant `is_*` predicate family — every downstream
1840    /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1841    /// one typed dispatch on the substrate primitive rather than open-
1842    /// coding a per-arm match.
1843    ///
1844    /// `const fn` preserves the zero-runtime-work property of the pre-
1845    /// promotion body verbatim, and the `&'static str` return (not
1846    /// `&str` tied to `&self`'s lifetime) matches the paired
1847    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1848    /// program-lifetime discipline so callers can stash the returned
1849    /// label in `&'static`-bounded positions (a static logger's format
1850    /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1851    /// slice-of-`&'static str` accept-set) without re-borrowing through
1852    /// the instruction reference. Named `lisp_form` (not `kind_label` /
1853    /// `discriminant_label`) to name the axis the substrate already
1854    /// reaches for in the paired
1855    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1856    /// in every per-arm `UpgradeError` diagnostic that carries the
1857    /// kebab-case tag verbatim — the lisp author-surface term, not the
1858    /// Rust discriminant name.
1859    #[must_use]
1860    pub const fn lisp_form(&self) -> &'static str {
1861        match self {
1862            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1863            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1864            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1865            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1866            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1867        }
1868    }
1869
1870    /// Substrate-canonical per-`UpgradeInstruction` kebab-case wire-form
1871    /// discriminator every consumer that lands on the un-prefixed
1872    /// kebab byte-string (matching serde's
1873    /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive's
1874    /// per-variant tag output and the
1875    /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1876    /// fleet-catalog identity) reaches through — returns `"load-module"`
1877    /// / `"state-change"` / `"soft-purge"` / `"purge"` / `"restart"`,
1878    /// byte-for-byte the same five strings the JSON `"kind"` tag carries
1879    /// (per the sibling
1880    /// [`crate::tests::dispatcher_registration::reflection_round_trips_through_serde_tags`]
1881    /// pin) and the fleet-wide dispatcher-catalog registers under
1882    /// `"caixa.upgrade-instruction"` (per
1883    /// [`crate::tests::dispatcher_registration::variant_kinds_match_otp_appup_kebab`]).
1884    ///
1885    /// Distinct axis from the peer [`Self::lisp_form`] accessor, which
1886    /// returns the tatara-lisp author-surface form with the leading `:`
1887    /// prefix (`":load-module"` / `":state-change"` / `":soft-purge"` /
1888    /// `":purge"` / `":restart"`) that lands in `feira lint` per-
1889    /// instruction diagnostics and every
1890    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const's docstring.
1891    /// The two axes carry different bytes by design, not drift: the lisp
1892    /// form is the author-facing tag the caixa.lisp grep-and-fix
1893    /// workflow reaches for (`grep '(:load-module '` finds the offending
1894    /// entry verbatim), while [`Self::as_str`] is the wire-format byte-
1895    /// string every serde-serialized CR / [`std::fmt::Display`]-formatted
1896    /// diagnostic line / [`AsRef<str>`]-bound consumer / fleet-catalog
1897    /// identity converge onto — the same two-axis discipline the sibling
1898    /// [`crate::CaixaKind::as_str`] / [`crate::CaixaKind::wire_name`]
1899    /// pair (2aa6d23) documents on the top-level `:kind` closed-set
1900    /// discriminator, extended here onto the M2 OTP-appup
1901    /// per-instruction tag axis.
1902    ///
1903    /// Peer of the sibling closed-set typed enums' `as_str` /
1904    /// `as_suffix` canonical-projection accessors:
1905    /// [`crate::CaixaKind::as_str`] (6b1f4fb),
1906    /// [`crate::supervisor::RestartStrategy::as_str`] (09ffb2d),
1907    /// [`crate::supervisor::RestartPolicy::as_str`] (ccdf955),
1908    /// [`crate::aplicacao::PlacementStrategy::as_str`] (cc8f749),
1909    /// [`crate::aplicacao::RateLimitUnit::as_suffix`] (6bce03d) — the
1910    /// last closed-set typed enum on the caixa `:upgrade-from` surface
1911    /// to converge onto the substrate-canonical
1912    /// `(as_str, AsRef<str>, Display)` triple through one lifted
1913    /// `const fn` scalar accessor, so a future author-facing rebrand
1914    /// (a per-consumer disambiguation of the OTP-appup vocabulary, a
1915    /// hypothetical `:reload` collapse of `:load-module` under an
1916    /// Elixir/Phoenix hot-reload convergence, an M4-side rename of
1917    /// `:state-change` onto Erlang's own `code_change/3` verbatim) lands
1918    /// at one match arm — the paired [`std::fmt::Display`] impl and
1919    /// [`AsRef<str>`] impl route through this accessor by construction,
1920    /// so every consumer downstream of any of the three reaches the same
1921    /// per-arm byte-string in lockstep.
1922    ///
1923    /// `pub const fn` matches the peer accessors' const-context posture:
1924    /// downstream `const`-context callers (a module-scope
1925    /// `const _:() = assert!(<variant>.as_str().len() > 0)` invariant
1926    /// pin, a `const fn` per-instruction wire-shape audit table the M4
1927    /// admission webhook materializes at build time) reach the accessor
1928    /// through one dispatch on the substrate primitive without an
1929    /// intermediate non-`const` step. Returns `&'static str` (not
1930    /// `&str` bound to `&self`'s lifetime) so callers can stash the
1931    /// returned label in `&'static`-bounded positions (a static logger's
1932    /// format argument, a `HashMap<&'static str, _>` key, a `matches!`-
1933    /// style slice-of-`&'static str` accept-set) without re-borrowing
1934    /// through the instruction reference.
1935    #[must_use]
1936    pub const fn as_str(&self) -> &'static str {
1937        match self {
1938            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
1939            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
1940            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
1941            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
1942            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
1943        }
1944    }
1945
1946    /// Validate the instruction's typed shape. Path existence is
1947    /// checked separately by [`crate::layout::StandardLayout`].
1948    ///
1949    /// The per-variant scalar the value-shape gates fire against is
1950    /// read through this method's two sibling accessors — the
1951    /// `String`-carrying axis via [`Self::declared_module`] (the
1952    /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1953    /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1954    /// carrying axis via [`Self::declared_path`] (the `StateChange`
1955    /// variant's tatara-lisp `:script`) — rather than the per-arm
1956    /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1957    /// Self::Purge { module }` pattern the module-axis previously
1958    /// open-coded and the per-arm `Self::StateChange { script }` the
1959    /// script-axis previously open-coded. Every scalar this enum
1960    /// carries now flows through one of the two `Option<&…>`
1961    /// accessors, so a future extension of either axis (a fifth
1962    /// module-bearing variant, an operator-side pre-parsed scalar
1963    /// cache the accessors materialize behind the same return
1964    /// contract, an M4 typed sub-slot the accessors could route
1965    /// alongside the existing scalar) migrates as a single edit on
1966    /// the accessor rather than a coordinated rewrite of every
1967    /// downstream value-shape gate. `Restart` (the only variant that
1968    /// carries neither scalar) falls through both `Option` checks and
1969    /// returns `Ok(())` — the terminal-fallback shape the
1970    /// [`Self::Restart`] variant doc pins.
1971    pub fn validate(&self) -> Result<(), UpgradeError> {
1972        if let Some(module) = self.declared_module() {
1973            return validate_module(self.lisp_form(), module);
1974        }
1975        if let Some(script) = self.declared_path() {
1976            // Delegate the four-arm cascade (empty / absolute /
1977            // parent-escape / non-`.lisp`-extension) to the lifted
1978            // [`crate::render::require_sandboxed_lisp_path`] helper —
1979            // same `Empty → Absolute → ParentEscape → NonLispExtension`
1980            // arm-ordering this method previously inlined verbatim,
1981            // now shared with [`crate::BehaviorSpec::validate`]'s
1982            // per-`:on-*`-callback gate so every author-supplied
1983            // tatara-lisp source path on every M2 typed slot consults
1984            // one gate, not two-and-counting verbatim copies of the
1985            // same four-arm cascade. Each closure wraps the tag in
1986            // the same `*Script` variant the original inline code
1987            // raised, so the diagnostic shape every caller depends
1988            // on (the `:state-change :script` self-locating error)
1989            // is preserved by construction. See
1990            // [`crate::render::require_sandboxed_lisp_path`] for the
1991            // smallest-scope-arm-fires-last ordering rationale.
1992            crate::render::require_sandboxed_lisp_path(
1993                script,
1994                || UpgradeError::EmptyScript,
1995                || UpgradeError::absolute_script(script),
1996                || UpgradeError::parent_escape_script(script),
1997                || UpgradeError::non_lisp_extension_script(script),
1998            )?;
1999        }
2000        // `Restart` (the only variant with no `Option<&…>`-carrying
2001        // scalar) falls through both accessor gates and returns
2002        // `Ok(())` — the terminal-fallback shape.
2003        Ok(())
2004    }
2005
2006    /// The `:module` scalar carried by this instruction — the
2007    /// K8s DNS-1123-label OTP-appup caixa-name reference every
2008    /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
2009    /// variant declares against, and every author expects `feira lint`
2010    /// to name verbatim in per-instruction diagnostics. Returns `None`
2011    /// on [`Self::StateChange`] (which carries a `:script` — closed by
2012    /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
2013    /// (which carries no data at all, the OTP terminal-fallback
2014    /// shape).
2015    ///
2016    /// Sibling in shape to [`Self::declared_path`] on the second and
2017    /// final scalar-carrying axis of [`UpgradeInstruction`]:
2018    /// `declared_path` closes the `PathBuf`-carrying arm
2019    /// (`StateChange`); `declared_module` closes the `String`-carrying
2020    /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
2021    /// enum carries now routes through one of the two `Option<&…>`
2022    /// accessors — a caller that doesn't care which variant declared
2023    /// the scalar reads through one `if let Some(…)` rather than a
2024    /// per-variant pattern match. The pair is the enum-variant-
2025    /// unifying peer of the per-mesh-slot-atom scalar-accessor family
2026    /// on the M3 side ([`crate::WitContract::source`] /
2027    /// [`crate::WitContract::destination`] /
2028    /// [`crate::WitContract::world_ref`] closing `:contratos`;
2029    /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
2030    /// closing `:entrada`; [`crate::Membro::nome`] /
2031    /// [`crate::Membro::versao_requirement`] closing `:membros`) and
2032    /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
2033    /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
2034    /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
2035    /// closed OTP-shape supervisor family) — those peer accessors
2036    /// return a struct field verbatim; this pair unifies enum-
2037    /// variant-carried scalars into one accessor per typed axis.
2038    ///
2039    /// Byte-for-byte from the typed variant's own `String` storage;
2040    /// no cloning, no re-parsing. A future extension of the axis (an
2041    /// M4 typed sub-slot the module string is derived from, an
2042    /// operator-side pre-parsed caixa-name cache the accessor could
2043    /// materialize behind the same `&str` return contract, a fifth
2044    /// module-bearing OTP-appup variant the enum grows) migrates as
2045    /// a single caixa-core edit rather than a coordinated rewrite
2046    /// of every downstream module-axis consumer (currently
2047    /// [`Self::validate`]'s DNS-1123-label gate through
2048    /// [`validate_module`]; extensible to future consumers on the
2049    /// same axis without further per-variant match sites).
2050    #[must_use]
2051    pub const fn declared_module(&self) -> Option<&str> {
2052        match self {
2053            Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
2054                Some(module.as_str())
2055            }
2056            Self::StateChange { .. } | Self::Restart => None,
2057        }
2058    }
2059
2060    /// If the instruction references an on-disk path, return it —
2061    /// used by the layout checker to verify the path resolves.
2062    ///
2063    /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
2064    /// on the `String`-carrying axis: `declared_path` closes the
2065    /// `StateChange` arm's `:script`; `declared_module` closes the
2066    /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
2067    /// they route every scalar this enum carries through one of two
2068    /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
2069    /// gates dispatch on the accessor return rather than a per-variant
2070    /// pattern match on the enum shape itself.
2071    ///
2072    /// Four per-`UpgradeInstruction` consumers now key off this
2073    /// accessor's `PathBuf`-carrying axis:
2074    /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
2075    /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
2076    /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
2077    /// within-entry
2078    /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
2079    /// per-`StateChange` script-projection fan-out, and the cross-slot
2080    /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
2081    /// :behavior` composition gate's per-`StateChange` detection loop
2082    /// — every downstream consumer of the `PathBuf`-carrying axis
2083    /// reaches through this one dispatch, so a future accessor
2084    /// extension (an M4 typed sub-slot the script path is derived from,
2085    /// an operator-side pre-resolved-path cache the accessor
2086    /// materializes behind the same `Option<&PathBuf>` return contract,
2087    /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
2088    /// migrates as a single caixa-core edit rather than a coordinated
2089    /// rewrite of four call sites.
2090    #[must_use]
2091    pub const fn declared_path(&self) -> Option<&PathBuf> {
2092        match self {
2093            Self::StateChange { script } => Some(script),
2094            _ => None,
2095        }
2096    }
2097
2098    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
2099    /// family arm-discriminator predicate every within-entry cross-
2100    /// instruction cleanup-facing gate keys off — true iff `self` is
2101    /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
2102    /// named module until no process is running it, then GC) or
2103    /// [`Self::Purge`] (`code:purge/1` analog: discard the named
2104    /// module immediately, without waiting for drain), the two OTP
2105    /// two-phase-code-load cleanup arms the closed-set enum's
2106    /// non-terminal / non-migration / non-load variants exhaust.
2107    /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
2108    /// two-phase-load half, [`Self::StateChange`] on the
2109    /// `gen_server:code_change/3`-analog migration axis,
2110    /// [`Self::Restart`] on the OTP terminal-fallback shape)
2111    /// returns `false`.
2112    ///
2113    /// Prior to this lift the `Self::SoftPurge { module } |
2114    /// Self::Purge { module }` two-arm cleanup-family pattern-
2115    /// match sat inline at three within-entry cross-instruction
2116    /// gate sites, each hand-rolling its own copy of the union
2117    /// with no compile-time link back to the substrate primitive's
2118    /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
2119    /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
2120    /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
2121    /// arriving before a preceding [`Self::LoadModule`]),
2122    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
2123    /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
2124    /// recording the first-encountered cleanup so a subsequent
2125    /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
2126    /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
2127    /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
2128    /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
2129    /// second cleanup targeting the same `:module`). Three open-
2130    /// coded per-arm-union pattern-matches that expressed no
2131    /// compile-time link back to the substrate primitive. A future
2132    /// fifth cleanup-shaped variant (a `Discard` variant the
2133    /// `code:delete/1` peer inspires that folds under the same
2134    /// two-phase-load cleanup partition, an M4 `SoftPurge` split
2135    /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
2136    /// cool-down policy grows a two-arm shape, an operator-side
2137    /// pre-resolved cleanup-decision cache the predicate could
2138    /// route through the same `bool` return contract) would have
2139    /// had to be threaded through every open-coded per-arm-union
2140    /// pattern-match in lockstep or one gate would silently
2141    /// classify the new arm outside the cleanup family while the
2142    /// peer gates classified it in (or vice versa) — a
2143    /// classification split across the three within-entry cross-
2144    /// instruction gates at build time that lands far from the
2145    /// source [`UpgradeInstruction`] declaration with no field
2146    /// naming which gate carries the drifted arm-set. Lifting the
2147    /// resolution to a typed predicate on the substrate primitive
2148    /// means every downstream cleanup-facing consumer of the
2149    /// [`UpgradeInstruction`] closed-set enum reaches for exactly
2150    /// one typed dispatch — the resolver's arm-set migrates as a
2151    /// unit on any future arm addition composing under this
2152    /// predicate's `||` chain.
2153    ///
2154    /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
2155    /// derive-generated [`Self::is_restart`] terminal-fallback
2156    /// arm-discriminator predicate on the same closed-set
2157    /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
2158    /// family partition as one typed dispatch on the substrate
2159    /// primitive; `is_restart` on the single-arm terminal-
2160    /// fallback family, `is_cleanup` on the two-arm cleanup
2161    /// family), extended here from the single-arm case onto the
2162    /// two-arm arm-family union case. Composes through the
2163    /// [`gen_platform::IsVariant`]-derive-generated
2164    /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
2165    /// predicates rather than an open-coded raw `matches!`
2166    /// pattern-match, so a future rebrand on either underlying
2167    /// per-arm classifier flows through this predicate's one
2168    /// body without a coordinated per-consumer rewrite across
2169    /// the three within-entry cross-instruction gates that route
2170    /// through it. Peer of the sibling per-`:contratos`
2171    /// shape-family union predicates [`crate::WitContract::is_http`] /
2172    /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
2173    /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
2174    /// per-shape WIT-prefix rule the substrate primitive's arm-
2175    /// family partition names as one typed dispatch) — the same
2176    /// "one typed dispatch on the substrate primitive, thin
2177    /// projections at each consumer" discipline extended onto the
2178    /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
2179    /// cleanup-family axis.
2180    ///
2181    /// The name `is_cleanup` maps directly onto the canonical
2182    /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
2183    /// `code:soft_purge/1` — wait until no process is running v1,
2184    /// then discard. (`code:purge/1` kills v1 immediately if you
2185    /// don't care.)" — the two `code:*_purge/1` operations are
2186    /// the two-phase-load contract's cleanup half, paired under
2187    /// one concept), and the peer [`Self::validate_cleanup_singularity`]
2188    /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
2189    /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
2190    /// reaches for the same "cleanup" vocabulary in identifier +
2191    /// diagnostic form.
2192    #[must_use]
2193    pub const fn is_cleanup(&self) -> bool {
2194        self.is_soft_purge() || self.is_purge()
2195    }
2196}
2197
2198/// [`std::fmt::Display`] routed through [`UpgradeInstruction::as_str`],
2199/// so the pretty-printed byte-string every consumer that formats the
2200/// per-`:upgrade-from :instructions` entry's OTP-appup tag as user-
2201/// facing text lands on (the future wasm-operator's
2202/// `install_release/1` per-instruction dispatch log line, the future
2203/// `feira lint --upgrade-from` per-entry annotation, an M4
2204/// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection body
2205/// naming the offending instruction's kind, an LSP hover projecting
2206/// the instruction kind onto a text-document diagnostic) reaches for
2207/// the same wire byte-string the un-`rename`d
2208/// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive emits
2209/// under the paired [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
2210/// tag key.
2211///
2212/// Peer of the sibling closed-set typed enums' `Display` route through
2213/// their `as_str` accessor: [`crate::CaixaKind`] (2aa6d23),
2214/// [`crate::supervisor::RestartStrategy`] (supervisor.rs),
2215/// [`crate::supervisor::RestartPolicy`] (supervisor.rs), and
2216/// [`crate::aplicacao::PlacementStrategy`] (aplicacao.rs) — the last
2217/// M2 OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2218/// surface to converge onto the `Display`-through-`as_str` discipline.
2219///
2220/// Deliberately routes through the wire-aligned
2221/// [`UpgradeInstruction::as_str`] axis (kebab-case, no `:` prefix),
2222/// not the tatara-lisp author-surface [`UpgradeInstruction::lisp_form`]
2223/// axis (kebab-case, with `:` prefix): the two axes carry different
2224/// bytes by design, and Rust convention pairs [`std::fmt::Display`]
2225/// with the wire byte-string every serde-carried CR / structured-log /
2226/// catalog identity reaches. The two-axis split is preserved
2227/// structurally by the pin
2228/// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
2229/// so a future accidental collapse (routing `Display` through
2230/// [`Self::lisp_form`] via a mistaken match-arm re-inlining) trips at
2231/// caixa-core test time rather than silently merging the two axes at
2232/// some future consumer's per-instruction dispatch step.
2233///
2234/// Discards the per-variant scalar data (`module: String` on
2235/// `LoadModule` / `SoftPurge` / `Purge`; `script: PathBuf` on
2236/// `StateChange`) by design — the `Display` axis is the *tag*
2237/// projection, not a full value dump; consumers wanting the field
2238/// scalar reach for [`Self::declared_module`] /
2239/// [`Self::declared_path`] on the sibling scalar-accessor family. The
2240/// `{:?}` [`std::fmt::Debug`] derive stays untouched for callers that
2241/// want the full variant + field rendering.
2242impl std::fmt::Display for UpgradeInstruction {
2243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2244        f.write_str(self.as_str())
2245    }
2246}
2247
2248/// Substrate-canonical [`AsRef<str>`] projection on the M2 OTP-appup
2249/// per-instruction [`UpgradeInstruction`] closed-set typed enum —
2250/// routes through the same [`UpgradeInstruction::as_str`]
2251/// `pub const fn` scalar accessor the paired [`std::fmt::Display`]
2252/// impl and the un-`rename`d [`serde::Serialize`] derive already key
2253/// off, so any future consumer that binds an [`UpgradeInstruction`]
2254/// through the standard-library `impl AsRef<str>` bound (a deferred
2255/// wasm-operator per-instruction structured-log recorder that accepts
2256/// `impl AsRef<str>` at the `tracing::field::Value` `Str`-arm, a
2257/// [`std::collections::HashMap`] lookup keyed on the instruction wire
2258/// byte through `map.get::<str>(instr.as_ref())` on a future
2259/// per-instruction dispatch table an M4 admission webhook composes,
2260/// a [`std::process::Command::arg`] shell-out threading the instruction
2261/// tag through a deferred `feira upgrade-from --dry-run <kind>` verb)
2262/// reaches the same kebab-case wire byte-string the
2263/// [`Self::as_str`] accessor returns through one substrate-primitive
2264/// dispatch rather than an open-coded `.as_str()` projection at
2265/// every wire-up.
2266///
2267/// Peer of the sibling [`std::fmt::Display`] impl on the same
2268/// primitive — both delegate to the shared
2269/// [`UpgradeInstruction::as_str`] `pub const fn` accessor, so
2270/// `format!("{v}")`, `v.as_str()`, and
2271/// `<UpgradeInstruction as AsRef<str>>::as_ref(&v)` resolve to the
2272/// same byte-string per instance by construction. A future variant
2273/// rename or `#[serde(rename_all = "…")]` attribute-drift on the enum
2274/// reaches every one of the three paths (plus the wire-format
2275/// `Serialize` derive that already routes through the same kebab
2276/// vocabulary and the [`gen_platform::Discriminant`]-derived
2277/// [`Self::discriminant`] catalog identity) through exactly one
2278/// caixa-core edit — the [`Self::as_str`] match arms.
2279///
2280/// Same "route the trait impl through the substrate-primitive
2281/// accessor" discipline the sibling
2282/// [`crate::supervisor::RestartStrategy`] [`AsRef<str>`] impl
2283/// (63eb1a4), [`crate::supervisor::RestartPolicy`] [`AsRef<str>`]
2284/// impl (419ea81), [`crate::aplicacao::PlacementStrategy`]
2285/// [`AsRef<str>`] impl (d86edd2), [`crate::CaixaKind`]
2286/// [`AsRef<str>`] impl (cd2091f), [`crate::aplicacao::RateLimitUnit`]
2287/// [`AsRef<str>`] impl (d8136db), and [`crate::CaixaVersion`]
2288/// [`AsRef<str>`] impl (16d5c7e) carry — closes the substrate
2289/// primitive's [`AsRef<str>`] projection axis onto the last M2
2290/// OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2291/// surface, so every closed-set typed enum on the caixa typed
2292/// surface now carries the paired [`AsRef<str>`] +
2293/// [`fmt::Display`] + `as_str` triple.
2294///
2295/// Pinned load-bearing by
2296/// [`tests::upgrade_instruction_as_ref_str_routes_through_as_str_accessor`]
2297/// — any future silent detour that routes the impl through a
2298/// divergent projection (a per-arm inline `match self { … }`
2299/// re-inlining that opens a compile-time link to the un-lifted arm-
2300/// literal, a swap onto the [`Self::lisp_form`] tatara-lisp axis
2301/// that would collide the wire axis with the author-surface axis)
2302/// trips at caixa-core test time under `assert_eq!` rather than at a
2303/// downstream `impl AsRef<str>`-bound consumer's silent split.
2304impl AsRef<str> for UpgradeInstruction {
2305    fn as_ref(&self) -> &str {
2306        self.as_str()
2307    }
2308}
2309
2310/// Reject upgrade instruction `:module` values that aren't K8s
2311/// DNS-1123 labels. Thin wrapper around
2312/// [`crate::render::is_dns_1123_label`] that maps the shared
2313/// parser-shaped reason into the kind-tagged
2314/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
2315/// diagnostics, so the author can grep their caixa.lisp for the
2316/// offending `(:<kind> <module>)` form and fix it in one edit.
2317///
2318/// The contract — the same DNS-1123 label rule the K8s apiserver
2319/// enforces on every `metadata.name` / Service name / label value the
2320/// module name lands in. Each upgrade instruction's `:module` is a
2321/// reference to a caixa name (the wasm-engine resolves it through the
2322/// same `ComputeUnit` registry the operator manages), so the value must
2323/// match every downstream apiserver-side schema: the per-Servico
2324/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
2325/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
2326/// against the loaded-module table at hot-upgrade dispatch, and the
2327/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
2328/// per-module reference axis. Same trajectory as `:children :caixa`
2329/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
2330/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
2331/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
2332///
2333/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
2334/// variant before this predicate is consulted, mirroring
2335/// `validate_membro_caixa`'s empty-first cascade.
2336fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
2337    // Routes through the shared
2338    // [`crate::render::require_valid_dns_1123_label`] gate the peer
2339    // name axes each land on. The `kind: &'static str` field flows
2340    // through both error variants so the diagnostic names which
2341    // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
2342    // offending value came from.
2343    crate::render::require_valid_dns_1123_label(
2344        module,
2345        || UpgradeError::module_empty(kind),
2346        |reason| UpgradeError::module_invalid(kind, module, reason),
2347    )
2348}
2349
2350#[derive(Debug, Error, PartialEq, Eq)]
2351pub enum UpgradeError {
2352    #[error(
2353        ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
2354         consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
2355         `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
2356         every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
2357         the running version through `semver::Version::parse` and matches it against each entry's \
2358         `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
2359         SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2360         git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2361         requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2362    )]
2363    FromInvalid { from: String, reason: String },
2364    #[error(
2365        "upgrade instruction `{kind}` :module is empty (every appup module reference \
2366         must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2367         the instruction entirely)"
2368    )]
2369    ModuleEmpty { kind: &'static str },
2370    #[error(
2371        "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2372         {reason} (every appup module reference resolves to a caixa name, which lands \
2373         verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2374         creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2375         dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2376         axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2377         `\"cache-v2\"`)"
2378    )]
2379    ModuleInvalid {
2380        kind: &'static str,
2381        module: String,
2382        reason: String,
2383    },
2384    #[error("instruction's :script is empty")]
2385    EmptyScript,
2386    #[error(
2387        "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2388         root (Path::join would otherwise escape the project sandbox)",
2389        script.display()
2390    )]
2391    AbsoluteScript { script: PathBuf },
2392    #[error(
2393        "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2394         above the caixa root",
2395        script.display()
2396    )]
2397    ParentEscapeScript { script: PathBuf },
2398    #[error(
2399        ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2400         wasm-engine instantiator reads every migration script as tatara-lisp source through \
2401         `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2402         peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2403         other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2404         parser error far from the source caixa.lisp, with no field naming the offending \
2405         `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2406         terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2407         `\"lib/migrations/v01-to-v02.lisp\"`).",
2408        script.display()
2409    )]
2410    NonLispExtensionScript { script: PathBuf },
2411    #[error(
2412        ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2413         one matching block per running version (`release_handler:install_release/1` dispatches \
2414         on the loaded `:from` against the currently-running release), so two entries with the \
2415         same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2416         pick either set non-deterministically). Author one path per prior version; if two \
2417         distinct instruction sequences are needed, fold them into one ordered list under the \
2418         single matching `(:from {from:?} :instructions (…))` block."
2419    )]
2420    DuplicateFrom { from: String },
2421    #[error(
2422        ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2423         `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2424         greater than or equal to the caixa's own version is structurally unreachable \
2425         (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2426         the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2427         is never reached because the operator never runs a version greater than or equal to \
2428         the current one that it could then upgrade *to* the current one). Bump the caixa's \
2429         `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2430         *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2431         reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2432         version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2433         than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2434         values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2435         here as a self-upgrade no-op."
2436    )]
2437    FromNotBeforeVersao { from: String, versao: String },
2438    #[error(
2439        ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2440         exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2441         `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2442         instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2443         `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2444         component-model world incompatibility, irreversible state shape change), and the \
2445         fallback is terminal by construction (the operator restarts the pod and the new \
2446         version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2447         in both directions: if the typed instructions would succeed, `(:restart)` is \
2448         unreached; if they wouldn't, the typed instructions are dead because the operator \
2449         restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2450         (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2451         never repeated. If two distinct upgrade strategies are needed for the same prior \
2452         version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2453         dispatch picks exactly one block per running version) — keep the typed sequence; \
2454         the fallback restart is what the operator does on any typed-sequence failure \
2455         already."
2456    )]
2457    RestartNotExclusive {
2458        from: String,
2459        restart_count: usize,
2460        other_kinds: Vec<&'static str>,
2461    },
2462    #[error(
2463        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2464         `(:load-module …)` in its :instructions list — a state migration is the \
2465         gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2466         code, but the operator executes instructions in declared order, so this migration \
2467         runs while the only resident version is still the prior one (which expects the \
2468         pre-migration state shape). Load the new module first: author the canonical \
2469         `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2470         resident before its state migration runs.",
2471        script.display(),
2472        script.display()
2473    )]
2474    StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2475    #[error(
2476        ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2477         `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2478         code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2479         resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2480         then `code:soft_purge/1`), but the operator executes instructions in declared \
2481         order, so this cleanup runs while the only resident version is still the same \
2482         old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2483         mid-request), leaving no replacement to route in-flight or future requests \
2484         to. Load the new module first: author the canonical `(:load-module …) \
2485         (:state-change …) ({kind} {module:?})` order so the new code is resident \
2486         before the old code is drained or discarded."
2487    )]
2488    PurgeWithoutPriorLoad {
2489        from: String,
2490        kind: &'static str,
2491        module: String,
2492    },
2493    #[error(
2494        ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2495         more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2496         code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2497         wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2498         if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2499         them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2500         both, never repeated. systools-generated `.relup` files emit at most one purge per \
2501         module for this reason. A second cleanup on the same module is at best redundant (the \
2502         module is already gone after the first cleanup, so the second is a no-op or undefined \
2503         depending on the operator's handling of a non-resident-module purge request) and at \
2504         worst incoherent (mixing drain and discard semantics on one module suggests the author \
2505         wanted a fallback, but the operator runs declared instructions unconditionally — \
2506         fallback on cleanup failure is the operator's job, not authored into the entry). \
2507         Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2508         callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2509         can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2510         cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2511    )]
2512    DuplicateCleanup {
2513        from: String,
2514        module: String,
2515        kinds: Vec<&'static str>,
2516    },
2517    #[error(
2518        ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2519         once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2520         `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2521         'old'.\"), and the instruction binds the named wasm component once: the operator's \
2522         dispatch table reads the module name and brings up the corresponding component \
2523         alongside the running version. systools-generated `.relup` files emit at most one \
2524         `load_module` per module per upgrade step for this reason. A second `(:load-module \
2525         {module:?})` instruction has no observable semantic relative to the first (the \
2526         component is already resident) — either dead code (copy-pasted load line) or a typo \
2527         masking a distinct module the author intended to load alongside (renamed both to \
2528         {module:?} by mistake), leaving the second module silently absent from the entry. \
2529         Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2530         versions need loading alongside the running one, name them distinctly (e.g. \
2531         `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2532    )]
2533    DuplicateLoadModule { from: String, module: String },
2534    #[error(
2535        ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2536         once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2537         \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2538         state shape into the current-version shape: a one-shot transition, not a step that \
2539         composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2540         per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2541         callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2542         the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2543         author intended two distinct migration scripts) and at worst silent state corruption \
2544         (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2545         counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2546         Author one `(:state-change {})` per migration script per entry; if two distinct state \
2547         transitions are needed (e.g. one module's schema *and* another module's projection), \
2548         name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2549        script.display(),
2550        script.display(),
2551        script.display(),
2552        script.display()
2553    )]
2554    DuplicateStateChange { from: String, script: PathBuf },
2555    #[error(
2556        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2557         {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2558         gen_server:code_change/3 analog and folds the prior-version state shape into the \
2559         current shape, but the prior version's state only exists while the prior code is \
2560         still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2561         analogs and drain or discard that prior code. The operator executes instructions in \
2562         declared order, so a cleanup ahead of a state-change has already drained the prior \
2563         module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2564         the migration script runs, leaving the script either no-op (no prior-version state \
2565         left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2566         canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2567         `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2568         {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2569         strictly between load and cleanup. Author the canonical `(:load-module …) \
2570         (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2571         migration runs against the prior-version state before the cleanup drains it.",
2572        script.display(),
2573        script.display()
2574    )]
2575    StateChangeAfterCleanup {
2576        from: String,
2577        script: PathBuf,
2578        prior_cleanup_kind: &'static str,
2579        prior_cleanup_module: String,
2580    },
2581    #[error(
2582        ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2583         declare `:behavior :on-state-change` — the per-version migration script is the \
2584         gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2585         hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2586         realizes the composition by invoking the running gen_server's code_change/3 callback \
2587         during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2588         composition into two typed slots, the per-version migration logic in this \
2589         `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2590         `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2591         verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2592         migration during hot upgrades\"). The missing callback leaves the per-version script \
2593         with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2594         callback at the migration step, finds it absent, and either fails the upgrade \
2595         mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2596         current version stays load-bearing\") or silently skips the migration leaving the \
2597         new code running against unmigrated prior-version state. Add the callback: \
2598         `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2599         path) alongside the existing `(:state-change {})` instruction (the per-version \
2600         script). If the upgrade truly carries no state migration, drop the `(:state-change \
2601         …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2602         migration — is the canonical shape).",
2603        script.display(),
2604        script.display()
2605    )]
2606    StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2607}
2608
2609// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2610// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2611// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2612// two-slot struct-variant wire-up sites at
2613// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2614// / `script` from `instr.declared_path()`),
2615// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2616// (`self.prior_versao()` / `script.as_path()` from
2617// `instr.declared_path()`), and
2618// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2619// / `script` from `instr.declared_path()`) onto one substrate primitive
2620// per typed variant — the paired `{ from: String, script: PathBuf }`
2621// two-slot sibling on [`UpgradeError`] of the peer
2622// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2623// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2624// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2625// (8580068, 4 variants on `{ de, para }`),
2626// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2627// `{ de, para, wit, expected }`),
2628// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2629// variants on `{ <field>: String, reason: String }`), and
2630// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2631// variants on `{ de, para, <field>: String, reason: String }`) on the
2632// sibling `AplicacaoError` envelopes, and the peer
2633// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2634// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2635// (0419438, 4 variants on `{ caixa, kind, slots }`),
2636// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2637// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2638// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2639// `LayoutError` envelopes, plus the peer
2640// [`crate::limits::limits_codec_value_only_ctors!`] /
2641// [`crate::limits::limits_codec_value_byte_ctors!`] /
2642// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2643// wire-ups) on the sibling `LimitsError` envelopes.
2644//
2645// Each of the three wire-up sites on this shape opens the identical
2646// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2647// script: <script>.to_path_buf() }` struct-literal against a local
2648// `prior_versao()` and `declared_path()` accessor pair — the exact
2649// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2650// names as a bug, on the same altitude the peer `SupervisorError` /
2651// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2652// closed on their sibling envelopes. The three variants share one
2653// `{ from: String, script: PathBuf }` shape, so the fold routes each
2654// wire-up site through one dispatch per typed variant.
2655//
2656// The macro below generates one `#[must_use]` inherent constructor per
2657// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2658// Self`, so every wire-up site collapses onto one dispatch:
2659// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2660// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2661// uniform two-field construction (`from.to_string()` /
2662// `script.to_path_buf()`) is spelled once — inside the macro — rather
2663// than at every wire-up site. The `&Path` parameter accepts both
2664// `&Path` (from `script.as_path()` at the uniqueness gate) and
2665// `&PathBuf` (from `instr.declared_path()` at the ordering /
2666// callback-declaration gates, via Deref coercion), so every existing
2667// wire-up threads through the ctor without a pre-conversion.
2668//
2669// Every future consumer that wants to construct one of these three
2670// variants outside the three in-crate `UpgradeFromEntry` /
2671// `validate_state_change_on_state_change_callback` gates (a deferred
2672// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2673// re-checker at hot-upgrade dispatch time, a future
2674// `feira validate --upgrade-from` per-caixa admission verb re-checking
2675// the three axes, a per-`Caixa` overlay resolver rejecting an
2676// ordering / uniqueness / callback-declaration invariant against a
2677// cluster-local snapshot) now reaches each variant through one call
2678// rather than re-inlining the three-line struct-literal in lockstep
2679// with the three in-crate wire-up sites.
2680macro_rules! upgrade_from_script_ctors {
2681    ($($ctor:ident => $variant:ident),* $(,)?) => {
2682        impl UpgradeError {
2683            $(
2684                #[doc = concat!(
2685                    "Construct an [`UpgradeError::",
2686                    stringify!($variant),
2687                    "`] naming the offending `(:from <prior-versao>)` and ",
2688                    "`(:state-change <script>)` pair. Folds the uniform ",
2689                    "`Self::",
2690                    stringify!($variant),
2691                    " { from: from.to_string(), script: script.to_path_buf() }` ",
2692                    "two-field struct-literal onto one substrate primitive so ",
2693                    "every wire-up on this variant reads through one dispatch ",
2694                    "rather than the pre-lift three-line open-coded block. The ",
2695                    "`from` string threads verbatim from ",
2696                    "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2697                    "from [`UpgradeInstruction::declared_path`] at the call site."
2698                )]
2699                #[must_use]
2700                pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2701                    Self::$variant {
2702                        from: from.to_string(),
2703                        script: script.to_path_buf(),
2704                    }
2705                }
2706            )*
2707        }
2708    };
2709}
2710
2711upgrade_from_script_ctors! {
2712    state_change_without_prior_load => StateChangeWithoutPriorLoad,
2713    duplicate_state_change => DuplicateStateChange,
2714    state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2715}
2716
2717// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2718// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2719// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2720// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2721// onto one substrate primitive per typed variant — the paired
2722// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2723// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2724// `{ from: String, script: PathBuf }`) two-slot family on the same
2725// envelope, and of the peer
2726// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2727// variants on `{ caixa: String }`) and
2728// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2729// `{ nome: String }`) single-slot families on the sibling
2730// `SupervisorError` / `DepError` envelopes, and of the peer
2731// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2732// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2733// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2734// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2735// variants on `{ <field>: String, reason: String }`), and
2736// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2737// variants on `{ de, para, <field>: String, reason: String }`) on the
2738// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2739// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2740// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2741// [`crate::LayoutError::missing_entry`] 1b09f9d;
2742// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2743// `LimitsError` codec families (81c856c), and the sibling
2744// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2745// `{ nome, caminho }`) two-slot family.
2746//
2747// The three wire-up sites this fold closes are the three closures
2748// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2749// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2750// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2751// passed to [`crate::render::require_sandboxed_lisp_path`] at
2752// [`UpgradeInstruction::validate`] — each opens the identical
2753// `UpgradeError::<Variant> { script: script.clone() }` three-line
2754// struct-literal against the same `script: &PathBuf` local threaded
2755// from [`UpgradeInstruction::declared_path`], the exact "same block
2756// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2757// bug. The three variants share one `{ script: PathBuf }` shape, so
2758// the fold routes each closure through one dispatch per typed variant.
2759// The sibling `EmptyScript` unit-variant on the same envelope stays on
2760// its pre-lift open-coded shape — it carries no `script` field (the
2761// offending `:script` value *is* the empty path this variant catches),
2762// so the uniform `fn(script: &Path) -> Self` signature this macro
2763// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2764// closure is already a one-liner. This is the second fold family on
2765// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2766// two-slot family established in 8e67041, which explicitly named this
2767// `{ script: PathBuf }` single-slot family as the next fold to land
2768// on the envelope; per that commit's coverage roster, both of the two
2769// most-populated shapes on `UpgradeError` — the two-slot
2770// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2771//
2772// The macro below generates one `#[must_use]` inherent constructor per
2773// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2774// every closure collapses onto one dispatch:
2775// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2776// struct-literal on the same `&Path` fixture. The uniform one-field
2777// construction (`script.to_path_buf()`) is spelled once — inside the
2778// macro — rather than at every wire-up site. The `&Path` parameter
2779// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2780// `instr.declared_path()` at the three closures, via Deref coercion),
2781// so every existing closure threads through the ctor without a
2782// pre-conversion.
2783//
2784// Every future consumer that wants to construct one of these three
2785// variants outside the three in-crate closures (a deferred
2786// wasm-operator's `install_release/1` per-instruction script-shape
2787// re-checker at hot-upgrade dispatch time, a future
2788// `feira validate --upgrade-from` per-caixa admission verb re-checking
2789// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2790// an author-supplied `:state-change :script` against a cluster-local
2791// snapshot) now reaches each variant through one call rather than
2792// re-inlining the three-line struct-literal in lockstep with the three
2793// in-crate closure sites.
2794macro_rules! upgrade_script_only_ctors {
2795    ($($ctor:ident => $variant:ident),* $(,)?) => {
2796        impl UpgradeError {
2797            $(
2798                #[doc = concat!(
2799                    "Construct an [`UpgradeError::",
2800                    stringify!($variant),
2801                    "`] naming the offending `(:state-change <script>)`. ",
2802                    "Folds the uniform `Self::",
2803                    stringify!($variant),
2804                    " { script: script.to_path_buf() }` one-field ",
2805                    "struct-literal onto one substrate primitive so every ",
2806                    "closure passed to ",
2807                    "[`crate::render::require_sandboxed_lisp_path`] at ",
2808                    "[`UpgradeInstruction::validate`] on this variant reads ",
2809                    "through one dispatch rather than the pre-lift three-line ",
2810                    "open-coded block. The `script` path threads verbatim ",
2811                    "from [`UpgradeInstruction::declared_path`] at the call ",
2812                    "site."
2813                )]
2814                #[must_use]
2815                pub fn $ctor(script: &std::path::Path) -> Self {
2816                    Self::$variant {
2817                        script: script.to_path_buf(),
2818                    }
2819                }
2820            )*
2821        }
2822    };
2823}
2824
2825upgrade_script_only_ctors! {
2826    absolute_script => AbsoluteScript,
2827    parent_escape_script => ParentEscapeScript,
2828    non_lisp_extension_script => NonLispExtensionScript,
2829}
2830
2831// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2832// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2833// <value>.to_string() }` two-slot struct-variant wire-up sites at
2834// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2835// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2836// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2837// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2838// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2839// self.prior_versao().to_string(), module: module.to_string() });`),
2840// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2841// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2842// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2843// });`) onto one substrate-primitive family per typed variant — the
2844// missing paired two-slot rung on the `UpgradeError`-side four-family
2845// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2846// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2847// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2848// script: PathBuf }`), and mirror-symmetric sibling of the peer
2849// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2850// String, <axis>: String }` fold on the `DepError` envelope — same
2851// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2852// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2853// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2854// carries the offending prior-version `:from` verbatim so the author
2855// can grep their caixa.lisp for the offending `(:from "<value>")` /
2856// `(:load-module …)` / `:versao` block in one edit). The three
2857// variants share the same `{ from: String, <axis>: String }` two-slot
2858// shape: the `from` field names the offending per-`:upgrade-from` block's
2859// prior-version tag the diagnostic points the author back at, and the
2860// middle `<axis>: String` field carries the offending per-envelope axis
2861// value verbatim (`reason` on `FromInvalid` carries the wrapped
2862// `semver::Version::parse` error message that pinpoints why the tag
2863// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2864// own current-`:versao` the entry's `:from` failed to precede; `module`
2865// on `DuplicateLoadModule` carries the caixa name the second
2866// `(:load-module …)` instruction re-loaded within the same entry).
2867// The middle axis-field name differs across variants (`reason` /
2868// `versao` / `module`) so the ctor family below takes the axis field
2869// name as a macro parameter (`$axis:ident`) alongside the ctor +
2870// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2871// -> Self` inherent constructor per typed variant that spells the
2872// uniform two-field construction (`from.to_string()` /
2873// `<axis>.to_string()`) exactly once.
2874//
2875// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2876// variants on `{ from: String, script: PathBuf }`) two-slot family on
2877// the same envelope — both key off the same `from: String` axis at the
2878// same per-`:upgrade-from :from`-owned altitude; this family carries the
2879// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2880// carrier) where the script-slot family carries the owned-`PathBuf`
2881// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2882// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2883// same envelope, of the sibling
2884// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2885// variants on `{ caixa: String }`) and
2886// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2887// `{ nome: String }`) single-slot families on the sibling
2888// `SupervisorError` / `DepError` envelopes, and of the peer
2889// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2890// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2891// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2892// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2893// variants on `{ <field>: String, reason: String }`),
2894// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2895// variants on `{ caixa: String }`),
2896// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2897// on `{ path: String }`), and
2898// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2899// variants on `{ de, para, <field>: String, reason: String }`) on the
2900// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2901// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2902// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2903// [`crate::LayoutError::missing_entry`] 1b09f9d;
2904// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2905// `LimitsError` codec families (81c856c), the sibling
2906// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2907// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2908// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2909// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2910// `{ nome, list: &'static str }`), and
2911// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2912// `{ nome, <axis>: String, reason: String }`) families.
2913//
2914// Each of the three wire-up sites on this shape opens the identical
2915// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2916// <value>.to_string() }` four-line struct-literal against a local
2917// `(prior_versao(), <axis-value>)` pair threaded from
2918// [`UpgradeFromEntry::prior_versao`] (or, at the
2919// [`validate_upgrade_from_against_versao`] site, directly from the
2920// caller-supplied `versao: &str` argument) — the exact "same block
2921// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2922// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2923// / `upgrade_script_only_ctors!` families closed on the sibling
2924// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2925// axis-field discriminators are the only things that vary between them;
2926// the rest of the struct-literal is a byte-for-byte re-inline.
2927//
2928// The macro below generates one `#[must_use]` inherent constructor per
2929// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2930// every wire-up site collapses onto one dispatch:
2931// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2932// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2933// parameters accept `&str` literals and `&String` (via Deref coercion)
2934// so every existing wire-up threads through the ctor without a
2935// pre-conversion.
2936//
2937// Every future consumer that wants to construct one of these three
2938// variants outside the three in-crate `UpgradeFromEntry::validate` /
2939// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2940// gates (a deferred wasm-operator's `install_release/1` per-entry
2941// `:from`-parse / per-`:load-module` singularity / per-entry
2942// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2943// `feira validate --upgrade-from` per-caixa admission verb re-checking
2944// the three axes, a per-`Caixa` overlay resolver rejecting a
2945// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2946// invariant against a cluster-local snapshot) now reaches each variant
2947// through one call rather than re-inlining the four-line struct-literal
2948// in lockstep with the three in-crate wire-up sites.
2949macro_rules! upgrade_from_axis_ctors {
2950    ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2951        impl UpgradeError {
2952            $(
2953                #[doc = concat!(
2954                    "Construct an [`UpgradeError::",
2955                    stringify!($variant),
2956                    "`] naming the offending `(:from <prior-versao>)` and ",
2957                    "the offending `:", stringify!($axis), "` axis value. ",
2958                    "Folds the uniform `Self::",
2959                    stringify!($variant),
2960                    " { from: from.to_string(), ",
2961                    stringify!($axis),
2962                    ": ",
2963                    stringify!($axis),
2964                    ".to_string() }` two-field struct-literal onto one ",
2965                    "substrate primitive so every in-crate wire-up on ",
2966                    "this variant reads through one dispatch rather than ",
2967                    "the pre-lift four-line open-coded block. Both `from: ",
2968                    "&str` and `",
2969                    stringify!($axis),
2970                    ": &str` parameters accept `&str` literals and ",
2971                    "`&String` (via Deref coercion) so every existing ",
2972                    "wire-up threads through the ctor without a pre-",
2973                    "conversion."
2974                )]
2975                #[must_use]
2976                pub fn $ctor(from: &str, $axis: &str) -> Self {
2977                    Self::$variant {
2978                        from: from.to_string(),
2979                        $axis: $axis.to_string(),
2980                    }
2981                }
2982            )*
2983        }
2984    };
2985}
2986
2987upgrade_from_axis_ctors! {
2988    from_invalid => FromInvalid { reason },
2989    from_not_before_versao => FromNotBeforeVersao { versao },
2990    duplicate_load_module => DuplicateLoadModule { module },
2991}
2992
2993// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2994// entry.prior_versao().to_string() }` one-slot struct-literal inside
2995// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2996// one substrate primitive on the [`UpgradeError`] envelope, projecting
2997// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2998// on the substrate primitive. The `DuplicateFrom` variant is the last
2999// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
3000// every peer envelope shape (`{ script: PathBuf }` one-slot via
3001// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
3002// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
3003// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
3004// 8e67041) already reads through one substrate-primitive dispatch, so
3005// this fold closes the last one-off single-slot on the envelope.
3006//
3007// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
3008// (b30edfe) on the paired [`WitContract`] projection — same
3009// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
3010// through the substrate primitive's own scalar accessor rather than
3011// re-inlining the `.to_string()` at the call site. Extended here onto
3012// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
3013// M2 companion of the M3 mesh-slot accessors (see
3014// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
3015// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
3016// 4a32abf, and the [`crate::WitContract::{source, destination,
3017// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
3018// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
3019//
3020// The one wire-up site this fold closes opens the identical
3021// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
3022// three-line struct-literal against the `entry: &UpgradeFromEntry` local
3023// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
3024// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
3025// names as a bug, on the same altitude the peer `contrato_self_loop`
3026// closed on the sibling `{ caixa: String, wit: String }` two-slot
3027// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
3028// parameter accepts the borrowed entry verbatim so the wire-up site
3029// threads through the ctor without a pre-projection — the ctor body
3030// spells the paired `prior_versao().to_string()` projection once.
3031//
3032// Every future consumer that wants to construct this variant outside
3033// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
3034// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
3035// re-checker at hot-upgrade dispatch time rejecting a second entry
3036// with the same prior-versao tag, a future `feira validate --upgrade-
3037// from` per-caixa admission verb re-running the cross-entry duplicate
3038// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
3039// supplied duplicate `(:from "<value>")` against a cluster-local
3040// snapshot — now reaches the variant through one call rather than
3041// re-inlining the three-line struct-literal in lockstep with the one
3042// in-crate wire-up site.
3043impl UpgradeError {
3044    /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
3045    /// duplicate `(:from <prior-versao>)` entry, projecting through the
3046    /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
3047    /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
3048    /// from: entry.prior_versao().to_string() }` one-field struct-literal
3049    /// onto one substrate primitive so every wire-up on this variant
3050    /// reads through one dispatch, matching the sibling
3051    /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
3052    /// substrate-primitive-projection ctor's shape on the peer
3053    /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
3054    /// parameter accepts the borrowed entry verbatim so the paired
3055    /// `prior_versao().to_string()` projection is spelled once — inside
3056    /// the ctor body — rather than at every wire-up site.
3057    #[must_use]
3058    pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
3059        Self::DuplicateFrom {
3060            from: entry.prior_versao().to_string(),
3061        }
3062    }
3063
3064    /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
3065    /// offending `(:from <prior-versao>)` entry, the offending cleanup
3066    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
3067    /// its `:module` target. Folds the uniform
3068    /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
3069    /// module: module.to_string() }` three-field struct-literal onto one
3070    /// substrate primitive so every wire-up on this sole-variant
3071    /// cleanup-family load-before-cleanup ordering-refusal envelope reads
3072    /// through one dispatch rather than the pre-lift seven-line
3073    /// open-coded block.
3074    ///
3075    /// The `from: &str` parameter accepts `&str` literals and `&String`
3076    /// via Deref coercion so the sole in-crate wire-up site threads
3077    /// [`UpgradeFromEntry::prior_versao`] verbatim without a
3078    /// pre-conversion. The `kind: &'static str` parameter accepts the
3079    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3080    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3081    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3082    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3083    /// re-projection at the ctor path. The `module: &str` parameter
3084    /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
3085    /// via `.expect("is_cleanup() implies declared_module() is Some")`
3086    /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
3087    /// `Some` composition pin at
3088    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3089    /// makes the `.expect(…)` structurally infallible at build time.
3090    ///
3091    /// Peer of the sibling one-off standalone-ctor
3092    /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
3093    /// String }` envelope on the same `UpgradeError` envelope, and of
3094    /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
3095    /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
3096    /// variant standalone ctor on the peer `AplicacaoError` envelope.
3097    /// Closes the last unlifted `{ from: String, kind: &'static str,
3098    /// module: String }` three-slot open-coded struct-literal wire-up
3099    /// on the OTP-appup load-before-cleanup ordering axis, sibling of
3100    /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
3101    /// (41d08db, three variants on `{ from: String, <axis>: String }`)
3102    /// on the paired ordering / uniqueness / callback-declaration axes,
3103    /// and of the peer standalone [`UpgradeError::duplicate_from`]
3104    /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
3105    /// `:from` gate. Every future consumer that raises this refusal
3106    /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
3107    /// wasm-operator's `install_release/1` per-entry load-before-cleanup
3108    /// re-checker at hot-upgrade dispatch time, a future
3109    /// `feira validate --upgrade-from` per-caixa admission verb
3110    /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
3111    /// overlay resolver rejecting a cluster-local `:soft-purge` /
3112    /// `:purge` overlay lacking a preceding `:load-module` — reaches
3113    /// the variant through one call rather than re-inlining the
3114    /// seven-line struct-literal in lockstep with the sole in-crate
3115    /// wire-up site.
3116    #[must_use]
3117    pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
3118        Self::PurgeWithoutPriorLoad {
3119            from: from.to_string(),
3120            kind,
3121            module: module.to_string(),
3122        }
3123    }
3124
3125    /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
3126    /// offending `(:from <prior-versao>)` entry, the offending
3127    /// `(:state-change …)` `:script` path, and the prior cleanup
3128    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
3129    /// `:module` target. Folds the uniform
3130    /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
3131    /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
3132    /// prior_cleanup_module.to_string() }` four-field struct-literal
3133    /// onto one substrate primitive so every wire-up on this sole-
3134    /// variant migrate-after-cleanup ordering-refusal envelope reads
3135    /// through one dispatch rather than the pre-lift seven-line open-
3136    /// coded block. Closes the last unlifted `{ from: String, script:
3137    /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
3138    /// String }` four-slot open-coded struct-literal wire-up on the
3139    /// OTP-appup migrate-before-cleanup ordering axis, filling the
3140    /// missing four-slot rung on the `UpgradeError`-side ctor-family
3141    /// ladder alongside the sibling one-slot
3142    /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
3143    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3144    /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
3145    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
3146    /// families, and the one-slot [`upgrade_script_only_ctors!`]
3147    /// (7468ca9) family. Sole in-crate wire-up site is inside
3148    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
3149    /// migrate-family sticky-latch dispatch — the third of three
3150    /// within-entry cross-instruction OTP-appup ordering gates the
3151    /// module doc pins (`validate_state_change_ordering` on the load →
3152    /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
3153    /// `state_change_without_prior_load`; `validate_purge_ordering` on
3154    /// the load → cleanup boundary via `purge_without_prior_load`;
3155    /// `validate_state_change_before_cleanup` on the migrate → cleanup
3156    /// boundary via this ctor — now).
3157    ///
3158    /// The `from: &str` parameter accepts `&str` literals and `&String`
3159    /// via Deref coercion so the sole in-crate wire-up site threads
3160    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3161    /// without a pre-conversion. The `script: &std::path::Path`
3162    /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
3163    /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
3164    /// via Deref coercion) so the wire-up threads the sticky-latch
3165    /// script projection through the ctor without a pre-conversion; the
3166    /// uniform `script.to_path_buf()` one-field construction is spelled
3167    /// once — inside the ctor body — rather than at every wire-up site.
3168    /// The `prior_cleanup_kind: &'static str` parameter accepts the
3169    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3170    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3171    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3172    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3173    /// re-projection at the ctor path. The `prior_cleanup_module: &str`
3174    /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
3175    /// returns via `.expect("is_cleanup() implies declared_module() is
3176    /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
3177    /// is-`Some` composition pin at
3178    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3179    /// makes the `.expect(…)` structurally infallible at build time.
3180    ///
3181    /// Every future consumer that raises this refusal outside
3182    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
3183    /// deferred wasm-operator's `install_release/1` per-entry
3184    /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
3185    /// a future `feira validate --upgrade-from` per-caixa admission verb
3186    /// re-running the migrate-before-cleanup gate on demand, a
3187    /// per-`Caixa` overlay resolver rejecting a cluster-local
3188    /// `:state-change` overlay authored after a `:soft-purge` /
3189    /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
3190    /// webhook re-checking a per-`:upgrade-from`-patched candidate
3191    /// before the migrate-before-cleanup gate re-fires — reaches the
3192    /// variant through one call rather than re-inlining the seven-line
3193    /// struct-literal in lockstep with the sole in-crate wire-up site.
3194    #[must_use]
3195    pub fn state_change_after_cleanup(
3196        from: &str,
3197        script: &std::path::Path,
3198        prior_cleanup_kind: &'static str,
3199        prior_cleanup_module: &str,
3200    ) -> Self {
3201        Self::StateChangeAfterCleanup {
3202            from: from.to_string(),
3203            script: script.to_path_buf(),
3204            prior_cleanup_kind,
3205            prior_cleanup_module: prior_cleanup_module.to_string(),
3206        }
3207    }
3208
3209    /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
3210    /// offending `(:from <prior-versao>)` entry, the colliding `:module`
3211    /// target, and the ordered pair of colliding cleanup `:kind` lisp-
3212    /// forms (`:soft-purge` / `:purge`). Folds the uniform
3213    /// `Self::DuplicateCleanup { from: from.to_string(), module:
3214    /// module.to_string(), kinds }` three-field struct-literal onto one
3215    /// substrate primitive so every wire-up on this sole-variant within-
3216    /// entry per-module cleanup-singularity refusal envelope reads
3217    /// through one dispatch rather than the pre-lift five-line open-coded
3218    /// block. Closes the last unlifted `{ from: String, module: String,
3219    /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
3220    /// wire-up on the OTP-appup per-module cleanup-singularity axis,
3221    /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
3222    /// family ladder alongside the sibling three-slot
3223    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3224    /// ctor on the paired within-entry load → cleanup ordering axis, the
3225    /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
3226    /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
3227    /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
3228    /// standalone ctor on the migrate → cleanup boundary, the two-slot
3229    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3230    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3231    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3232    /// Sole in-crate wire-up site is inside
3233    /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
3234    /// cleanup-family dedup arm.
3235    ///
3236    /// The `from: &str` parameter accepts `&str` literals and `&String`
3237    /// via Deref coercion so the sole in-crate wire-up threads
3238    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3239    /// without a pre-conversion. The `module: &str` parameter takes the
3240    /// `&str` [`UpgradeInstruction::declared_module`] returns via
3241    /// `.expect("is_cleanup() implies declared_module() is Some")` at the
3242    /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
3243    /// composition pin at
3244    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3245    /// makes the `.expect(…)` structurally infallible at build time. The
3246    /// `kinds: Vec<&'static str>` parameter takes the ordered pair
3247    /// `vec![prior_kind, kind]` built at the caller from the two
3248    /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
3249    /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3250    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
3251    /// primitive `&'static str` projection the paired three-slot
3252    /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
3253    /// sibling load → cleanup ordering axis.
3254    ///
3255    /// Every future consumer that raises this refusal outside
3256    /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
3257    /// wasm-operator's `install_release/1` per-entry per-module
3258    /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
3259    /// future `feira validate --upgrade-from` per-caixa admission verb
3260    /// re-running the singularity pass on demand, a per-`Caixa` overlay
3261    /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
3262    /// overlay that collides with a base-entry cleanup on the same
3263    /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3264    /// re-checking a per-`:upgrade-from`-patched candidate before the
3265    /// singularity gate re-fires — reaches the variant through one call
3266    /// rather than re-inlining the five-line struct-literal in lockstep
3267    /// with the sole in-crate wire-up site.
3268    #[must_use]
3269    pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
3270        Self::DuplicateCleanup {
3271            from: from.to_string(),
3272            module: module.to_string(),
3273            kinds,
3274        }
3275    }
3276
3277    /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
3278    /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
3279    /// instruction count, and the ordered list of non-`:restart`
3280    /// instruction lisp-forms the entry mixed with the terminal fallback.
3281    /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
3282    /// restart_count, other_kinds }` three-field struct-literal onto one
3283    /// substrate primitive so every wire-up on this sole-variant within-
3284    /// entry `(:restart)`-exclusivity refusal envelope reads through one
3285    /// dispatch rather than the pre-lift five-line open-coded block. Closes
3286    /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
3287    /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
3288    /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
3289    /// the last-remaining open-coded emission site the sibling
3290    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
3291    /// the natural next lift on the `UpgradeError` envelope. Fills a peer
3292    /// three-slot rung on the `UpgradeError`-side ctor-family ladder
3293    /// alongside the sibling three-slot
3294    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3295    /// ctor on the paired within-entry load → cleanup ordering axis and
3296    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
3297    /// the per-module cleanup-singularity axis, the one-slot
3298    /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
3299    /// cross-entry duplicate-`:from` gate, the four-slot
3300    /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
3301    /// ctor on the migrate → cleanup boundary, the two-slot
3302    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3303    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3304    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3305    /// Sole in-crate wire-up site is inside
3306    /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
3307    /// arm.
3308    ///
3309    /// The `from: &str` parameter accepts `&str` literals and `&String`
3310    /// via Deref coercion so the sole in-crate wire-up threads
3311    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3312    /// without a pre-conversion. The `restart_count: usize` parameter
3313    /// takes the observed `(:restart)` occurrence count built at the
3314    /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
3315    /// — the same `IsVariant`-derived arm-discriminator dispatch the
3316    /// paired `other_kinds` projection routes through — so the diagnostic
3317    /// surfaces the duplication mode unambiguously even when `other_kinds`
3318    /// is empty (the `((:restart) (:restart))` shape the sibling
3319    /// `validate_rejects_restart_duplicated` test pins with
3320    /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
3321    /// Vec<&'static str>` parameter takes the ordered list of non-
3322    /// `:restart` instruction lisp-forms built at the caller from
3323    /// `instructions.iter().filter(|i| !i.is_restart()).map(
3324    /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
3325    /// primitive `&'static str` projection the peer three-slot
3326    /// [`UpgradeError::purge_without_prior_load`] /
3327    /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
3328    /// within-entry cleanup axes.
3329    ///
3330    /// Every future consumer that raises this refusal outside
3331    /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
3332    /// wasm-operator's `install_release/1` per-entry `(:restart)`-
3333    /// exclusivity re-checker at hot-upgrade dispatch time, a future
3334    /// `feira validate --upgrade-from` per-caixa admission verb re-running
3335    /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
3336    /// rejecting a cluster-local `(:restart)` overlay that mixes with a
3337    /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
3338    /// CR admission webhook re-checking a per-`:upgrade-from`-patched
3339    /// candidate before the exclusivity gate re-fires — reaches the
3340    /// variant through one call rather than re-inlining the five-line
3341    /// struct-literal in lockstep with the sole in-crate wire-up site.
3342    #[must_use]
3343    pub fn restart_not_exclusive(
3344        from: &str,
3345        restart_count: usize,
3346        other_kinds: Vec<&'static str>,
3347    ) -> Self {
3348        Self::RestartNotExclusive {
3349            from: from.to_string(),
3350            restart_count,
3351            other_kinds,
3352        }
3353    }
3354
3355    /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
3356    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3357    /// `:purge`), the malformed `:module` value, and the parser-shaped
3358    /// `reason` from
3359    /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3360    /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3361    /// three-field struct-literal onto one substrate primitive so every
3362    /// wire-up on this variant reads through one dispatch rather than the
3363    /// pre-lift open-coded closure block inside [`validate_module`]'s
3364    /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3365    ///
3366    /// The `kind: &'static str` parameter accepts the lisp-form
3367    /// [`UpgradeInstruction::lisp_form`] returns for the three
3368    /// [`UpgradeInstruction::declared_module`]-bearing arms —
3369    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3370    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3371    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3372    /// without a per-arm re-projection at the ctor path. The `module: &str`
3373    /// parameter threads the offending author-supplied `:module` value
3374    /// verbatim from [`UpgradeInstruction::declared_module`]. The
3375    /// `reason: impl Into<String>` bound accepts both `&str` literals and
3376    /// the `String` [`crate::render::is_dns_1123_label`] returns via
3377    /// `.into()`, matching the peer
3378    /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3379    /// [`crate::SupervisorError::child_caixa_invalid`] /
3380    /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3381    /// three-slot invalid-arm ctor discipline on the sibling
3382    /// DNS-1123-label per-envelope shape.
3383    ///
3384    /// Peer of the sibling standalone-ctor
3385    /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3386    /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3387    /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3388    /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3389    /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3390    /// [`validate_module`]'s cascade now reads through one substrate
3391    /// primitive on the invalid-arm rather than an open-coded four-line
3392    /// struct-literal in lockstep with the sole in-crate wire-up site.
3393    ///
3394    /// Every future consumer that raises this refusal outside
3395    /// [`validate_module`] — a deferred wasm-operator's
3396    /// `install_release/1` per-instruction `:module` re-validator at
3397    /// hot-upgrade dispatch time re-running the same DNS-1123-label
3398    /// floor against a candidate module reference, a future
3399    /// `feira validate --upgrade-from` per-caixa admission verb
3400    /// re-running the module-shape gate on demand, an M4
3401    /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3402    /// per-`:upgrade-from`-patched candidate before the module-shape
3403    /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3404    /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3405    /// overlay against a cluster-local snapshot — now reaches this
3406    /// variant through one call rather than re-inlining the four-line
3407    /// struct-literal in lockstep with the [`validate_module`]
3408    /// closure-form wire-up.
3409    #[must_use]
3410    pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3411        Self::ModuleInvalid {
3412            kind,
3413            module: module.to_string(),
3414            reason: reason.into(),
3415        }
3416    }
3417
3418    /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3419    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3420    /// `:purge`) at which the appup module reference is the empty
3421    /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3422    /// struct-literal onto one substrate primitive so the sole in-crate
3423    /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3424    /// at [`validate_module`] on this variant reads through one dispatch
3425    /// rather than the pre-lift open-coded block. The `kind` label
3426    /// threads verbatim from the caller-side
3427    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3428    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3429    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3430    /// roster the wire-up feeds through [`validate_module`]'s
3431    /// `kind: &'static str` parameter.
3432    ///
3433    /// Sibling of the paired three-slot [`Self::module_invalid`]
3434    /// (3d0d64a) substrate primitive on the same
3435    /// [`crate::render::require_valid_dns_1123_label`] two-closure
3436    /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3437    /// now both reach the `UpgradeError` envelope through one substrate
3438    /// primitive per typed variant, closing the pair on the OTP-appup
3439    /// per-instruction `:module` caixa-reference axis. Same shape
3440    /// discipline as the peer
3441    /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3442    /// one-slot `{ slot: &'static str }` sibling that closed the peer
3443    /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3444    /// cascade at the `:contratos <slot>` per-edge axis
3445    /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3446    /// "one substrate primitive per typed arm on both sides of a
3447    /// `require_valid_dns_1123_label` two-closure cascade, projecting
3448    /// through the caller-supplied axis-tag" discipline now extended
3449    /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3450    /// of the pair the M3 (`:contratos <slot>`) side already carries.
3451    ///
3452    /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3453    /// :instructions <kind>` tag comes from the
3454    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3455    /// carrying program-lifetime storage, matching the enum-field type
3456    /// and the [`validate_module`] wire-up's per-arm dispatch. A
3457    /// runtime-borrowed `&str` would silently downgrade the label
3458    /// lifetime and let a caller stash a non-`'static` borrow into the
3459    /// returned error. `#[must_use]` fires a compile warning at any
3460    /// wire-up that mistakenly discards the constructed error rather
3461    /// than routing it through `return Err(…)` / `.map_err(…)` / a
3462    /// closure return. `pub const fn` matches the peer per-envelope
3463    /// one-slot `Copy`-scalar ctor family discipline
3464    /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3465    /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3466    /// ctor is usable in `const` position at every wire-up site.
3467    ///
3468    /// Every future consumer that constructs `ModuleEmpty` outside
3469    /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3470    /// closure — a deferred wasm-operator's `install_release/1`
3471    /// per-instruction `:module` re-validator at hot-upgrade dispatch
3472    /// time re-running the same empty-arm floor against a candidate
3473    /// module reference, a future `feira validate --upgrade-from`
3474    /// per-caixa admission verb re-running the empty-module gate on
3475    /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3476    /// re-checking a per-`:upgrade-from`-patched candidate before the
3477    /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3478    /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3479    /// overlay against a cluster-local snapshot — now reaches this
3480    /// variant through one call rather than re-inlining the one-line
3481    /// struct-literal in lockstep with the sole in-crate wire-up site.
3482    #[must_use]
3483    pub const fn module_empty(kind: &'static str) -> Self {
3484        Self::ModuleEmpty { kind }
3485    }
3486}
3487
3488#[cfg(test)]
3489mod tests {
3490    use std::path::Path;
3491
3492    use super::*;
3493
3494    fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3495        UpgradeFromEntry {
3496            from: from.into(),
3497            instructions: instrs,
3498        }
3499    }
3500
3501    #[test]
3502    fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3503        // Fail-before-pass-after pin on
3504        // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3505        // posture. The accessor projects the per-`:upgrade-from :from`
3506        // [`String`] storage through the `pub const fn`
3507        // [`String::as_str`] (const-stable since Rust 1.87, well within
3508        // the workspace MSRV) — any future accidental downgrade to
3509        // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3510        // build time with E0015 (`cannot call non-const method`),
3511        // strictly stronger than a runtime `assert!`. Sibling of the
3512        // peer M2/M3 slot family pins on the sibling `const`-eval-
3513        // surface passes ([`crate::Caixa::nome`] /
3514        // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3515        // [`crate::aplicacao::Membro::nome`] /
3516        // [`crate::aplicacao::Membro::versao_requirement`],
3517        // [`crate::aplicacao::Entrada::hostname`] /
3518        // [`crate::aplicacao::Entrada::destination`],
3519        // [`crate::supervisor::ChildSpec::nome`] /
3520        // [`crate::supervisor::ChildSpec::versao_requirement`],
3521        // [`crate::dep::Dep::nome`] /
3522        // [`crate::dep::Dep::versao_requirement`], and the
3523        // per-`:contratos`
3524        // [`crate::aplicacao::WitContract::source`] /
3525        // [`crate::aplicacao::WitContract::destination`] /
3526        // [`crate::aplicacao::WitContract::world_ref`] trio the
3527        // sibling pin at 279823b already anchors).
3528        const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3529            e.prior_versao()
3530        }
3531        for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3532            let e = entry(from, vec![]);
3533            assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3534            assert_eq!(e.prior_versao(), from);
3535        }
3536    }
3537
3538    #[test]
3539    fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3540        // Fail-before-pass-after pin on
3541        // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3542        // posture. The accessor destructures the per-`:upgrade-from
3543        // :instructions` `Vec<UpgradeInstruction>` storage through the
3544        // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3545        // 1.66, well within the workspace MSRV) — any future
3546        // accidental downgrade to non-`const` fails
3547        // `instructions_via_const_fn` at caixa-core build time with
3548        // E0015 (`cannot call non-const method`), strictly stronger
3549        // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3550        // slot `Vec → &[T]` slice-return accessor family pin
3551        // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3552        // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3553        // per-`:membros` / per-`:contratos` slice-return axes, and of
3554        // the peer M2 supervisor-tree axis pin
3555        // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3556        // on the per-`:children` slice-return axis.
3557        const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3558            e.instructions()
3559        }
3560        // Sweep both the empty-instructions arm (author-declared
3561        // per-`:from` entry with no migration steps — the degenerate
3562        // shape the appup `restart`-only path folds through) and the
3563        // populated-instructions arm (the canonical OTP-appup shape
3564        // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3565        // chain) so the accessor carries a const-dispatch pin on
3566        // both arms.
3567        let e_empty = entry("0.1.0", vec![]);
3568        assert!(instructions_via_const_fn(&e_empty).is_empty());
3569        assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3570        let e_full = entry(
3571            "0.1.0",
3572            vec![
3573                UpgradeInstruction::LoadModule {
3574                    module: "hello-rio".into(),
3575                },
3576                UpgradeInstruction::StateChange {
3577                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3578                },
3579                UpgradeInstruction::SoftPurge {
3580                    module: "hello-rio-old".into(),
3581                },
3582            ],
3583        );
3584        assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3585        assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3586    }
3587
3588    #[test]
3589    fn round_trip_load_module() {
3590        let i = UpgradeInstruction::LoadModule {
3591            module: "hello-rio".into(),
3592        };
3593        let json = serde_json::to_string(&i).unwrap();
3594        assert!(json.contains("\"kind\":\"load-module\""));
3595        let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3596        assert_eq!(i, back);
3597    }
3598
3599    #[test]
3600    fn round_trip_all_variants() {
3601        let cases = vec![
3602            UpgradeInstruction::LoadModule { module: "x".into() },
3603            UpgradeInstruction::StateChange {
3604                script: PathBuf::from("lib/migrations.lisp"),
3605            },
3606            UpgradeInstruction::SoftPurge {
3607                module: "x-old".into(),
3608            },
3609            UpgradeInstruction::Purge {
3610                module: "x-old".into(),
3611            },
3612            UpgradeInstruction::Restart,
3613        ];
3614        for c in cases {
3615            let json = serde_json::to_string(&c).unwrap();
3616            let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3617            assert_eq!(c, back);
3618        }
3619    }
3620
3621    #[test]
3622    fn validate_accepts_well_formed() {
3623        let e = entry(
3624            "0.1.0",
3625            vec![
3626                UpgradeInstruction::LoadModule {
3627                    module: "hello-rio".into(),
3628                },
3629                UpgradeInstruction::StateChange {
3630                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3631                },
3632                UpgradeInstruction::SoftPurge {
3633                    module: "hello-rio-old".into(),
3634                },
3635            ],
3636        );
3637        e.validate().unwrap();
3638    }
3639
3640    #[test]
3641    fn validate_rejects_non_semver_from() {
3642        let e = entry("not-a-semver", vec![]);
3643        let err = e.validate().unwrap_err();
3644        assert!(
3645            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3646        );
3647    }
3648
3649    #[test]
3650    fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3651        // Diagnostic-shape pin: the error names the offending
3652        // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3653        // reason, so a `feira lint` run can render the diagnostic
3654        // without re-parsing — the author can grep their caixa.lisp for
3655        // `:from "<value>"` and fix it in one edit. Mirrors the peer
3656        // `versao_invalid_diagnostic_carries_offending_versao` pin on
3657        // the sibling SemVer-2 axis (the top-level `:versao`), the
3658        // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3659        // pin on `:membros :versao`, and the peer
3660        // `deps_invalid_diagnostic_carries_offending_value` pin on
3661        // `:deps :versao` — every SemVer-2-parsing slot's invalid
3662        // diagnostic is now structurally equivalent.
3663        let e = entry("v0.1.0", vec![]);
3664        let err = e.validate().unwrap_err();
3665        let UpgradeError::FromInvalid { from, reason } = err else {
3666            panic!("expected FromInvalid variant, got {err:?}");
3667        };
3668        assert_eq!(from, "v0.1.0");
3669        assert!(
3670            !reason.is_empty(),
3671            "FromInvalid `reason` must carry the parser's wording verbatim"
3672        );
3673    }
3674
3675    #[test]
3676    fn prior_versao_returns_from_byte_equal_across_permutations() {
3677        // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3678        // accessor across the SemVer-2 shape lattice every consumer
3679        // reaches through it — the numeric-triad canonical shape, a
3680        // pre-release build with a dotted identifier chain, a full-
3681        // metadata build, a large-magnitude triad, and the empty
3682        // string (which reaches this accessor unchanged before any
3683        // validate gate rejects it). Sibling to the peer
3684        // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3685        // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3686        // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3687        // family — extended here onto the first M2 slot scalar-value
3688        // axis. Any silent detour on the accessor (a `.to_string()`
3689        // + retained ownership shape, a canonicalization pass, a
3690        // trim-whitespace on the return path) surfaces as a byte-
3691        // inequality failure here rather than as a downstream error-
3692        // diagnostic drift.
3693        let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3694        for from in cases {
3695            let e = entry(from, vec![]);
3696            assert_eq!(
3697                e.prior_versao(),
3698                from,
3699                "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3700            );
3701            assert_eq!(
3702                e.prior_versao().len(),
3703                from.len(),
3704                "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3705            );
3706        }
3707    }
3708
3709    #[test]
3710    fn prior_versao_borrows_from_from_storage() {
3711        // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3712        // a borrow into `self.from`'s heap allocation, never a fresh
3713        // owned copy. Guards against a future silent detour where
3714        // the accessor materializes a `Cow<'_, str>` / `String` /
3715        // `Rc<str>` intermediate — the return path stays zero-cost
3716        // even under a refactor that reshapes the storage. Sibling
3717        // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3718        // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3719        // (4a32abf) pins — extended onto the M2 slot's first
3720        // scalar-value axis.
3721        let e = entry("0.1.0", vec![]);
3722        assert!(
3723            std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3724            "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3725        );
3726    }
3727
3728    #[test]
3729    fn validate_parses_prior_versao_through_lifted_accessor() {
3730        // Coherence pin between the accessor and the SemVer-2 parse
3731        // gate: every `:upgrade-from :from` value the validator
3732        // accepts (resp. rejects) must be identical to what
3733        // `Version::parse(entry.prior_versao())` accepts (resp.
3734        // rejects) — the two must remain in lockstep across the
3735        // shape lattice so `validate_upgrade_from`'s
3736        // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3737        // assertion holds by construction. If a future extension of
3738        // `prior_versao` reshapes the return (a canonicalization
3739        // pass, a leading/trailing whitespace trim, an empty-to-
3740        // "0.0.0" fallback) it would either loosen the validator
3741        // (silently accepting shapes the parser rejects) or
3742        // tighten the parser's re-parse (silently panicking on
3743        // shapes the validator accepts) — this pin catches either
3744        // shift at caixa-core build time.
3745        let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3746        for from in accepted {
3747            let e = entry(from, vec![]);
3748            e.validate().unwrap_or_else(|err| {
3749                panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3750            });
3751            semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3752                panic!(
3753                    "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3754                     got {err:?}",
3755                );
3756            });
3757        }
3758        let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3759        for from in rejected {
3760            let e = entry(from, vec![]);
3761            assert!(
3762                matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3763                "validate() must reject {from:?} that Version::parse rejects",
3764            );
3765            assert!(
3766                semver::Version::parse(e.prior_versao()).is_err(),
3767                "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3768            );
3769        }
3770    }
3771
3772    #[test]
3773    fn validate_rejects_empty_module() {
3774        // Per-arm coverage: every Module-bearing variant surfaces the
3775        // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3776        // so the author can grep their caixa.lisp for `(:load-module
3777        // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3778        // edit — same self-locating shape `BehaviorError::EmptyPath`
3779        // (b0c8389) carries on the peer M2 typed slot.
3780        let cases: &[(UpgradeInstruction, &'static str)] = &[
3781            (
3782                UpgradeInstruction::LoadModule {
3783                    module: String::new(),
3784                },
3785                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3786            ),
3787            (
3788                UpgradeInstruction::SoftPurge {
3789                    module: String::new(),
3790                },
3791                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3792            ),
3793            (
3794                UpgradeInstruction::Purge {
3795                    module: String::new(),
3796                },
3797                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3798            ),
3799        ];
3800        for (instr, expected_kind) in cases {
3801            assert_eq!(
3802                instr.validate().unwrap_err(),
3803                UpgradeError::ModuleEmpty {
3804                    kind: expected_kind
3805                },
3806                "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3807            );
3808        }
3809    }
3810
3811    #[test]
3812    fn validate_rejects_non_dns_1123_module() {
3813        // Every appup `:module` reference is a caixa name (the
3814        // wasm-engine resolves it through the same ComputeUnit
3815        // registry the operator manages), so the value-shape gate
3816        // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3817        // the canonical authoring footguns — uppercase letters, `_`
3818        // separator, embedded `.`, leading/trailing `-`, an embedded
3819        // whitespace byte, the >63-byte UUID-shaped slug — across
3820        // every Module-bearing variant; each must surface as
3821        // `ModuleInvalid { kind, module, reason }` carrying the
3822        // offending value verbatim and the parser-shaped reason.
3823        type Build = fn(String) -> UpgradeInstruction;
3824        let footguns: &[&str] = &[
3825            "Hello-Rio",
3826            "hello_rio",
3827            "hello.rio",
3828            "-hello",
3829            "hello-",
3830            "hello rio",
3831            &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3832        ];
3833        let variants: &[(Build, &'static str)] = &[
3834            (
3835                |m| UpgradeInstruction::LoadModule { module: m },
3836                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3837            ),
3838            (
3839                |m| UpgradeInstruction::SoftPurge { module: m },
3840                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3841            ),
3842            (
3843                |m| UpgradeInstruction::Purge { module: m },
3844                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3845            ),
3846        ];
3847        for (build, expected_kind) in variants {
3848            for module in footguns {
3849                let instr = build((*module).to_string());
3850                let err = instr.validate().unwrap_err();
3851                match err {
3852                    UpgradeError::ModuleInvalid {
3853                        kind,
3854                        module: m,
3855                        reason,
3856                    } => {
3857                        assert_eq!(
3858                            kind, *expected_kind,
3859                            ":module footgun on {instr:?} must tag the lisp-form"
3860                        );
3861                        assert_eq!(
3862                            m, *module,
3863                            "ModuleInvalid must carry the offending value verbatim"
3864                        );
3865                        assert!(
3866                            !reason.is_empty(),
3867                            "ModuleInvalid reason must name the specific violation \
3868                             (the predicate's parser-shaped wording from \
3869                             `is_dns_1123_label`), got empty"
3870                        );
3871                    }
3872                    other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3873                }
3874            }
3875        }
3876    }
3877
3878    #[test]
3879    fn validate_accepts_canonical_module_names() {
3880        // Positive control: every documented authoring shape — bare
3881        // identifier, with hyphens, with digits, the
3882        // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3883        // references — passes the gate. Drift here = a future
3884        // tighten that rejects any of these surfaces as a
3885        // test-failure at the predicate boundary, not piecemeal
3886        // across per-instruction call sites.
3887        let canonical: &[&str] = &[
3888            "hello-rio",
3889            "hello-rio-old",
3890            "cache",
3891            "cache-v2",
3892            "x",
3893            "a1",
3894            "0a",
3895            "abc-123-def",
3896        ];
3897        for module in canonical {
3898            UpgradeInstruction::LoadModule {
3899                module: (*module).to_string(),
3900            }
3901            .validate()
3902            .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3903            UpgradeInstruction::SoftPurge {
3904                module: (*module).to_string(),
3905            }
3906            .validate()
3907            .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3908            UpgradeInstruction::Purge {
3909                module: (*module).to_string(),
3910            }
3911            .validate()
3912            .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3913        }
3914    }
3915
3916    #[test]
3917    fn validate_empty_takes_precedence_over_invalid() {
3918        // Empty input is rejected via the narrower `ModuleEmpty`
3919        // diagnostic before the DNS-1123 predicate is consulted, so
3920        // a future tighten that adds another stage between the two
3921        // doesn't accidentally reorder the diagnostic precedence.
3922        // Mirrors the empty-first cascade on every peer DNS-1123
3923        // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3924        // `SupervisorSpec::validate`'s child-name arm).
3925        let err = UpgradeInstruction::LoadModule {
3926            module: String::new(),
3927        }
3928        .validate()
3929        .unwrap_err();
3930        assert_eq!(
3931            err,
3932            UpgradeError::ModuleEmpty {
3933                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3934            }
3935        );
3936    }
3937
3938    #[test]
3939    fn validate_rejects_empty_script() {
3940        let i = UpgradeInstruction::StateChange {
3941            script: PathBuf::new(),
3942        };
3943        assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3944    }
3945
3946    #[test]
3947    fn validate_rejects_absolute_script() {
3948        let i = UpgradeInstruction::StateChange {
3949            script: PathBuf::from("/etc/migrations.lisp"),
3950        };
3951        assert!(matches!(
3952            i.validate().unwrap_err(),
3953            UpgradeError::AbsoluteScript { .. }
3954        ));
3955    }
3956
3957    #[test]
3958    fn validate_rejects_parent_escape_script() {
3959        let i = UpgradeInstruction::StateChange {
3960            script: PathBuf::from("../sibling/migrations.lisp"),
3961        };
3962        assert!(matches!(
3963            i.validate().unwrap_err(),
3964            UpgradeError::ParentEscapeScript { .. }
3965        ));
3966        // mid-path `..` is also caught
3967        let i2 = UpgradeInstruction::StateChange {
3968            script: PathBuf::from("lib/../../escaped.lisp"),
3969        };
3970        assert!(matches!(
3971            i2.validate().unwrap_err(),
3972            UpgradeError::ParentEscapeScript { .. }
3973        ));
3974    }
3975
3976    // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3977    // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3978    // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3979    // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3980    // consumer; the file-type contract is identical, so the per-axis
3981    // test grid is mirrored leg-for-leg.
3982
3983    #[test]
3984    fn validate_rejects_no_extension_script() {
3985        // Fail-before-pass-after: the canonical "I declared the
3986        // migration script but forgot the `.lisp` extension"
3987        // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3988        // The wasm-engine's `tatara_lisp::read` consumer needs a
3989        // file-type contract beyond the structural-shape gate; a
3990        // no-extension path past `is_sandboxed_relative_path` would
3991        // surface a parser-shaped diagnostic at hot-upgrade migration
3992        // time far from the source caixa.lisp.
3993        for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3994            let i = UpgradeInstruction::StateChange {
3995                script: PathBuf::from(relpath),
3996            };
3997            let err = i.validate().unwrap_err();
3998            assert!(
3999                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4000                         if s == Path::new(relpath)),
4001                "no-extension script {relpath:?} must surface as NonLispExtensionScript \
4002                 carrying the offending path verbatim, got {err:?}"
4003            );
4004        }
4005    }
4006
4007    #[test]
4008    fn validate_rejects_non_lisp_extension_script() {
4009        // Wrong-extension sweep across common authoring footguns: the
4010        // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
4011        // drag in from the workspace tree, the `.rs` shape that an
4012        // IDE auto-complete might propose, the `.lisp.bak` shape an
4013        // editor might leave behind, and the `.lispx` near-miss that
4014        // a typo would produce. Each must surface as
4015        // `NonLispExtensionScript` carrying the offending path
4016        // verbatim — the wasm-engine's `tatara_lisp::read` consumer
4017        // rejects all of these at hot-upgrade migration time, and
4018        // the gate lifts that contract to validate time. Mirrors the
4019        // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
4020        // the `:behavior :on-*` axis leg-for-leg — same downstream
4021        // consumer, same accepted set, same per-axis test grid.
4022        let footguns: &[&str] = &[
4023            "lib/migrations.rs",
4024            "lib/migrations.txt",
4025            "lib/migrations.md",
4026            "lib/migrations.json",
4027            "lib/migrations.yaml",
4028            "lib/migrations.toml",
4029            "lib/migrations.lisp.bak",
4030            "lib/migrations.lispx",
4031            "lib/migrations.lis",
4032        ];
4033        for relpath in footguns {
4034            let i = UpgradeInstruction::StateChange {
4035                script: PathBuf::from(relpath),
4036            };
4037            let err = i.validate().unwrap_err();
4038            assert!(
4039                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4040                         if s == Path::new(relpath)),
4041                "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
4042                 carrying the offending path verbatim, got {err:?}"
4043            );
4044        }
4045    }
4046
4047    #[test]
4048    fn validate_rejects_uppercase_lisp_extension_script() {
4049        // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
4050        // case-folded shapes a case-insensitive volume's existence
4051        // check would match the on-disk file — but the
4052        // canonical-form codec emits lowercase `.lisp` verbatim, so
4053        // a case-folded shape mismatches the round-trip-stable
4054        // canonical form (THEORY.md §V.2.7 render-determinism).
4055        // Same case-sensitive discipline the byte-size / duration
4056        // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
4057        // and every other shape-gate predicate in `render.rs` (label
4058        // / scheme / unit boundaries). Mirrors the peer
4059        // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
4060        for relpath in [
4061            "lib/migrations.LISP",
4062            "lib/migrations.Lisp",
4063            "lib/migrations.LiSp",
4064            "lib/migrations.lISP",
4065        ] {
4066            let i = UpgradeInstruction::StateChange {
4067                script: PathBuf::from(relpath),
4068            };
4069            let err = i.validate().unwrap_err();
4070            assert!(
4071                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4072                         if s == Path::new(relpath)),
4073                "case-folded `.lisp` extension {relpath:?} must surface as \
4074                 NonLispExtensionScript (strict lowercase, canonical-form \
4075                 round-trip pin), got {err:?}"
4076            );
4077        }
4078    }
4079
4080    #[test]
4081    fn validate_accepts_canonical_lisp_extension_scripts() {
4082        // Positive-control sweep across every canonical in-tree
4083        // authoring shape: bare filename, standard `lib/`
4084        // subdirectory, deeply-nested migrations subdirectory,
4085        // explicit current-dir-relative prefix, mid-path `./`
4086        // segment, multi-dot stem (the version-suffix shape
4087        // `lib/migrations/v.0.1.lisp` an author might use to encode
4088        // the migration's `:from` version into the filename). Drift
4089        // here = a future tightening that rejects any of these
4090        // surfaces as a test-failure at the per-axis validator
4091        // boundary, not piecemeal across renderer / layout-checker
4092        // call sites. Mirrors the peer `BehaviorSpec` positive-set
4093        // sweep (c97815a).
4094        let canonical: &[&str] = &[
4095            "lib/migrations.lisp",
4096            "lib/migrations/v01-to-v02.lisp",
4097            "migrations.lisp",
4098            "a.lisp",
4099            "./lib/migrations.lisp",
4100            "lib/./migrations.lisp",
4101            "lib/migrations/v.0.1.lisp",
4102        ];
4103        for relpath in canonical {
4104            UpgradeInstruction::StateChange {
4105                script: PathBuf::from(relpath),
4106            }
4107            .validate()
4108            .unwrap_or_else(|e| {
4109                panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
4110            });
4111        }
4112    }
4113
4114    #[test]
4115    fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
4116        // Cross-arm precedence pin: a script that is *both*
4117        // sandbox-escaping (Empty / Absolute / ParentEscape) and
4118        // non-`.lisp` must surface the more-fundamental
4119        // sandbox-shape diagnostic first — the canonical fix
4120        // collapses both into "pin a relative `.lisp` path under the
4121        // caixa root", and the `.lisp` remediation would be
4122        // misleading when the offending path can never resolve under
4123        // the caixa root anyway. Mirrors the peer
4124        // `BehaviorError` cross-arm precedence (c97815a) and the
4125        // sibling `LimitsError`
4126        // (`MemoryZero` → `MemoryBelowWasm32Page` →
4127        // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
4128        // smallest-scope-arm-fires-last posture.
4129        let i_empty = UpgradeInstruction::StateChange {
4130            script: PathBuf::new(),
4131        };
4132        assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
4133        let i_abs = UpgradeInstruction::StateChange {
4134            script: PathBuf::from("/etc/migrations.txt"),
4135        };
4136        assert!(
4137            matches!(
4138                i_abs.validate().unwrap_err(),
4139                UpgradeError::AbsoluteScript { .. }
4140            ),
4141            "absolute + non-`.lisp` must surface AbsoluteScript first"
4142        );
4143        let i_esc = UpgradeInstruction::StateChange {
4144            script: PathBuf::from("../sibling/migrations.rs"),
4145        };
4146        assert!(
4147            matches!(
4148                i_esc.validate().unwrap_err(),
4149                UpgradeError::ParentEscapeScript { .. }
4150            ),
4151            "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
4152        );
4153    }
4154
4155    #[test]
4156    fn non_lisp_extension_script_diagnostic_carries_offending_path() {
4157        // Diagnostic-shape pin: the surfaced error message names the
4158        // offending path verbatim (so the author can grep their
4159        // caixa.lisp for the literal value), the `.lisp` extension
4160        // is named in the remediation, and the downstream consumer
4161        // (`tatara_lisp::read` at hot-upgrade migration time) is
4162        // named so the author can trace the contract back to its
4163        // source. Same self-locating shape every per-axis variant
4164        // carries (`BehaviorError::NonLispExtension`, c97815a;
4165        // `LimitsError::MemoryNotPageMultiple`, ec266d8).
4166        let bad = PathBuf::from("lib/migrations.txt");
4167        let err = UpgradeInstruction::StateChange {
4168            script: bad.clone(),
4169        }
4170        .validate()
4171        .unwrap_err();
4172        let msg = err.to_string();
4173        assert!(
4174            msg.contains("lib/migrations.txt"),
4175            "diagnostic must name the offending path verbatim, got {msg:?}"
4176        );
4177        assert!(
4178            msg.contains(".lisp"),
4179            "diagnostic must name the expected `.lisp` extension, got {msg:?}"
4180        );
4181        assert!(
4182            msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
4183            "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
4184        );
4185        match err {
4186            UpgradeError::NonLispExtensionScript { script } => {
4187                assert_eq!(
4188                    script, bad,
4189                    "variant must carry the offending path verbatim"
4190                );
4191            }
4192            other => panic!("expected NonLispExtensionScript, got {other:?}"),
4193        }
4194    }
4195
4196    #[test]
4197    fn declared_path_only_for_state_change() {
4198        let load = UpgradeInstruction::LoadModule { module: "x".into() };
4199        assert!(load.declared_path().is_none());
4200        let mig = UpgradeInstruction::StateChange {
4201            script: PathBuf::from("lib/m.lisp"),
4202        };
4203        assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
4204    }
4205
4206    #[test]
4207    fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
4208        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4209        // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
4210        // predicate: [`UpgradeInstruction::Restart`] is the only variant
4211        // that satisfies `.is_restart()`; every module-bearing arm
4212        // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
4213        // `StateChange` arm all return `false`. This pin makes the
4214        // partition invariant load-bearing at caixa-core test time so a
4215        // future derive regression (a hole that returns `false` for
4216        // `Restart` too, or a byte-collision that flips a second variant
4217        // to `true`) trips here rather than laundering the arm at
4218        // [`Self::validate_restart_exclusive`]'s paired positive /
4219        // negated filter sites (a hole flips restart-count to 0 →
4220        // vacuous OK; a collision flips restart-count > 1 → false
4221        // `RestartNotExclusive` on an entry the author declared without
4222        // any `(:restart)`). Peer of the sibling
4223        // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
4224        // pin on the M0 `CaixaKind` axis.
4225        let cases: &[(UpgradeInstruction, bool)] = &[
4226            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4227            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4228            (UpgradeInstruction::Purge { module: "c".into() }, false),
4229            (
4230                UpgradeInstruction::StateChange {
4231                    script: PathBuf::from("lib/m.lisp"),
4232                },
4233                false,
4234            ),
4235            (UpgradeInstruction::Restart, true),
4236        ];
4237        for (variant, expected) in cases {
4238            assert_eq!(
4239                variant.is_restart(),
4240                *expected,
4241                "UpgradeInstruction::{variant:?}.is_restart() must \
4242                 return {expected} (partition invariant on the \
4243                 IsVariant-derived arm-discriminator predicate)"
4244            );
4245        }
4246    }
4247
4248    #[test]
4249    fn validate_restart_exclusive_routes_through_is_restart_predicate() {
4250        // Byte-identity pin on the paired positive / negated
4251        // `.is_restart()` filters at
4252        // [`Self::validate_restart_exclusive`] against the pre-lift
4253        // `matches!(i, UpgradeInstruction::Restart)` /
4254        // `!matches!(i, UpgradeInstruction::Restart)` predicates every
4255        // consumer of the gate previously coupled to inline. Asserts
4256        // the two projections agree byte-for-byte on every arm of the
4257        // enum, so a future derive regression that flipped either
4258        // predicate's arm-set would surface here at caixa-core test
4259        // time rather than at
4260        // [`Self::validate_restart_exclusive`]'s per-entry restart-
4261        // count / other-kinds tabulation far from the derive site.
4262        // Same peer-shape pin every sibling
4263        // `IsVariant`-derive-routed gate carries on the substrate's
4264        // closed-set typed-enum surface.
4265        let cases: Vec<UpgradeInstruction> = vec![
4266            UpgradeInstruction::LoadModule { module: "a".into() },
4267            UpgradeInstruction::SoftPurge { module: "b".into() },
4268            UpgradeInstruction::Purge { module: "c".into() },
4269            UpgradeInstruction::StateChange {
4270                script: PathBuf::from("lib/m.lisp"),
4271            },
4272            UpgradeInstruction::Restart,
4273        ];
4274        for instr in &cases {
4275            let via_predicate = instr.is_restart();
4276            let via_matches = matches!(instr, UpgradeInstruction::Restart);
4277            assert_eq!(
4278                via_predicate, via_matches,
4279                "UpgradeInstruction::{instr:?}: is_restart() must \
4280                 byte-equal matches!(_, UpgradeInstruction::Restart) — \
4281                 the pre-lift open-coded pattern and the \
4282                 IsVariant-derived predicate are the same axis, \
4283                 one typed dispatch"
4284            );
4285        }
4286    }
4287
4288    #[test]
4289    fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
4290        // The fail-before-pass-after pin on the lifted
4291        // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
4292        // arm-discriminator predicate:
4293        // [`UpgradeInstruction::SoftPurge`] and
4294        // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
4295        // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
4296        // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
4297        // on the paired two-phase-load half,
4298        // [`UpgradeInstruction::StateChange`] on the
4299        // `gen_server:code_change/3`-analog migration axis,
4300        // [`UpgradeInstruction::Restart`] on the OTP terminal-
4301        // fallback shape) returns `false`. This pin makes the
4302        // partition invariant load-bearing at caixa-core test time
4303        // so a future accessor regression (a hole that returns
4304        // `false` for `SoftPurge` or `Purge`, or a byte-collision
4305        // that flips `LoadModule` / `StateChange` / `Restart` to
4306        // `true`) trips here rather than laundering the arm at the
4307        // three within-entry cross-instruction cleanup-facing gates
4308        // ([`UpgradeFromEntry::validate_purge_ordering`],
4309        // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
4310        // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
4311        // hole would silently accept a cleanup-shaped entry the
4312        // three gates should refuse; a collision would fire a
4313        // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
4314        // `DuplicateCleanup` refusal on a well-shaped
4315        // [`UpgradeInstruction::LoadModule`] / `StateChange` /
4316        // `Restart` arm the three gates should pass through. Peer
4317        // of the sibling
4318        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4319        // pin on the single-arm terminal-fallback partition —
4320        // extended here from the single-arm case onto the two-arm
4321        // cleanup-family union case.
4322        let cases: &[(UpgradeInstruction, bool)] = &[
4323            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4324            (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
4325            (UpgradeInstruction::Purge { module: "c".into() }, true),
4326            (
4327                UpgradeInstruction::StateChange {
4328                    script: PathBuf::from("lib/m.lisp"),
4329                },
4330                false,
4331            ),
4332            (UpgradeInstruction::Restart, false),
4333        ];
4334        for (variant, expected) in cases {
4335            assert_eq!(
4336                variant.is_cleanup(),
4337                *expected,
4338                "UpgradeInstruction::{variant:?}.is_cleanup() must \
4339                 return {expected} (partition invariant on the \
4340                 lifted OTP-appup two-arm cleanup-family arm-\
4341                 discriminator predicate)"
4342            );
4343        }
4344    }
4345
4346    #[test]
4347    fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
4348        // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
4349        // composition against the two [`gen_platform::IsVariant`]-
4350        // derive-generated per-variant classifiers it routes through
4351        // — the accessor's one body must byte-equal
4352        // `self.is_soft_purge() || self.is_purge()` across every arm
4353        // of the closed-set enum, so a future silent detour that
4354        // reintroduced a raw `matches!` pattern or that stopped
4355        // composing through the derive-generated per-variant
4356        // predicates (an accidental `self.is_soft_purge()` on its
4357        // own — silently dropping the `Purge` arm; an accidental
4358        // `self.is_purge() || self.is_state_change()` — silently
4359        // folding the migration arm into the cleanup family; a
4360        // typo `&&` for the union `||` — silently classifying no
4361        // arm as cleanup) trips here at caixa-core test time
4362        // rather than laundering the arm at the three within-entry
4363        // cross-instruction cleanup-facing gates. Same peer-shape
4364        // pin the sibling
4365        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4366        // carries on the paired terminal-fallback axis.
4367        let cases: Vec<UpgradeInstruction> = vec![
4368            UpgradeInstruction::LoadModule { module: "a".into() },
4369            UpgradeInstruction::SoftPurge { module: "b".into() },
4370            UpgradeInstruction::Purge { module: "c".into() },
4371            UpgradeInstruction::StateChange {
4372                script: PathBuf::from("lib/m.lisp"),
4373            },
4374            UpgradeInstruction::Restart,
4375        ];
4376        for instr in &cases {
4377            let via_predicate = instr.is_cleanup();
4378            let via_composition = instr.is_soft_purge() || instr.is_purge();
4379            assert_eq!(
4380                via_predicate, via_composition,
4381                "UpgradeInstruction::{instr:?}: is_cleanup() must \
4382                 byte-equal is_soft_purge() || is_purge() — the \
4383                 lifted union predicate and its per-variant \
4384                 composition are the same axis, one typed dispatch"
4385            );
4386        }
4387    }
4388
4389    #[test]
4390    fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4391        // Composition-pin the load-bearing invariant every consumer
4392        // that routes through `is_cleanup()` + `declared_module()`
4393        // relies on: any [`UpgradeInstruction`] value whose
4394        // `.is_cleanup()` returns `true` must have a `Some(_)`
4395        // `.declared_module()`. This makes the three within-entry
4396        // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4397        // implies declared_module() is Some")` structurally
4398        // infallible at build time — a future refactor that added
4399        // a cleanup-shaped variant carrying no `:module` would trip
4400        // here rather than panic at
4401        // [`UpgradeFromEntry::validate_purge_ordering`] /
4402        // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4403        // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4404        // runtime on the offending author's caixa.lisp.
4405        let cases: Vec<UpgradeInstruction> = vec![
4406            UpgradeInstruction::LoadModule { module: "a".into() },
4407            UpgradeInstruction::SoftPurge { module: "b".into() },
4408            UpgradeInstruction::Purge { module: "c".into() },
4409            UpgradeInstruction::StateChange {
4410                script: PathBuf::from("lib/m.lisp"),
4411            },
4412            UpgradeInstruction::Restart,
4413        ];
4414        for instr in &cases {
4415            if instr.is_cleanup() {
4416                assert!(
4417                    instr.declared_module().is_some(),
4418                    "UpgradeInstruction::{instr:?}: is_cleanup() \
4419                     must imply declared_module().is_some() — the \
4420                     three within-entry cross-instruction cleanup-\
4421                     facing gates rely on this invariant to route \
4422                     the cleanup-target :module scalar through the \
4423                     sibling declared_module accessor without a \
4424                     pattern-bound `module` binding"
4425                );
4426            }
4427        }
4428    }
4429
4430    #[test]
4431    fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4432        // Composition-pin the load-bearing invariant
4433        // [`UpgradeFromEntry::validate_load_singularity`] relies on
4434        // when routing the per-instruction load-family arm-discriminator
4435        // through the sibling
4436        // [`UpgradeInstruction::is_load_module`] +
4437        // [`UpgradeInstruction::declared_module`] accessor pair: any
4438        // [`UpgradeInstruction`] value whose `.is_load_module()`
4439        // returns `true` must have a `Some(_)` `.declared_module()`.
4440        // This makes the gate's `.expect("is_load_module() implies
4441        // declared_module() is Some")` structurally infallible at
4442        // build time — a future refactor that added a load-shaped
4443        // variant carrying no `:module` would trip here rather than
4444        // panic at [`UpgradeFromEntry::validate_load_singularity`]
4445        // at runtime on the offending author's caixa.lisp. Sibling
4446        // of the peer
4447        // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4448        // composition pin on the two-arm cleanup-family axis — same
4449        // "predicate implies accessor" discipline extended onto the
4450        // single-arm load-family axis, closes the load-vs-cleanup
4451        // pair on the substrate primitive's typed dispatch discipline.
4452        let cases: Vec<UpgradeInstruction> = vec![
4453            UpgradeInstruction::LoadModule { module: "a".into() },
4454            UpgradeInstruction::SoftPurge { module: "b".into() },
4455            UpgradeInstruction::Purge { module: "c".into() },
4456            UpgradeInstruction::StateChange {
4457                script: PathBuf::from("lib/m.lisp"),
4458            },
4459            UpgradeInstruction::Restart,
4460        ];
4461        for instr in &cases {
4462            if instr.is_load_module() {
4463                assert!(
4464                    instr.declared_module().is_some(),
4465                    "UpgradeInstruction::{instr:?}: is_load_module() \
4466                     must imply declared_module().is_some() — the \
4467                     within-entry load-singularity gate relies on this \
4468                     invariant to route the load-target :module scalar \
4469                     through the sibling declared_module accessor \
4470                     without a pattern-bound `module` binding"
4471                );
4472            }
4473        }
4474    }
4475
4476    #[test]
4477    fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4478     {
4479        // Byte-identity pin on the
4480        // [`UpgradeFromEntry::validate_load_singularity`] load-family
4481        // dispatch against the pre-lift
4482        // `match instr { UpgradeInstruction::LoadModule { module } =>
4483        // module.as_str(), _ => continue }` open-coded pattern-match
4484        // the site previously carried. Asserts the two projections
4485        // agree byte-for-byte on every arm of the enum — the
4486        // arm-discriminator via `is_load_module()` and the `:module`
4487        // scalar via `declared_module()` — so a future derive
4488        // regression that flipped the predicate's arm-set (a hole
4489        // returning `false` for [`UpgradeInstruction::LoadModule`], a
4490        // byte-collision flipping a second variant to `true`) or an
4491        // accessor extension that promoted an additional variant onto
4492        // the `String`-carrying axis would trip here at caixa-core
4493        // test time rather than laundering the arm at the gate's
4494        // per-entry load-singularity scan far from the derive site.
4495        // Peer of the sibling
4496        // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4497        // byte-identity pin on the paired ordering-side load-family
4498        // sticky-latch dispatch (both consumers now agree on one
4499        // typed dispatch for the load-family axis) and the peer
4500        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4501        // pin on the migration-family script-projection axis — the
4502        // three within-entry per-instruction-class singularity gates
4503        // now share one byte-identity pin apiece against their
4504        // respective substrate-primitive typed dispatches.
4505        //
4506        // Three-arm projective coverage:
4507        //   (a) `LoadModule` modules project through
4508        //       `declared_module()` byte-equal to the raw
4509        //       `module.as_str()` field access;
4510        //   (b) a duplicate-`LoadModule` input trips the gate on the
4511        //       second occurrence with `DuplicateLoadModule` carrying
4512        //       the offending module verbatim;
4513        //   (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4514        //       `StateChange` / `Restart`) leaves the gate vacuous
4515        //       with `Ok(())` — the `!instr.is_load_module()`
4516        //       `continue` fall-through pins.
4517        //
4518        // Fail-before-pass-after verified locally: swapping the
4519        // production `if !instr.is_load_module() { continue; } let
4520        // module = instr.declared_module().expect(…);` back to `let
4521        // module = match instr { UpgradeInstruction::LoadModule
4522        // { module } => module.as_str(), _ => continue, };` keeps
4523        // arms (a)-(c) passing but silently detaches the gate from
4524        // the accessor's typed dispatch — any future
4525        // `is_load_module` / `declared_module` extension (a hole in
4526        // either predicate, a promotion of an additional variant
4527        // onto the `String`-carrying axis, an operator-side
4528        // pre-parsed caixa-name cache the accessor materializes)
4529        // would then silently disagree between this gate's raw
4530        // pattern-match and the peer per-`UpgradeInstruction`
4531        // consumers that route through the accessor pair.
4532
4533        // (a) LoadModule projection byte-equal via
4534        //     is_load_module() + declared_module().
4535        let lm = UpgradeInstruction::LoadModule {
4536            module: "hello-rio".into(),
4537        };
4538        assert!(
4539            lm.is_load_module(),
4540            "LoadModule must satisfy is_load_module() — the gate's \
4541             load-family arm-discriminator relies on this partition"
4542        );
4543        assert_eq!(
4544            lm.declared_module(),
4545            Some("hello-rio"),
4546            "declared_module() must project the LoadModule :module \
4547             byte-equal to the raw field access — accessor divergence \
4548             would silently detach the gate from the projection every \
4549             peer per-`UpgradeInstruction` consumer routes through"
4550        );
4551
4552        // (b) Duplicate-LoadModule input trips the gate.
4553        let dup = entry(
4554            "0.1.0",
4555            vec![
4556                UpgradeInstruction::LoadModule { module: "x".into() },
4557                UpgradeInstruction::LoadModule { module: "x".into() },
4558            ],
4559        );
4560        assert_eq!(
4561            dup.validate_load_singularity(),
4562            Err(UpgradeError::DuplicateLoadModule {
4563                from: "0.1.0".into(),
4564                module: "x".into(),
4565            }),
4566            "duplicate LoadModule modules within one entry must fire \
4567             DuplicateLoadModule byte-identical to the pre-lift \
4568             pattern-match shape"
4569        );
4570
4571        // (c) Non-LoadModule-only input leaves the gate vacuous.
4572        let no_load = entry(
4573            "0.1.0",
4574            vec![
4575                UpgradeInstruction::StateChange {
4576                    script: PathBuf::from("lib/m.lisp"),
4577                },
4578                UpgradeInstruction::Restart,
4579            ],
4580        );
4581        assert_eq!(
4582            no_load.validate_load_singularity(),
4583            Ok(()),
4584            "non-LoadModule-only entries must leave the load-\
4585             singularity gate vacuous — the `!is_load_module()` \
4586             continue fall-through pins"
4587        );
4588    }
4589
4590    #[test]
4591    fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4592        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4593        // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4594        // predicate: [`UpgradeInstruction::LoadModule`] is the only
4595        // variant that satisfies `.is_load_module()`; every cleanup arm
4596        // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4597        // and the terminal-fallback arm (`Restart`) all return `false`.
4598        // This pin makes the partition invariant load-bearing at
4599        // caixa-core test time so a future derive regression (a hole
4600        // that returns `false` for `LoadModule` too, or a byte-collision
4601        // that flips a second variant to `true`) trips here rather than
4602        // laundering the arm at
4603        // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4604        // dispatch — a hole would silently keep `loaded = false` through
4605        // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4606        // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4607        // a collision would flip `loaded = true` on a well-shaped
4608        // cleanup-only entry and silently swallow the load-less
4609        // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4610        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4611        // and
4612        // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4613        // pins on the paired terminal-fallback and cleanup-family
4614        // arm-discriminator axes — closes the last unlifted `matches!`-
4615        // based arm-discriminator axis on the OTP-appup closed-set
4616        // typed enum.
4617        let cases: &[(UpgradeInstruction, bool)] = &[
4618            (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4619            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4620            (UpgradeInstruction::Purge { module: "c".into() }, false),
4621            (
4622                UpgradeInstruction::StateChange {
4623                    script: PathBuf::from("lib/m.lisp"),
4624                },
4625                false,
4626            ),
4627            (UpgradeInstruction::Restart, false),
4628        ];
4629        for (variant, expected) in cases {
4630            assert_eq!(
4631                variant.is_load_module(),
4632                *expected,
4633                "UpgradeInstruction::{variant:?}.is_load_module() must \
4634                 return {expected} (partition invariant on the \
4635                 IsVariant-derived arm-discriminator predicate)"
4636            );
4637        }
4638    }
4639
4640    #[test]
4641    fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4642        // Byte-identity pin on the [`Self::validate_purge_ordering`]
4643        // load-family sticky-latch dispatch against the pre-lift
4644        // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4645        // predicate the site previously open-coded. Asserts the two
4646        // projections agree byte-for-byte on every arm of the enum, so
4647        // a future derive regression that flipped the predicate's
4648        // arm-set would surface here at caixa-core test time rather
4649        // than at [`Self::validate_purge_ordering`]'s per-entry
4650        // load-before-cleanup ordering scan far from the derive site.
4651        // Same peer-shape pin the sibling
4652        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4653        // carries on the paired terminal-fallback axis and the
4654        // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4655        // carries on the two-arm cleanup-family axis — the third and
4656        // final byte-identity pin closes the substrate primitive's
4657        // arm-discriminator dispatch discipline on the OTP-appup
4658        // closed-set typed enum.
4659        let cases: Vec<UpgradeInstruction> = vec![
4660            UpgradeInstruction::LoadModule { module: "a".into() },
4661            UpgradeInstruction::SoftPurge { module: "b".into() },
4662            UpgradeInstruction::Purge { module: "c".into() },
4663            UpgradeInstruction::StateChange {
4664                script: PathBuf::from("lib/m.lisp"),
4665            },
4666            UpgradeInstruction::Restart,
4667        ];
4668        for instr in &cases {
4669            let via_predicate = instr.is_load_module();
4670            let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4671            assert_eq!(
4672                via_predicate, via_matches,
4673                "UpgradeInstruction::{instr:?}: is_load_module() must \
4674                 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4675                 {{ .. }}) — the pre-lift open-coded pattern and the \
4676                 IsVariant-derived predicate are the same axis, one \
4677                 typed dispatch"
4678            );
4679        }
4680    }
4681
4682    #[test]
4683    fn declared_module_only_for_module_bearing_variants() {
4684        // Pinned partition of the `UpgradeInstruction` closed-set
4685        // variant space against the sibling of the peer
4686        // `declared_path` accessor: every OTP-appup module-bearing
4687        // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4688        // `:module` string byte-for-byte through the lifted
4689        // `declared_module` accessor; every non-module-bearing variant
4690        // (`StateChange` on the peer `:script`-carrying axis;
4691        // `Restart` on the OTP terminal-fallback data-less axis)
4692        // returns `None`. Mirrors the peer
4693        // `declared_path_only_for_state_change` pin — the pair now
4694        // closes both scalar-carrying axes on the enum on one lifted
4695        // `Option<&…>` accessor apiece.
4696        let load = UpgradeInstruction::LoadModule {
4697            module: "hello-rio".into(),
4698        };
4699        assert_eq!(load.declared_module(), Some("hello-rio"));
4700        let soft = UpgradeInstruction::SoftPurge {
4701            module: "hello-rio-old".into(),
4702        };
4703        assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4704        let hard = UpgradeInstruction::Purge {
4705            module: "hello-rio-ancient".into(),
4706        };
4707        assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4708        let mig = UpgradeInstruction::StateChange {
4709            script: PathBuf::from("lib/m.lisp"),
4710        };
4711        assert!(mig.declared_module().is_none());
4712        assert!(UpgradeInstruction::Restart.declared_module().is_none());
4713    }
4714
4715    #[test]
4716    fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4717        // Byte-identity pin on the two-accessor partition: every
4718        // `UpgradeInstruction` variant returns `Some` from *exactly
4719        // one* of {`declared_module`, `declared_path`} (the two
4720        // module-bearing / script-carrying axes) or from *neither*
4721        // (the OTP terminal-fallback `Restart` shape). No variant
4722        // returns `Some` from both — the two axes are disjoint by
4723        // construction, and this pin closes the disjointness at the
4724        // test surface so a future variant that leaks a scalar across
4725        // both axes fails at build time. Mirrors the peer
4726        // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4727        // discipline on the `BehaviorSpec` per-slot family.
4728        let cases: Vec<UpgradeInstruction> = vec![
4729            UpgradeInstruction::LoadModule { module: "a".into() },
4730            UpgradeInstruction::SoftPurge { module: "b".into() },
4731            UpgradeInstruction::Purge { module: "c".into() },
4732            UpgradeInstruction::StateChange {
4733                script: PathBuf::from("lib/m.lisp"),
4734            },
4735            UpgradeInstruction::Restart,
4736        ];
4737        for instr in &cases {
4738            let has_module = instr.declared_module().is_some();
4739            let has_path = instr.declared_path().is_some();
4740            assert!(
4741                !(has_module && has_path),
4742                "no variant may declare both a module and a path — offending: {instr:?}"
4743            );
4744            match instr {
4745                UpgradeInstruction::LoadModule { .. }
4746                | UpgradeInstruction::SoftPurge { .. }
4747                | UpgradeInstruction::Purge { .. } => {
4748                    assert!(has_module && !has_path, "module axis: {instr:?}");
4749                }
4750                UpgradeInstruction::StateChange { .. } => {
4751                    assert!(!has_module && has_path, "script axis: {instr:?}");
4752                }
4753                UpgradeInstruction::Restart => {
4754                    assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4755                }
4756            }
4757        }
4758    }
4759
4760    #[test]
4761    fn entry_with_chain_of_versions() {
4762        // Middle entry pairs a `:load-module` with the trailing
4763        // `:soft-purge` so it satisfies the within-entry purge-ordering
4764        // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4765        // preceding `:load-module`, mirroring the state-change-ordering
4766        // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4767        // test is *cross-entry* `:from` values; the within-entry shape
4768        // is incidental — keeping it canonical (`:load-module` before
4769        // `:soft-purge`) leaves the chain assertion load-bearing.
4770        let entries = vec![
4771            entry(
4772                "0.1.0",
4773                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4774            ),
4775            entry(
4776                "0.1.5",
4777                vec![
4778                    UpgradeInstruction::LoadModule { module: "x".into() },
4779                    UpgradeInstruction::SoftPurge {
4780                        module: "x-old".into(),
4781                    },
4782                ],
4783            ),
4784            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4785        ];
4786        for e in &entries {
4787            e.validate().unwrap();
4788        }
4789        let json = serde_json::to_string(&entries).unwrap();
4790        let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4791        assert_eq!(entries, back);
4792    }
4793
4794    #[test]
4795    fn empty_instructions_list_is_valid() {
4796        let e = entry("0.1.0", vec![]);
4797        e.validate().unwrap();
4798    }
4799
4800    #[test]
4801    fn json_uses_kebab_case_kind_tags() {
4802        let i = UpgradeInstruction::SoftPurge {
4803            module: "x-old".into(),
4804        };
4805        let json = serde_json::to_string(&i).unwrap();
4806        assert!(json.contains("\"kind\":\"soft-purge\""));
4807        let i2 = UpgradeInstruction::StateChange {
4808            script: PathBuf::from("m.lisp"),
4809        };
4810        let json2 = serde_json::to_string(&i2).unwrap();
4811        assert!(json2.contains("\"kind\":\"state-change\""));
4812    }
4813
4814    // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4815
4816    #[test]
4817    fn validate_upgrade_from_accepts_disjoint_versions() {
4818        // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4819        // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4820        // (and `entry_with_chain_of_versions` above) passes the cross-
4821        // entry gate. Different `:from` per entry is the intended
4822        // shape; the gate must not regress this baseline. Middle entry
4823        // pairs `:load-module` with `:soft-purge` to satisfy the
4824        // within-entry purge-ordering gate (see
4825        // `entry_with_chain_of_versions` for the same shape).
4826        let entries = vec![
4827            entry(
4828                "0.1.0",
4829                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4830            ),
4831            entry(
4832                "0.1.5",
4833                vec![
4834                    UpgradeInstruction::LoadModule { module: "x".into() },
4835                    UpgradeInstruction::SoftPurge {
4836                        module: "x-old".into(),
4837                    },
4838                ],
4839            ),
4840            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4841        ];
4842        validate_upgrade_from(&entries).unwrap();
4843    }
4844
4845    #[test]
4846    fn validate_upgrade_from_accepts_empty_list() {
4847        // Absent `:upgrade-from` (the bare `feira init` shape) — the
4848        // gate must trivially pass an empty list. Mirrors the per-axis
4849        // "empty list passes" positive control on every peer typed-
4850        // graph gate (`validate_membros` empty list, `validate_placement`
4851        // requires non-empty clusters but only after a `Placement`
4852        // exists, etc.).
4853        validate_upgrade_from(&[]).unwrap();
4854    }
4855
4856    #[test]
4857    fn validate_upgrade_from_rejects_duplicate_from() {
4858        // Fail-before-pass-after pin: two entries with the same parsed-
4859        // semver `:from` are an ambiguous edge in the typed upgrade
4860        // graph (OTP appup picks at most one matching block per running
4861        // version; with two matching blocks the operator picks either
4862        // set non-deterministically — author intent is one path per
4863        // prior version). Same set-not-multiset discipline as
4864        // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4865        // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4866        // `:entrada :paths` (eb3456d) — now extended onto the fifth
4867        // typed-graph axis.
4868        let entries = vec![
4869            entry(
4870                "0.1.0",
4871                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4872            ),
4873            entry(
4874                "0.1.0",
4875                vec![
4876                    UpgradeInstruction::LoadModule { module: "x".into() },
4877                    UpgradeInstruction::SoftPurge {
4878                        module: "x-old".into(),
4879                    },
4880                ],
4881            ),
4882        ];
4883        let err = validate_upgrade_from(&entries).unwrap_err();
4884        assert_eq!(
4885            err,
4886            UpgradeError::DuplicateFrom {
4887                from: "0.1.0".into()
4888            },
4889            "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4890             offending value verbatim"
4891        );
4892    }
4893
4894    #[test]
4895    fn validate_upgrade_from_treats_pre_release_as_distinct() {
4896        // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4897        // equal under semver (pre-release version is part of the
4898        // identity), so they're distinct upgrade paths and must not
4899        // collide. A future tightening that collapses pre-release into
4900        // the release version surfaces here.
4901        let entries = vec![
4902            entry("1.0.0", vec![UpgradeInstruction::Restart]),
4903            entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4904        ];
4905        validate_upgrade_from(&entries).unwrap();
4906    }
4907
4908    #[test]
4909    fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4910        // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4911        // compares build metadata (it derives equality across all
4912        // fields including `pre` + `build`), so `1.0.0+build1` and
4913        // `1.0.0+build2` are *not* duplicates from the gate's
4914        // perspective — the operator may treat the build-metadata
4915        // suffix as a tiebreaker even though the semver spec says
4916        // build metadata is ignored for precedence
4917        // (https://semver.org/#spec-item-10). Pin the conservative
4918        // behavior here so a future switch to a build-metadata-
4919        // stripping comparator surfaces as a test failure first; that
4920        // change would require coordinating with the wasm-operator's
4921        // `:from`-match dispatch step, which is the load-bearing
4922        // semantic we'd be mirroring.
4923        let entries = vec![
4924            entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4925            entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4926        ];
4927        validate_upgrade_from(&entries).unwrap();
4928    }
4929
4930    #[test]
4931    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4932        // Order pin: a malformed `:from` on the second entry surfaces
4933        // its `FromInvalid` diagnostic, not a (less-useful)
4934        // `DuplicateFrom`. The per-entry shape pass runs *inline*
4935        // before the duplicate-key insert — parallel to
4936        // `child_versao_invalid_fires_before_duplicate_check`
4937        // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4938        // (9888b13). Without this pin a future shortcut that runs the
4939        // cross-entry gate first would surface a duplicate diagnostic
4940        // on a string that isn't even parsable as a version.
4941        let entries = vec![
4942            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4943            entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4944        ];
4945        let err = validate_upgrade_from(&entries).unwrap_err();
4946        assert!(
4947            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4948            "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4949        );
4950    }
4951
4952    #[test]
4953    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4954        // Symmetric arm: a malformed shape on the *first* entry of a
4955        // duplicate pair surfaces its per-entry diagnostic too (not
4956        // the duplicate diagnostic that would otherwise fire on the
4957        // second entry). Pinned separately so a future shortcut that
4958        // walks the duplicate-check ahead of the per-entry pass for the
4959        // first entry only — easy regression to introduce — surfaces
4960        // here.
4961        let entries = vec![
4962            entry(
4963                "0.1.0",
4964                vec![UpgradeInstruction::LoadModule {
4965                    module: String::new(),
4966                }],
4967            ),
4968            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4969        ];
4970        let err = validate_upgrade_from(&entries).unwrap_err();
4971        assert_eq!(
4972            err,
4973            UpgradeError::ModuleEmpty {
4974                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4975            },
4976            "malformed instruction on the first entry of a duplicate pair must surface its \
4977             per-entry diagnostic before the duplicate gate fires, got {err:?}"
4978        );
4979    }
4980
4981    #[test]
4982    fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4983        // Diagnostic-shape pin: when three entries carry the same
4984        // `:from`, the gate reports the *first* collision (the second
4985        // entry) and stops — the third entry's duplicate is masked by
4986        // the first surfaced one. Mirrors
4987        // `validate_duplicate_child_diagnostic_names_first_collision`
4988        // (dbf50a9) on the supervisor axis.
4989        let entries = vec![
4990            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4991            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4992            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4993        ];
4994        let err = validate_upgrade_from(&entries).unwrap_err();
4995        assert_eq!(
4996            err,
4997            UpgradeError::DuplicateFrom {
4998                from: "0.1.0".into()
4999            }
5000        );
5001    }
5002
5003    #[test]
5004    fn validate_upgrade_from_single_entry_never_duplicates() {
5005        // Boundary control: a list of one entry can never produce a
5006        // duplicate, regardless of `:from` value (any single-element
5007        // set is trivially without duplicates). Pin this so a future
5008        // off-by-one in the seen-set insert doesn't accidentally flag
5009        // a single entry as duplicating itself.
5010        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
5011        validate_upgrade_from(&entries).unwrap();
5012    }
5013
5014    // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
5015
5016    #[test]
5017    fn versao_gate_accepts_strict_upgrade() {
5018        // Positive control: the canonical "chain prior versions →
5019        // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
5020        // `:from` strictly less than the current `:versao` under
5021        // SemVer-2 precedence. The gate must not regress this baseline.
5022        let entries = vec![
5023            entry("0.1.0", vec![UpgradeInstruction::Restart]),
5024            entry("0.1.5", vec![UpgradeInstruction::Restart]),
5025            entry("0.1.9", vec![UpgradeInstruction::Restart]),
5026        ];
5027        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5028    }
5029
5030    #[test]
5031    fn versao_gate_accepts_empty_entries() {
5032        // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
5033        // the gate is a no-op when the entries list is empty. Mirrors
5034        // `validate_upgrade_from_accepts_empty_list` on the peer gate.
5035        validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
5036    }
5037
5038    #[test]
5039    fn versao_gate_rejects_equal_from() {
5040        // Self-upgrade no-op: declaring `:from "0.2.0"` while
5041        // `:versao "0.2.0"` means "upgrade from myself to myself" —
5042        // the operator's dispatch either skips silently or
5043        // trivially "succeeds" with no observable state change.
5044        // Reject as the canonical "I forgot to bump :versao when
5045        // adding this entry" footgun.
5046        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
5047        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5048        assert_eq!(
5049            err,
5050            UpgradeError::FromNotBeforeVersao {
5051                from: "0.2.0".into(),
5052                versao: "0.2.0".into(),
5053            },
5054            ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
5055             values verbatim, got {err:?}"
5056        );
5057    }
5058
5059    #[test]
5060    fn versao_gate_rejects_downgrade_from() {
5061        // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
5062        // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
5063        // the operator's `:from`-match dispatch can never reach (it
5064        // never runs a version >= the current one). Reject as the
5065        // canonical "I copy-pasted from the next minor version and
5066        // forgot to bump :versao" footgun.
5067        let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
5068        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5069        assert_eq!(
5070            err,
5071            UpgradeError::FromNotBeforeVersao {
5072                from: "0.3.0".into(),
5073                versao: "0.2.0".into(),
5074            }
5075        );
5076    }
5077
5078    #[test]
5079    fn versao_gate_accepts_prerelease_before_release() {
5080        // SemVer §11 precedence: pre-release versions are *less than*
5081        // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
5082        // FROM an RC TO the GA release is the canonical authoring
5083        // shape — must pass. A regression that collapses pre-release
5084        // into the release version (treating them as equal) surfaces
5085        // here as a false-positive rejection.
5086        let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
5087        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5088    }
5089
5090    #[test]
5091    fn versao_gate_rejects_release_after_prerelease() {
5092        // Symmetric arm: with `:versao "0.2.0-rc.1"` and
5093        // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
5094        // the typical "I'm on an RC of a release that already
5095        // shipped" footgun. The gate names both values verbatim
5096        // so the author can grep for either side and fix in one
5097        // edit.
5098        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
5099        let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
5100        assert_eq!(
5101            err,
5102            UpgradeError::FromNotBeforeVersao {
5103                from: "0.2.0".into(),
5104                versao: "0.2.0-rc.1".into(),
5105            }
5106        );
5107    }
5108
5109    #[test]
5110    fn versao_gate_rejects_build_metadata_only_difference() {
5111        // SemVer §11 explicitly excludes build metadata from
5112        // precedence comparison: `0.2.0+build.1` and `0.2.0` are
5113        // *equal* under [`semver::Version::cmp`]. From the
5114        // operator's `:from`-match dispatch perspective this is a
5115        // self-upgrade no-op (no semantic transition between the
5116        // two), so the gate rejects it — *unlike* the peer
5117        // duplicate-`:from` gate which uses derived `PartialEq` and
5118        // treats build-metadata variants as distinct dispatch keys.
5119        // The two gates' different equality notions are deliberate:
5120        // duplicate-check is conservative (preserves operator-side
5121        // tiebreaking surface), precedence-check is permissive
5122        // (matches operator-side dispatch semantic).
5123        let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
5124        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5125        assert_eq!(
5126            err,
5127            UpgradeError::FromNotBeforeVersao {
5128                from: "0.2.0+build.1".into(),
5129                versao: "0.2.0".into(),
5130            }
5131        );
5132    }
5133
5134    #[test]
5135    fn versao_gate_silently_passes_on_unparseable_versao() {
5136        // Defensive arm: a malformed `:versao` (gated by the
5137        // narrower `ManifestError::VersaoInvalid` surface at the
5138        // load-bearing call site) must not regress into a
5139        // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
5140        // the precedence error over an unparseable `:versao` would
5141        // mask the more actionable root cause (the author meant to
5142        // type `"0.2.0"`, not `"v0.2.0"`).
5143        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
5144        validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
5145    }
5146
5147    #[test]
5148    fn versao_gate_silently_passes_on_unparseable_from() {
5149        // Symmetric defensive arm: a malformed `:from` is gated by
5150        // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
5151        // upstream at the LayoutInvariants call site. Surfacing the
5152        // precedence error over an unparseable `:from` from this
5153        // gate alone would mask the narrower `FromInvalid`
5154        // diagnostic that's expected to lead — same fall-through
5155        // posture as the unparseable-`:versao` arm above. The
5156        // wiring in `LayoutInvariants::verify` runs
5157        // `validate_upgrade_from` *before* this gate, so in practice
5158        // an unparseable `:from` surfaces as `FromInvalid` first
5159        // and this gate is never reached on that input.
5160        let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
5161        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5162    }
5163
5164    #[test]
5165    fn versao_gate_reports_first_offending_entry() {
5166        // Determinism pin: with multiple offending entries the gate
5167        // surfaces the *first* one in declaration order — same
5168        // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5169        // on the peer gate. Walks the entries in order; first
5170        // failing `:from >= :versao` short-circuits.
5171        let entries = vec![
5172            entry("0.1.0", vec![UpgradeInstruction::Restart]),
5173            entry("0.3.0", vec![UpgradeInstruction::Restart]),
5174            entry("0.4.0", vec![UpgradeInstruction::Restart]),
5175        ];
5176        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5177        assert_eq!(
5178            err,
5179            UpgradeError::FromNotBeforeVersao {
5180                from: "0.3.0".into(),
5181                versao: "0.2.0".into(),
5182            },
5183            "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
5184        );
5185    }
5186
5187    // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
5188
5189    #[test]
5190    fn validate_rejects_restart_mixed_with_load_module() {
5191        // The "I'll try the typed path *then* restart anyway" footgun:
5192        // an instructions list with `(:restart)` plus `(:load-module …)`
5193        // is dead code in both directions (succeed → restart discards
5194        // the work that just succeeded, defeating the typed sequence's
5195        // whole point; fail → restart never reached because the entry
5196        // already failed). The gate names the offending entry's `:from`
5197        // verbatim plus the kebab-case lisp-form of every non-`:restart`
5198        // peer so the author can grep their caixa.lisp for either side
5199        // and fix in one edit.
5200        let e = entry(
5201            "0.1.0",
5202            vec![
5203                UpgradeInstruction::LoadModule {
5204                    module: "hello-rio".into(),
5205                },
5206                UpgradeInstruction::Restart,
5207            ],
5208        );
5209        let err = e.validate().unwrap_err();
5210        assert_eq!(
5211            err,
5212            UpgradeError::RestartNotExclusive {
5213                from: "0.1.0".into(),
5214                restart_count: 1,
5215                other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
5216            },
5217            "restart + load-module mix must surface as RestartNotExclusive naming the \
5218             offending `:from` + the non-:restart kinds verbatim, got {err:?}"
5219        );
5220    }
5221
5222    #[test]
5223    fn validate_rejects_restart_mixed_with_full_typed_sequence() {
5224        // Sweep the typed-sequence universe — every non-`:restart`
5225        // variant alongside `:restart` — and assert every typed
5226        // instruction's lisp-form appears in `other_kinds` in
5227        // declaration order. The author should be able to grep for
5228        // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
5229        // `:purge`) and resolve in one pass. Drift in the `lisp_form`
5230        // mapping surfaces here.
5231        let e = entry(
5232            "0.1.0",
5233            vec![
5234                UpgradeInstruction::LoadModule {
5235                    module: "hello-rio".into(),
5236                },
5237                UpgradeInstruction::StateChange {
5238                    script: PathBuf::from("lib/m.lisp"),
5239                },
5240                UpgradeInstruction::SoftPurge {
5241                    module: "hello-rio-old".into(),
5242                },
5243                UpgradeInstruction::Purge {
5244                    module: "hello-rio-old".into(),
5245                },
5246                UpgradeInstruction::Restart,
5247            ],
5248        );
5249        let err = e.validate().unwrap_err();
5250        assert_eq!(
5251            err,
5252            UpgradeError::RestartNotExclusive {
5253                from: "0.1.0".into(),
5254                restart_count: 1,
5255                other_kinds: vec![
5256                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5257                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
5258                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5259                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5260                ],
5261            },
5262        );
5263    }
5264
5265    #[test]
5266    fn validate_rejects_restart_duplicated() {
5267        // `((:restart) (:restart))` — multiple Restart variants in one
5268        // entry. The fallback is a single semantic (restart the pod;
5269        // the new version comes up fresh); repeating it is at best
5270        // redundant, at worst suggests the author thought the second
5271        // would re-trigger after the first. The gate reports
5272        // `restart_count: 2` so the diagnostic surfaces the duplication
5273        // mode unambiguously even when `other_kinds` is empty.
5274        let e = entry(
5275            "0.1.0",
5276            vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5277        );
5278        let err = e.validate().unwrap_err();
5279        assert_eq!(
5280            err,
5281            UpgradeError::RestartNotExclusive {
5282                from: "0.1.0".into(),
5283                restart_count: 2,
5284                other_kinds: vec![],
5285            },
5286        );
5287    }
5288
5289    #[test]
5290    fn validate_accepts_sole_restart() {
5291        // Positive control: the canonical "this prior version's typed
5292        // upgrade is impossible — restart" authoring shape from the
5293        // UpgradeInstruction::Restart doc comment. `((:restart))` alone
5294        // is the entry's whole instructions list and the only valid
5295        // Restart-bearing shape.
5296        let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
5297        e.validate().unwrap();
5298    }
5299
5300    #[test]
5301    fn validate_accepts_typed_sequence_without_restart() {
5302        // Positive control: the canonical typed hot-upgrade authoring
5303        // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
5304        // `:state-change` → `:soft-purge`. Absent `:restart` is the
5305        // only shape that lets the sequence run to completion under
5306        // the wasm-operator's `:from`-match dispatch. Drift here =
5307        // a future tighten that rejects any canonical typed-only shape
5308        // surfaces as a regression at this gate.
5309        let e = entry(
5310            "0.1.0",
5311            vec![
5312                UpgradeInstruction::LoadModule {
5313                    module: "hello-rio".into(),
5314                },
5315                UpgradeInstruction::StateChange {
5316                    script: PathBuf::from("lib/m.lisp"),
5317                },
5318                UpgradeInstruction::SoftPurge {
5319                    module: "hello-rio-old".into(),
5320                },
5321            ],
5322        );
5323        e.validate().unwrap();
5324    }
5325
5326    // ── within-entry state-change-ordering invariant ───────────────────
5327
5328    #[test]
5329    fn validate_rejects_state_change_without_load() {
5330        // Fail-before-pass-after pin: a `:state-change` migrates state
5331        // into the newly-loaded code (gen_server:code_change/3 analog),
5332        // so an entry that runs it with no preceding `:load-module`
5333        // migrates state into code that was never loaded. The operator
5334        // runs instructions in declared order, so this is a build error,
5335        // not a runtime surprise (CAIXA-SDLC §III).
5336        let e = entry(
5337            "0.1.0",
5338            vec![UpgradeInstruction::StateChange {
5339                script: PathBuf::from("lib/m.lisp"),
5340            }],
5341        );
5342        let err = e.validate().unwrap_err();
5343        assert_eq!(
5344            err,
5345            UpgradeError::StateChangeWithoutPriorLoad {
5346                from: "0.1.0".into(),
5347                script: PathBuf::from("lib/m.lisp"),
5348            },
5349            "a `:state-change` with no preceding `:load-module` must surface as \
5350             StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
5351        );
5352    }
5353
5354    #[test]
5355    fn validate_rejects_state_change_before_load() {
5356        // Right-instructions-wrong-order: the load is present but runs
5357        // *after* the migration. Because the operator executes in
5358        // declared order, the migration runs before the new code is
5359        // resident — the same incoherence as the missing-load case.
5360        let e = entry(
5361            "0.1.0",
5362            vec![
5363                UpgradeInstruction::StateChange {
5364                    script: PathBuf::from("lib/m.lisp"),
5365                },
5366                UpgradeInstruction::LoadModule {
5367                    module: "hello-rio".into(),
5368                },
5369            ],
5370        );
5371        let err = e.validate().unwrap_err();
5372        assert!(
5373            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5374            "a `:state-change` ahead of its `:load-module` must surface as \
5375             StateChangeWithoutPriorLoad, got {err:?}"
5376        );
5377    }
5378
5379    #[test]
5380    fn validate_accepts_state_change_after_load() {
5381        // Positive control: the canonical `(:load-module …)
5382        // (:state-change …)` order validates. The load need not name
5383        // the same module the migration targets (StateChange carries a
5384        // script, not a module ref), so any preceding `:load-module`
5385        // satisfies "new code is resident before its migration runs".
5386        let e = entry(
5387            "0.1.0",
5388            vec![
5389                UpgradeInstruction::LoadModule {
5390                    module: "hello-rio".into(),
5391                },
5392                UpgradeInstruction::StateChange {
5393                    script: PathBuf::from("lib/m.lisp"),
5394                },
5395            ],
5396        );
5397        e.validate().unwrap();
5398    }
5399
5400    #[test]
5401    fn validate_accepts_multiple_state_changes_after_one_load() {
5402        // A single leading `:load-module` covers every subsequent
5403        // `:state-change` — the `loaded` latch stays set once the new
5404        // code is resident.
5405        let e = entry(
5406            "0.1.0",
5407            vec![
5408                UpgradeInstruction::LoadModule {
5409                    module: "hello-rio".into(),
5410                },
5411                UpgradeInstruction::StateChange {
5412                    script: PathBuf::from("lib/m1.lisp"),
5413                },
5414                UpgradeInstruction::StateChange {
5415                    script: PathBuf::from("lib/m2.lisp"),
5416                },
5417            ],
5418        );
5419        e.validate().unwrap();
5420    }
5421
5422    #[test]
5423    fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5424     {
5425        // Byte-identity pin on the
5426        // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5427        // migrate ordering dispatch against the pre-lift
5428        // `match instr { UpgradeInstruction::LoadModule { .. } =>
5429        // loaded = true, UpgradeInstruction::StateChange { script } if
5430        // !loaded => …, _ => {} }` open-coded pattern-match the site
5431        // previously carried. Asserts the two projections agree
5432        // byte-for-byte on every arm of the enum — the load-family
5433        // arm-discriminator via `is_load_module()` and the migration-
5434        // family `:script` scalar via `declared_path()` — so a future
5435        // derive regression that flipped the predicate's arm-set (a
5436        // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5437        // a byte-collision flipping a second variant to `true`) or an
5438        // accessor extension that promoted an additional variant onto
5439        // the `PathBuf`-carrying axis would trip here at caixa-core
5440        // test time rather than laundering the arm at the gate's
5441        // per-entry ordering scan far from the derive site.
5442        //
5443        // Peer of the sibling
5444        // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5445        // (c9ce91d) pin on the peer within-entry per-instruction-class
5446        // singularity gate's load-family + `String`-carrying dispatch,
5447        // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5448        // (580d0f1) pin on the paired load → cleanup ordering gate's
5449        // load-family sticky-latch dispatch, and the
5450        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5451        // pin on the peer within-entry per-instruction-class singularity
5452        // gate's migration-family script-projection dispatch — closes
5453        // the last unlifted `match`-shaped per-arm-hand-rolled load-
5454        // family arm-discriminator + migration-family script-projection
5455        // pair inside `impl UpgradeFromEntry`. The four within-entry
5456        // ordering / singularity gates now share one byte-identity pin
5457        // apiece against their respective substrate-primitive typed
5458        // dispatches on the OTP-appup closed-set enum.
5459        //
5460        // Three-arm projective coverage:
5461        //   (a) `LoadModule` satisfies `is_load_module()`, so the
5462        //       sticky-latch advances byte-equal to the pre-lift
5463        //       `UpgradeInstruction::LoadModule { .. }` arm; every
5464        //       other variant leaves the latch untouched;
5465        //   (b) a `((:state-change …))`-only entry (no preceding load)
5466        //       trips the gate on the first `StateChange` with
5467        //       `StateChangeWithoutPriorLoad` carrying the offending
5468        //       script verbatim — the migration-family script surfaces
5469        //       through `declared_path()` byte-equal to the raw
5470        //       `StateChange { script }` pattern-bound field;
5471        //   (c) a `((:load-module …) (:state-change …))` entry leaves
5472        //       the gate vacuous with `Ok(())` — the `loaded = true`
5473        //       latch on the first arm satisfies the `!loaded` guard
5474        //       negation on the second, so the `declared_path()`
5475        //       `Some(script)` fall-through does not fire — and a
5476        //       non-`StateChange`-non-`LoadModule` sequence
5477        //       (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5478        //       the gate vacuous because `declared_path()` is `None`
5479        //       on all three of those arms.
5480        //
5481        // Fail-before-pass-after verified locally: swapping the
5482        // production `if instr.is_load_module() { loaded = true; }
5483        // else if !loaded && let Some(script) = instr.declared_path()
5484        // { … }` back to `match instr { UpgradeInstruction::LoadModule
5485        // { .. } => loaded = true, UpgradeInstruction::StateChange
5486        // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5487        // passing but silently detaches the gate from the accessor's
5488        // typed dispatch — any future `is_load_module` / `declared_path`
5489        // extension (a hole in either predicate, a promotion of an
5490        // additional variant onto either axis, an operator-side
5491        // pre-resolved-path cache the accessor materializes) would
5492        // then silently disagree between this gate's raw pattern-match
5493        // and the peer per-`UpgradeInstruction` consumers that route
5494        // through the accessor pair.
5495
5496        // (a) is_load_module() partitions the arm-set byte-equal to
5497        //     the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5498        //     { .. })` and declared_path() surfaces the StateChange
5499        //     `:script` byte-equal to the raw field access.
5500        let lm = UpgradeInstruction::LoadModule {
5501            module: "hello-rio".into(),
5502        };
5503        assert!(
5504            lm.is_load_module(),
5505            "LoadModule must satisfy is_load_module() — the gate's \
5506             load-family sticky-latch relies on this partition"
5507        );
5508        assert!(
5509            lm.declared_path().is_none(),
5510            "LoadModule must not carry a declared_path — the gate's \
5511             else-if migration-family arm must not fire on load arms"
5512        );
5513        let sc = UpgradeInstruction::StateChange {
5514            script: PathBuf::from("lib/m.lisp"),
5515        };
5516        assert!(
5517            !sc.is_load_module(),
5518            "StateChange must not satisfy is_load_module() — the gate's \
5519             sticky-latch must not advance on migration arms"
5520        );
5521        assert_eq!(
5522            sc.declared_path().map(std::path::PathBuf::as_path),
5523            Some(PathBuf::from("lib/m.lisp").as_path()),
5524            "declared_path() must project the StateChange :script \
5525             byte-equal to the raw field access — accessor divergence \
5526             would silently detach the gate from the projection every \
5527             peer per-`UpgradeInstruction` consumer routes through"
5528        );
5529
5530        // (b) A `((:state-change …))`-only entry trips
5531        //     StateChangeWithoutPriorLoad byte-identical to the
5532        //     pre-lift match-pattern shape.
5533        let no_prior_load = entry(
5534            "0.1.0",
5535            vec![UpgradeInstruction::StateChange {
5536                script: PathBuf::from("lib/m.lisp"),
5537            }],
5538        );
5539        assert_eq!(
5540            no_prior_load.validate_state_change_ordering(),
5541            Err(UpgradeError::StateChangeWithoutPriorLoad {
5542                from: "0.1.0".into(),
5543                script: PathBuf::from("lib/m.lisp"),
5544            }),
5545            "a `:state-change` with no preceding `:load-module` must fire \
5546             StateChangeWithoutPriorLoad carrying the offending script \
5547             verbatim through the declared_path() accessor"
5548        );
5549
5550        // (c) `((:load-module …) (:state-change …))` leaves the gate
5551        //     vacuous; so does a non-StateChange-non-LoadModule
5552        //     sequence (SoftPurge / Purge / Restart alone).
5553        let load_before_migrate = entry(
5554            "0.1.0",
5555            vec![
5556                UpgradeInstruction::LoadModule {
5557                    module: "hello-rio".into(),
5558                },
5559                UpgradeInstruction::StateChange {
5560                    script: PathBuf::from("lib/m.lisp"),
5561                },
5562            ],
5563        );
5564        assert_eq!(
5565            load_before_migrate.validate_state_change_ordering(),
5566            Ok(()),
5567            "load-before-migrate entries must leave the ordering gate \
5568             vacuous — the `loaded = true` sticky-latch on the first arm \
5569             satisfies the `!loaded` guard negation on the else-if arm"
5570        );
5571        for instr in [
5572            UpgradeInstruction::SoftPurge {
5573                module: "x-old".into(),
5574            },
5575            UpgradeInstruction::Purge {
5576                module: "x-old".into(),
5577            },
5578            UpgradeInstruction::Restart,
5579        ] {
5580            let e = entry("0.1.0", vec![instr.clone()]);
5581            assert_eq!(
5582                e.validate_state_change_ordering(),
5583                Ok(()),
5584                "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5585                 leave the ordering gate vacuous — declared_path() is None \
5586                 on every non-StateChange arm, so the else-if migration-\
5587                 family arm never fires"
5588            );
5589        }
5590    }
5591
5592    #[test]
5593    fn validate_state_change_ordering_fires_after_restart_exclusive() {
5594        // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5595        // shape is *both* state-change-without-load and restart-mixed.
5596        // The more-fundamental `RestartNotExclusive` must win (a valid
5597        // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5598        // entry should reach the ordering gate). Guards the call order
5599        // in `validate` against silent reordering.
5600        let e = entry(
5601            "0.1.0",
5602            vec![
5603                UpgradeInstruction::StateChange {
5604                    script: PathBuf::from("lib/m.lisp"),
5605                },
5606                UpgradeInstruction::Restart,
5607            ],
5608        );
5609        let err = e.validate().unwrap_err();
5610        assert!(
5611            matches!(err, UpgradeError::RestartNotExclusive { .. }),
5612            "restart-mixed must surface before the ordering gate, got {err:?}"
5613        );
5614    }
5615
5616    // ── within-entry purge-ordering invariant ──────────────────────────
5617
5618    #[test]
5619    fn validate_rejects_soft_purge_without_load() {
5620        // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5621        // module after the new one is resident (OTP's two-phase code
5622        // load — code:load_module/1 then code:soft_purge/1), so an
5623        // entry that runs it with no preceding `:load-module` drains
5624        // the live module with no replacement. The operator runs
5625        // instructions in declared order, so this is a build error,
5626        // not a runtime surprise (CAIXA-SDLC §III).
5627        let e = entry(
5628            "0.1.0",
5629            vec![UpgradeInstruction::SoftPurge {
5630                module: "x-old".into(),
5631            }],
5632        );
5633        let err = e.validate().unwrap_err();
5634        assert_eq!(
5635            err,
5636            UpgradeError::PurgeWithoutPriorLoad {
5637                from: "0.1.0".into(),
5638                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5639                module: "x-old".into(),
5640            },
5641            "a `:soft-purge` with no preceding `:load-module` must surface as \
5642             PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5643        );
5644    }
5645
5646    #[test]
5647    fn validate_rejects_purge_without_load() {
5648        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5649        // the more catastrophic peer of `:soft-purge`; same gate, same
5650        // shape, kind-tag differs so the author can grep their
5651        // caixa.lisp for the offending `(:purge …)` form.
5652        let e = entry(
5653            "0.1.0",
5654            vec![UpgradeInstruction::Purge {
5655                module: "x-old".into(),
5656            }],
5657        );
5658        let err = e.validate().unwrap_err();
5659        assert_eq!(
5660            err,
5661            UpgradeError::PurgeWithoutPriorLoad {
5662                from: "0.1.0".into(),
5663                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5664                module: "x-old".into(),
5665            },
5666        );
5667    }
5668
5669    #[test]
5670    fn validate_rejects_soft_purge_before_load() {
5671        // Right-instructions-wrong-order: the load is present but runs
5672        // *after* the purge. Because the operator executes in declared
5673        // order, the cleanup drains the old code before the new code
5674        // is resident — same incoherence as the missing-load case,
5675        // leaving a window during which neither version is available.
5676        let e = entry(
5677            "0.1.0",
5678            vec![
5679                UpgradeInstruction::SoftPurge {
5680                    module: "x-old".into(),
5681                },
5682                UpgradeInstruction::LoadModule { module: "x".into() },
5683            ],
5684        );
5685        let err = e.validate().unwrap_err();
5686        assert!(
5687            matches!(
5688                err,
5689                UpgradeError::PurgeWithoutPriorLoad {
5690                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5691                    ..
5692                }
5693            ),
5694            "a `:soft-purge` ahead of its `:load-module` must surface as \
5695             PurgeWithoutPriorLoad, got {err:?}"
5696        );
5697    }
5698
5699    #[test]
5700    fn validate_rejects_purge_before_load() {
5701        // Symmetric arm on the `:purge` variant — the kind tag
5702        // distinguishes the diagnostic so the author lands on the
5703        // offending form directly.
5704        let e = entry(
5705            "0.1.0",
5706            vec![
5707                UpgradeInstruction::Purge {
5708                    module: "x-old".into(),
5709                },
5710                UpgradeInstruction::LoadModule { module: "x".into() },
5711            ],
5712        );
5713        let err = e.validate().unwrap_err();
5714        assert!(
5715            matches!(
5716                err,
5717                UpgradeError::PurgeWithoutPriorLoad {
5718                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5719                    ..
5720                }
5721            ),
5722            "a `:purge` ahead of its `:load-module` must surface as \
5723             PurgeWithoutPriorLoad, got {err:?}"
5724        );
5725    }
5726
5727    #[test]
5728    fn validate_accepts_soft_purge_after_load() {
5729        // Positive control: the canonical `(:load-module …)
5730        // (:soft-purge …)` order validates. The load need not name the
5731        // same module the purge targets — the cleanup typically targets
5732        // the *old* module name (e.g. `"x-old"`) and the load brings up
5733        // the *new* one (`"x"`); the gate only requires that *some*
5734        // `:load-module` precedes the purge, so the new code is resident
5735        // before the old one is drained.
5736        let e = entry(
5737            "0.1.0",
5738            vec![
5739                UpgradeInstruction::LoadModule { module: "x".into() },
5740                UpgradeInstruction::SoftPurge {
5741                    module: "x-old".into(),
5742                },
5743            ],
5744        );
5745        e.validate().unwrap();
5746    }
5747
5748    #[test]
5749    fn validate_accepts_multiple_purges_after_one_load() {
5750        // A single leading `:load-module` covers every subsequent
5751        // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5752        // the new code is resident. Same shape as
5753        // `validate_accepts_multiple_state_changes_after_one_load` on
5754        // the peer ordering gate.
5755        let e = entry(
5756            "0.1.0",
5757            vec![
5758                UpgradeInstruction::LoadModule { module: "x".into() },
5759                UpgradeInstruction::SoftPurge {
5760                    module: "x-old".into(),
5761                },
5762                UpgradeInstruction::Purge {
5763                    module: "x-oldest".into(),
5764                },
5765            ],
5766        );
5767        e.validate().unwrap();
5768    }
5769
5770    #[test]
5771    fn validate_purge_ordering_fires_after_state_change_ordering() {
5772        // Diagnostic-precedence pin: an entry like `((:state-change …)
5773        // (:soft-purge …))` is *both* state-change-without-load and
5774        // purge-without-load. The state-change gate must win — it's
5775        // the load-bearing semantic on this ordering contract, and
5776        // surfacing the purge diagnostic first would mask the more-
5777        // fundamental migration-against-stale-code defect. Guards the
5778        // call order in `validate` against silent reordering.
5779        let e = entry(
5780            "0.1.0",
5781            vec![
5782                UpgradeInstruction::StateChange {
5783                    script: PathBuf::from("lib/m.lisp"),
5784                },
5785                UpgradeInstruction::SoftPurge {
5786                    module: "x-old".into(),
5787                },
5788            ],
5789        );
5790        let err = e.validate().unwrap_err();
5791        assert!(
5792            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5793            "state-change-without-load must surface before purge-without-load, got {err:?}"
5794        );
5795    }
5796
5797    #[test]
5798    fn validate_purge_ordering_fires_after_per_instr_shape() {
5799        // Order pin: a malformed `:module` value on a `:soft-purge` (an
5800        // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5801        // diagnostic *before* the within-entry purge-ordering gate fires.
5802        // The per-instruction shape pass walks the list inline before
5803        // the ordering checks, so the narrower self-locating diagnostic
5804        // surfaces first — mirrors the empty-first cascade on every peer
5805        // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5806        // per_instr_shape` pin on the sibling ordering gate.
5807        let e = entry(
5808            "0.1.0",
5809            vec![UpgradeInstruction::SoftPurge {
5810                module: String::new(),
5811            }],
5812        );
5813        let err = e.validate().unwrap_err();
5814        assert_eq!(
5815            err,
5816            UpgradeError::ModuleEmpty {
5817                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5818            },
5819            "malformed instruction must surface its kind-tagged diagnostic before the \
5820             purge-ordering gate fires, got {err:?}"
5821        );
5822    }
5823
5824    #[test]
5825    fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5826        // The whole-list entry-point surfaces the per-entry ordering
5827        // error (mirrors
5828        // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5829        // the gate is reachable from the LayoutInvariants call site, not
5830        // only from a direct `entry.validate()`.
5831        let entries = vec![entry(
5832            "0.1.0",
5833            vec![UpgradeInstruction::Purge {
5834                module: "x-old".into(),
5835            }],
5836        )];
5837        let err = validate_upgrade_from(&entries).unwrap_err();
5838        assert!(
5839            matches!(
5840                err,
5841                UpgradeError::PurgeWithoutPriorLoad {
5842                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5843                    ..
5844                }
5845            ),
5846            "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5847        );
5848    }
5849
5850    #[test]
5851    fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5852        // The whole-list entry-point surfaces the per-entry ordering
5853        // error (mirrors `validate_restart_exclusive_threads_through_…`):
5854        // the gate is reachable from the LayoutInvariants call site, not
5855        // only from a direct `entry.validate()`.
5856        let entries = vec![entry(
5857            "0.1.0",
5858            vec![UpgradeInstruction::StateChange {
5859                script: PathBuf::from("lib/m.lisp"),
5860            }],
5861        )];
5862        let err = validate_upgrade_from(&entries).unwrap_err();
5863        assert!(
5864            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5865            "validate_upgrade_from must thread the ordering error, got {err:?}"
5866        );
5867    }
5868
5869    // ── within-entry cleanup-singularity invariant ─────────────────────
5870
5871    #[test]
5872    fn validate_rejects_duplicate_soft_purge_for_same_module() {
5873        // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5874        // its target module (code:soft_purge/1 analog); after the
5875        // first the module is gone, so a second `:soft-purge` of the
5876        // same module is at best a no-op and at worst undefined
5877        // (depending on the operator's handling of a non-resident-
5878        // module purge). Author one cleanup per module.
5879        let e = entry(
5880            "0.1.0",
5881            vec![
5882                UpgradeInstruction::LoadModule { module: "x".into() },
5883                UpgradeInstruction::SoftPurge {
5884                    module: "x-old".into(),
5885                },
5886                UpgradeInstruction::SoftPurge {
5887                    module: "x-old".into(),
5888                },
5889            ],
5890        );
5891        let err = e.validate().unwrap_err();
5892        assert_eq!(
5893            err,
5894            UpgradeError::DuplicateCleanup {
5895                from: "0.1.0".into(),
5896                module: "x-old".into(),
5897                kinds: vec![
5898                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5899                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5900                ],
5901            },
5902            "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5903             module + both kinds in declaration order, got {err:?}"
5904        );
5905    }
5906
5907    #[test]
5908    fn validate_rejects_duplicate_purge_for_same_module() {
5909        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5910        // the more catastrophic peer of `:soft-purge`; same gate, same
5911        // shape, kind-tag distinguishes so the author can grep their
5912        // caixa.lisp for the offending `(:purge …)` form.
5913        let e = entry(
5914            "0.1.0",
5915            vec![
5916                UpgradeInstruction::LoadModule { module: "x".into() },
5917                UpgradeInstruction::Purge {
5918                    module: "x-old".into(),
5919                },
5920                UpgradeInstruction::Purge {
5921                    module: "x-old".into(),
5922                },
5923            ],
5924        );
5925        let err = e.validate().unwrap_err();
5926        assert_eq!(
5927            err,
5928            UpgradeError::DuplicateCleanup {
5929                from: "0.1.0".into(),
5930                module: "x-old".into(),
5931                kinds: vec![
5932                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5933                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5934                ],
5935            },
5936        );
5937    }
5938
5939    #[test]
5940    fn validate_rejects_soft_purge_then_purge_for_same_module() {
5941        // Soft-then-hard footgun: the author wrote "drain, and if
5942        // drain doesn't clean up, force-discard", but the operator
5943        // runs declared instructions unconditionally — the `:purge`
5944        // fires whether the `:soft-purge` already discarded the
5945        // module or not, so the imagined fallback semantic is
5946        // missing. Fallback on cleanup failure is the operator's
5947        // job, not authored into the entry. Both kinds carry in
5948        // declaration order so the author can grep for either side
5949        // and pick one.
5950        let e = entry(
5951            "0.1.0",
5952            vec![
5953                UpgradeInstruction::LoadModule { module: "x".into() },
5954                UpgradeInstruction::SoftPurge {
5955                    module: "x-old".into(),
5956                },
5957                UpgradeInstruction::Purge {
5958                    module: "x-old".into(),
5959                },
5960            ],
5961        );
5962        let err = e.validate().unwrap_err();
5963        assert_eq!(
5964            err,
5965            UpgradeError::DuplicateCleanup {
5966                from: "0.1.0".into(),
5967                module: "x-old".into(),
5968                kinds: vec![
5969                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5970                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5971                ],
5972            },
5973        );
5974    }
5975
5976    #[test]
5977    fn validate_rejects_purge_then_soft_purge_for_same_module() {
5978        // Reversed-ordering arm: `:purge` discards immediately; the
5979        // trailing `:soft-purge` has no module to drain. The kinds
5980        // list reflects declaration order so the diagnostic locates
5981        // both forms in the source.
5982        let e = entry(
5983            "0.1.0",
5984            vec![
5985                UpgradeInstruction::LoadModule { module: "x".into() },
5986                UpgradeInstruction::Purge {
5987                    module: "x-old".into(),
5988                },
5989                UpgradeInstruction::SoftPurge {
5990                    module: "x-old".into(),
5991                },
5992            ],
5993        );
5994        let err = e.validate().unwrap_err();
5995        assert_eq!(
5996            err,
5997            UpgradeError::DuplicateCleanup {
5998                from: "0.1.0".into(),
5999                module: "x-old".into(),
6000                kinds: vec![
6001                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6002                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6003                ],
6004            },
6005        );
6006    }
6007
6008    #[test]
6009    fn validate_accepts_distinct_cleanup_modules() {
6010        // Positive control: `:soft-purge` and `:purge` on *different*
6011        // modules pass the gate. Mirrors
6012        // `validate_accepts_multiple_purges_after_one_load` — the
6013        // cleanup-singularity gate is keyed on (module), not on
6014        // (kind, module) pair, so distinct old-version names render
6015        // distinct cleanup targets and don't collide. Sweep both
6016        // same-class (two `:soft-purge` distinct modules) and cross-
6017        // class (`:soft-purge` then `:purge` distinct modules) so a
6018        // future tighten to a kind-only key (which would over-fire on
6019        // distinct modules) surfaces here.
6020        let two_soft = entry(
6021            "0.1.0",
6022            vec![
6023                UpgradeInstruction::LoadModule { module: "x".into() },
6024                UpgradeInstruction::SoftPurge {
6025                    module: "x-old".into(),
6026                },
6027                UpgradeInstruction::SoftPurge {
6028                    module: "x-older".into(),
6029                },
6030            ],
6031        );
6032        two_soft.validate().unwrap();
6033        let mixed = entry(
6034            "0.1.0",
6035            vec![
6036                UpgradeInstruction::LoadModule { module: "x".into() },
6037                UpgradeInstruction::SoftPurge {
6038                    module: "x-old".into(),
6039                },
6040                UpgradeInstruction::Purge {
6041                    module: "x-oldest".into(),
6042                },
6043            ],
6044        );
6045        mixed.validate().unwrap();
6046    }
6047
6048    #[test]
6049    fn validate_accepts_single_cleanup_per_module() {
6050        // Boundary control: a list with exactly one `:soft-purge` and
6051        // one `:purge` (distinct modules, the canonical "drain one,
6052        // hard-discard the other" shape) is the gate's identity
6053        // element. Pin so a future off-by-one in the duplicate-detection
6054        // scan doesn't accidentally flag a single occurrence as
6055        // duplicating itself — mirrors
6056        // `validate_upgrade_from_single_entry_never_duplicates` on
6057        // the peer cross-entry duplicate axis.
6058        let e = entry(
6059            "0.1.0",
6060            vec![
6061                UpgradeInstruction::LoadModule { module: "x".into() },
6062                UpgradeInstruction::SoftPurge {
6063                    module: "x-old".into(),
6064                },
6065                UpgradeInstruction::Purge {
6066                    module: "y-old".into(),
6067                },
6068            ],
6069        );
6070        e.validate().unwrap();
6071    }
6072
6073    #[test]
6074    fn validate_cleanup_singularity_fires_after_purge_ordering() {
6075        // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
6076        // (:soft-purge "x"))` is *both* purge-without-load and
6077        // duplicate-cleanup. The more-fundamental ordering gate must
6078        // win — the missing-load defect is load-bearing (the canonical
6079        // OTP shape requires the new code be resident before any
6080        // cleanup runs), and surfacing the duplicate diagnostic first
6081        // would mask the no-replacement-window defect the ordering
6082        // gate exists to close. Guards the call order in `validate`
6083        // against silent reordering. Same posture as
6084        // `validate_purge_ordering_fires_after_state_change_ordering`
6085        // on the sibling ordering gate.
6086        let e = entry(
6087            "0.1.0",
6088            vec![
6089                UpgradeInstruction::SoftPurge {
6090                    module: "x-old".into(),
6091                },
6092                UpgradeInstruction::SoftPurge {
6093                    module: "x-old".into(),
6094                },
6095            ],
6096        );
6097        let err = e.validate().unwrap_err();
6098        assert!(
6099            matches!(
6100                err,
6101                UpgradeError::PurgeWithoutPriorLoad {
6102                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6103                    ..
6104                }
6105            ),
6106            "purge-without-load must surface before duplicate-cleanup, got {err:?}"
6107        );
6108    }
6109
6110    #[test]
6111    fn validate_cleanup_singularity_fires_after_per_instr_shape() {
6112        // Order pin: a malformed `:module` value on a `:soft-purge`
6113        // (an empty string) surfaces its narrower kind-tagged
6114        // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
6115        // singularity gate fires. The per-instruction shape pass walks
6116        // the list inline before the singularity check, so the
6117        // narrower self-locating diagnostic surfaces first — mirrors
6118        // the empty-first cascade on every peer DNS-1123 gate and the
6119        // `validate_purge_ordering_fires_after_per_instr_shape` pin on
6120        // the sibling ordering gate.
6121        //
6122        // Two empty-string `:soft-purge` would *otherwise* duplicate
6123        // (both modules are the same empty string), so this pin
6124        // double-locks the precedence: the per-instr shape gate must
6125        // win on the first malformed instruction before the duplicate
6126        // scan even reaches the second.
6127        let e = entry(
6128            "0.1.0",
6129            vec![
6130                UpgradeInstruction::LoadModule { module: "x".into() },
6131                UpgradeInstruction::SoftPurge {
6132                    module: String::new(),
6133                },
6134                UpgradeInstruction::SoftPurge {
6135                    module: String::new(),
6136                },
6137            ],
6138        );
6139        let err = e.validate().unwrap_err();
6140        assert_eq!(
6141            err,
6142            UpgradeError::ModuleEmpty {
6143                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
6144            },
6145            "malformed instruction must surface its kind-tagged diagnostic before the \
6146             cleanup-singularity gate fires, got {err:?}"
6147        );
6148    }
6149
6150    #[test]
6151    fn validate_cleanup_singularity_reports_first_collision() {
6152        // Determinism pin: with three cleanups of the same module the
6153        // gate reports the *first* collision (the second occurrence)
6154        // and stops — the third's duplicate is masked by the first
6155        // surfaced one. Mirrors
6156        // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
6157        // on the peer cross-entry duplicate axis.
6158        let e = entry(
6159            "0.1.0",
6160            vec![
6161                UpgradeInstruction::LoadModule { module: "x".into() },
6162                UpgradeInstruction::SoftPurge {
6163                    module: "x-old".into(),
6164                },
6165                UpgradeInstruction::SoftPurge {
6166                    module: "x-old".into(),
6167                },
6168                UpgradeInstruction::Purge {
6169                    module: "x-old".into(),
6170                },
6171            ],
6172        );
6173        let err = e.validate().unwrap_err();
6174        assert_eq!(
6175            err,
6176            UpgradeError::DuplicateCleanup {
6177                from: "0.1.0".into(),
6178                module: "x-old".into(),
6179                kinds: vec![
6180                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6181                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6182                ],
6183            },
6184            "the first colliding pair must surface, not the later `:purge` collision"
6185        );
6186    }
6187
6188    #[test]
6189    fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
6190        // The whole-list entry-point surfaces the per-entry singularity
6191        // error (mirrors
6192        // `validate_purge_ordering_threads_through_validate_upgrade_from`):
6193        // the gate is reachable from the LayoutInvariants call site,
6194        // not only from a direct `entry.validate()`.
6195        let entries = vec![entry(
6196            "0.1.0",
6197            vec![
6198                UpgradeInstruction::LoadModule { module: "x".into() },
6199                UpgradeInstruction::SoftPurge {
6200                    module: "x-old".into(),
6201                },
6202                UpgradeInstruction::Purge {
6203                    module: "x-old".into(),
6204                },
6205            ],
6206        )];
6207        let err = validate_upgrade_from(&entries).unwrap_err();
6208        assert!(
6209            matches!(err, UpgradeError::DuplicateCleanup { .. }),
6210            "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
6211        );
6212    }
6213
6214    #[test]
6215    fn validate_rejects_duplicate_load_module_for_same_module() {
6216        // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
6217        // §II.4): each module is loaded exactly once per upgrade entry,
6218        // the operator's dispatch table reads the module name to bind
6219        // the wasm component, and a second `(:load-module "x")` re-reads
6220        // the same module name and re-binds the same component — a
6221        // no-op the second time. systools-generated `.relup` files emit
6222        // at most one `load_module` per module per upgrade step for
6223        // this reason. Author one `(:load-module "x")` per old module.
6224        let e = entry(
6225            "0.1.0",
6226            vec![
6227                UpgradeInstruction::LoadModule { module: "x".into() },
6228                UpgradeInstruction::LoadModule { module: "x".into() },
6229            ],
6230        );
6231        let err = e.validate().unwrap_err();
6232        assert_eq!(
6233            err,
6234            UpgradeError::DuplicateLoadModule {
6235                from: "0.1.0".into(),
6236                module: "x".into(),
6237            },
6238            "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
6239             the module, got {err:?}"
6240        );
6241    }
6242
6243    #[test]
6244    fn validate_accepts_distinct_load_modules() {
6245        // Positive control: `:load-module` instructions on *different*
6246        // modules pass the gate. Mirrors
6247        // `validate_accepts_distinct_cleanup_modules` on the sibling
6248        // singularity axis — the load-singularity gate is keyed on
6249        // (module), so distinct module names render distinct load
6250        // targets and don't collide. Sweep both the bare two-load shape
6251        // and the canonical load-pair-with-cleanup shape so a future
6252        // tighten that over-fires on distinct loads surfaces here.
6253        let two_loads = entry(
6254            "0.1.0",
6255            vec![
6256                UpgradeInstruction::LoadModule { module: "x".into() },
6257                UpgradeInstruction::LoadModule { module: "y".into() },
6258            ],
6259        );
6260        two_loads.validate().unwrap();
6261        let with_cleanup = entry(
6262            "0.1.0",
6263            vec![
6264                UpgradeInstruction::LoadModule { module: "x".into() },
6265                UpgradeInstruction::LoadModule { module: "y".into() },
6266                UpgradeInstruction::SoftPurge {
6267                    module: "x-old".into(),
6268                },
6269                UpgradeInstruction::SoftPurge {
6270                    module: "y-old".into(),
6271                },
6272            ],
6273        );
6274        with_cleanup.validate().unwrap();
6275    }
6276
6277    #[test]
6278    fn validate_accepts_single_load_per_module() {
6279        // Boundary control: a list with exactly one `:load-module`
6280        // followed by the canonical `:state-change` + `:soft-purge`
6281        // sequence (the module-doc OTP shape) is the gate's identity
6282        // element. Pin so a future off-by-one in the duplicate-
6283        // detection scan doesn't accidentally flag a single occurrence
6284        // as duplicating itself — mirrors
6285        // `validate_accepts_single_cleanup_per_module` on the sibling
6286        // singularity axis.
6287        let e = entry(
6288            "0.1.0",
6289            vec![
6290                UpgradeInstruction::LoadModule { module: "x".into() },
6291                UpgradeInstruction::StateChange {
6292                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6293                },
6294                UpgradeInstruction::SoftPurge {
6295                    module: "x-old".into(),
6296                },
6297            ],
6298        );
6299        e.validate().unwrap();
6300    }
6301
6302    #[test]
6303    fn validate_load_singularity_fires_after_state_change_ordering() {
6304        // Diagnostic-precedence pin: an entry like `((:state-change
6305        // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
6306        // state-change-without-load and duplicate-load. The more-
6307        // fundamental ordering gate must win — the missing-load defect
6308        // is load-bearing (the migration runs against unloaded code),
6309        // and surfacing the duplicate diagnostic first would mask the
6310        // migrate-into-unloaded-code defect the ordering gate exists
6311        // to close. Guards the call order in `validate` against silent
6312        // reordering. Same posture as
6313        // `validate_cleanup_singularity_fires_after_purge_ordering`
6314        // on the sibling singularity gate.
6315        let e = entry(
6316            "0.1.0",
6317            vec![
6318                UpgradeInstruction::StateChange {
6319                    script: PathBuf::from("lib/m.lisp"),
6320                },
6321                UpgradeInstruction::LoadModule { module: "x".into() },
6322                UpgradeInstruction::LoadModule { module: "x".into() },
6323            ],
6324        );
6325        let err = e.validate().unwrap_err();
6326        assert!(
6327            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6328            "state-change-without-load must surface before duplicate-load, got {err:?}"
6329        );
6330    }
6331
6332    #[test]
6333    fn validate_load_singularity_fires_after_purge_ordering() {
6334        // Diagnostic-precedence pin: an entry like `((:soft-purge
6335        // "x-old") (:load-module "x") (:load-module "x"))` is *both*
6336        // purge-without-load and duplicate-load. The more-fundamental
6337        // ordering gate must win — the missing-load defect is load-
6338        // bearing (the cleanup runs against no-replacement-window),
6339        // and surfacing the duplicate diagnostic first would mask the
6340        // drain-to-nothing defect the ordering gate exists to close.
6341        // Sibling of
6342        // `validate_cleanup_singularity_fires_after_purge_ordering` on
6343        // the load-singularity axis.
6344        let e = entry(
6345            "0.1.0",
6346            vec![
6347                UpgradeInstruction::SoftPurge {
6348                    module: "x-old".into(),
6349                },
6350                UpgradeInstruction::LoadModule { module: "x".into() },
6351                UpgradeInstruction::LoadModule { module: "x".into() },
6352            ],
6353        );
6354        let err = e.validate().unwrap_err();
6355        assert!(
6356            matches!(
6357                err,
6358                UpgradeError::PurgeWithoutPriorLoad {
6359                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6360                    ..
6361                }
6362            ),
6363            "purge-without-load must surface before duplicate-load, got {err:?}"
6364        );
6365    }
6366
6367    #[test]
6368    fn validate_load_singularity_fires_after_per_instr_shape() {
6369        // Order pin: a malformed `:module` value on a `:load-module`
6370        // (an empty string) surfaces its narrower kind-tagged
6371        // `ModuleEmpty` diagnostic *before* the within-entry load-
6372        // singularity gate fires. The per-instruction shape pass walks
6373        // the list inline before the singularity check, so the
6374        // narrower self-locating diagnostic surfaces first — mirrors
6375        // the empty-first cascade on every peer DNS-1123 gate and the
6376        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6377        // pin on the sibling singularity gate.
6378        //
6379        // Two empty-string `:load-module` would *otherwise* duplicate
6380        // (both modules are the same empty string), so this pin
6381        // double-locks the precedence: the per-instr shape gate must
6382        // win on the first malformed instruction before the duplicate
6383        // scan even reaches the second.
6384        let e = entry(
6385            "0.1.0",
6386            vec![
6387                UpgradeInstruction::LoadModule {
6388                    module: String::new(),
6389                },
6390                UpgradeInstruction::LoadModule {
6391                    module: String::new(),
6392                },
6393            ],
6394        );
6395        let err = e.validate().unwrap_err();
6396        assert_eq!(
6397            err,
6398            UpgradeError::ModuleEmpty {
6399                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6400            },
6401            "malformed instruction must surface its kind-tagged diagnostic before the \
6402             load-singularity gate fires, got {err:?}"
6403        );
6404    }
6405
6406    #[test]
6407    fn validate_load_singularity_fires_before_cleanup_singularity() {
6408        // Diagnostic-precedence pin: an entry that violates *both*
6409        // singularities — duplicate load on "x" *and* duplicate cleanup
6410        // on "y-old" — must surface the load-side diagnostic first.
6411        // The load axis precedes the cleanup axis in the canonical OTP
6412        // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6413        // in [`UpgradeInstruction`] declaration order (LoadModule
6414        // before SoftPurge/Purge), so the load-side singularity is the
6415        // load-bearing diagnostic when both fire — the cleanup-side
6416        // duplicate is meaningless either way without a coherent load.
6417        // Guards the call order in `validate`: `validate_load_singularity`
6418        // runs before `validate_cleanup_singularity`.
6419        let e = entry(
6420            "0.1.0",
6421            vec![
6422                UpgradeInstruction::LoadModule { module: "x".into() },
6423                UpgradeInstruction::LoadModule { module: "x".into() },
6424                UpgradeInstruction::SoftPurge {
6425                    module: "y-old".into(),
6426                },
6427                UpgradeInstruction::SoftPurge {
6428                    module: "y-old".into(),
6429                },
6430            ],
6431        );
6432        let err = e.validate().unwrap_err();
6433        assert_eq!(
6434            err,
6435            UpgradeError::DuplicateLoadModule {
6436                from: "0.1.0".into(),
6437                module: "x".into(),
6438            },
6439            "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6440        );
6441    }
6442
6443    #[test]
6444    fn validate_load_singularity_reports_first_collision() {
6445        // Determinism pin: with three loads of the same module the gate
6446        // reports the *first* collision (the second occurrence) and
6447        // stops — the third's duplicate is masked by the first surfaced
6448        // one. Mirrors
6449        // `validate_cleanup_singularity_reports_first_collision` on the
6450        // sibling singularity axis and every peer duplicate gate's
6451        // first-collision discipline.
6452        let e = entry(
6453            "0.1.0",
6454            vec![
6455                UpgradeInstruction::LoadModule { module: "x".into() },
6456                UpgradeInstruction::LoadModule { module: "x".into() },
6457                UpgradeInstruction::LoadModule { module: "x".into() },
6458            ],
6459        );
6460        let err = e.validate().unwrap_err();
6461        assert_eq!(
6462            err,
6463            UpgradeError::DuplicateLoadModule {
6464                from: "0.1.0".into(),
6465                module: "x".into(),
6466            },
6467            "the first colliding occurrence must surface, not the later third-load collision"
6468        );
6469    }
6470
6471    #[test]
6472    fn validate_load_singularity_threads_through_validate_upgrade_from() {
6473        // The whole-list entry-point surfaces the per-entry singularity
6474        // error (mirrors
6475        // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6476        // the gate is reachable from the LayoutInvariants call site,
6477        // not only from a direct `entry.validate()`.
6478        let entries = vec![entry(
6479            "0.1.0",
6480            vec![
6481                UpgradeInstruction::LoadModule { module: "x".into() },
6482                UpgradeInstruction::LoadModule { module: "x".into() },
6483            ],
6484        )];
6485        let err = validate_upgrade_from(&entries).unwrap_err();
6486        assert!(
6487            matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6488            "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6489        );
6490    }
6491
6492    // ── within-entry state-change-singularity invariant ────────────────
6493
6494    #[test]
6495    fn validate_rejects_duplicate_state_change_for_same_script() {
6496        // `StateChange` is the `gen_server:code_change/3` analog
6497        // (INSPIRATIONS §II.4): the script folds the prior-version
6498        // state shape into the current-version shape — a one-shot
6499        // transition, not a step that composes with itself. OTP's
6500        // release_handler invokes `code_change/3` exactly once per
6501        // upgrade per gen_server; systools-generated `.relup` files
6502        // emit at most one `code_change` per gen_server per upgrade
6503        // step for this reason. A second `(:state-change "m.lisp")`
6504        // re-runs the same fold on the already-migrated state — at
6505        // best a no-op and at worst silent state corruption from
6506        // double-applied non-idempotent transforms (`add column`,
6507        // `increment counter`, `rename field`). Author one
6508        // `(:state-change "m.lisp")` per migration script per entry.
6509        let e = entry(
6510            "0.1.0",
6511            vec![
6512                UpgradeInstruction::LoadModule { module: "x".into() },
6513                UpgradeInstruction::StateChange {
6514                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6515                },
6516                UpgradeInstruction::StateChange {
6517                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6518                },
6519            ],
6520        );
6521        let err = e.validate().unwrap_err();
6522        assert_eq!(
6523            err,
6524            UpgradeError::DuplicateStateChange {
6525                from: "0.1.0".into(),
6526                script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6527            },
6528            "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6529             the script, got {err:?}"
6530        );
6531    }
6532
6533    #[test]
6534    fn validate_accepts_distinct_state_change_scripts() {
6535        // Positive control: `:state-change` instructions on *different*
6536        // scripts pass the gate. Mirrors
6537        // `validate_accepts_distinct_cleanup_modules` /
6538        // `validate_accepts_distinct_load_modules` on the sibling
6539        // singularity axes — the state-change-singularity gate is keyed
6540        // on the script PathBuf, so distinct scripts render distinct
6541        // migration targets and don't collide. Sweep both the bare two-
6542        // migration shape and the canonical load-pair-with-cleanup shape
6543        // so a future tighten that over-fires on distinct scripts
6544        // surfaces here. This positive control is the gate-level peer of
6545        // `validate_accepts_multiple_state_changes_after_one_load` (the
6546        // ordering-gate positive control on distinct scripts), pinned
6547        // here independently so a future refactor that decouples the
6548        // gates can't accidentally drop coverage on either.
6549        let two_migrations = entry(
6550            "0.1.0",
6551            vec![
6552                UpgradeInstruction::LoadModule { module: "x".into() },
6553                UpgradeInstruction::StateChange {
6554                    script: PathBuf::from("lib/m1.lisp"),
6555                },
6556                UpgradeInstruction::StateChange {
6557                    script: PathBuf::from("lib/m2.lisp"),
6558                },
6559            ],
6560        );
6561        two_migrations.validate().unwrap();
6562        let with_cleanup = entry(
6563            "0.1.0",
6564            vec![
6565                UpgradeInstruction::LoadModule { module: "x".into() },
6566                UpgradeInstruction::StateChange {
6567                    script: PathBuf::from("lib/m1.lisp"),
6568                },
6569                UpgradeInstruction::StateChange {
6570                    script: PathBuf::from("lib/m2.lisp"),
6571                },
6572                UpgradeInstruction::SoftPurge {
6573                    module: "x-old".into(),
6574                },
6575            ],
6576        );
6577        with_cleanup.validate().unwrap();
6578    }
6579
6580    #[test]
6581    fn validate_accepts_single_state_change_per_script() {
6582        // Boundary control: a list with exactly one `:state-change`
6583        // wrapped by the canonical `:load-module` + `:soft-purge`
6584        // sequence (the module-doc OTP shape) is the gate's identity
6585        // element. Pin so a future off-by-one in the duplicate-
6586        // detection scan doesn't accidentally flag a single occurrence
6587        // as duplicating itself — mirrors
6588        // `validate_accepts_single_load_per_module` /
6589        // `validate_accepts_single_cleanup_per_module` on the sibling
6590        // singularity axes.
6591        let e = entry(
6592            "0.1.0",
6593            vec![
6594                UpgradeInstruction::LoadModule { module: "x".into() },
6595                UpgradeInstruction::StateChange {
6596                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6597                },
6598                UpgradeInstruction::SoftPurge {
6599                    module: "x-old".into(),
6600                },
6601            ],
6602        );
6603        e.validate().unwrap();
6604    }
6605
6606    #[test]
6607    fn validate_state_change_singularity_fires_after_state_change_ordering() {
6608        // Diagnostic-precedence pin: an entry like `((:state-change
6609        // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6610        // without-load and duplicate-state-change. The more-fundamental
6611        // ordering gate must win — the missing-load defect is load-
6612        // bearing (the migration runs against unloaded code), and
6613        // surfacing the duplicate diagnostic first would mask the
6614        // migrate-into-unloaded-code defect the ordering gate exists to
6615        // close. Guards the call order in `validate` against silent
6616        // reordering. Same posture as
6617        // `validate_load_singularity_fires_after_state_change_ordering`
6618        // on the sibling singularity gate.
6619        //
6620        // Two same-script `:state-change` would *otherwise* duplicate
6621        // (both scripts collide on the very first `:state-change`-
6622        // without-load encountered), so this pin double-locks the
6623        // precedence: the ordering gate must win on the first un-loaded
6624        // `:state-change` before the singularity scan even reaches the
6625        // second.
6626        let e = entry(
6627            "0.1.0",
6628            vec![
6629                UpgradeInstruction::StateChange {
6630                    script: PathBuf::from("lib/m.lisp"),
6631                },
6632                UpgradeInstruction::StateChange {
6633                    script: PathBuf::from("lib/m.lisp"),
6634                },
6635            ],
6636        );
6637        let err = e.validate().unwrap_err();
6638        assert!(
6639            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6640            "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6641        );
6642    }
6643
6644    #[test]
6645    fn validate_state_change_singularity_fires_after_purge_ordering() {
6646        // Diagnostic-precedence pin: an entry like `((:soft-purge
6647        // "x-old") (:load-module "x") (:state-change "m.lisp")
6648        // (:state-change "m.lisp"))` is *both* purge-without-load and
6649        // duplicate-state-change. The more-fundamental ordering gate
6650        // must win — the missing-load defect (a cleanup that drains the
6651        // only resident version to nothing) is load-bearing, and
6652        // surfacing the duplicate diagnostic first would mask the
6653        // drain-to-nothing defect the ordering gate exists to close.
6654        // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6655        // on the state-change-singularity axis.
6656        let e = entry(
6657            "0.1.0",
6658            vec![
6659                UpgradeInstruction::SoftPurge {
6660                    module: "x-old".into(),
6661                },
6662                UpgradeInstruction::LoadModule { module: "x".into() },
6663                UpgradeInstruction::StateChange {
6664                    script: PathBuf::from("lib/m.lisp"),
6665                },
6666                UpgradeInstruction::StateChange {
6667                    script: PathBuf::from("lib/m.lisp"),
6668                },
6669            ],
6670        );
6671        let err = e.validate().unwrap_err();
6672        assert!(
6673            matches!(
6674                err,
6675                UpgradeError::PurgeWithoutPriorLoad {
6676                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6677                    ..
6678                }
6679            ),
6680            "purge-without-load must surface before duplicate-state-change, got {err:?}"
6681        );
6682    }
6683
6684    #[test]
6685    fn validate_state_change_singularity_fires_after_per_instr_shape() {
6686        // Order pin: a malformed `:script` value on a `:state-change`
6687        // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6688        // *before* the within-entry state-change-singularity gate fires.
6689        // The per-instruction shape pass walks the list inline before
6690        // the singularity check, so the narrower self-locating
6691        // diagnostic surfaces first — mirrors the empty-first cascade on
6692        // every peer path-shape gate and the
6693        // `validate_load_singularity_fires_after_per_instr_shape` /
6694        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6695        // pins on the sibling singularity gates.
6696        //
6697        // Two empty-path `:state-change` would *otherwise* duplicate
6698        // (both scripts are the same empty PathBuf), so this pin double-
6699        // locks the precedence: the per-instr shape gate must win on the
6700        // first malformed instruction before the duplicate scan even
6701        // reaches the second.
6702        let e = entry(
6703            "0.1.0",
6704            vec![
6705                UpgradeInstruction::LoadModule { module: "x".into() },
6706                UpgradeInstruction::StateChange {
6707                    script: PathBuf::new(),
6708                },
6709                UpgradeInstruction::StateChange {
6710                    script: PathBuf::new(),
6711                },
6712            ],
6713        );
6714        let err = e.validate().unwrap_err();
6715        assert_eq!(
6716            err,
6717            UpgradeError::EmptyScript,
6718            "malformed instruction must surface its narrower diagnostic before the \
6719             state-change-singularity gate fires, got {err:?}"
6720        );
6721    }
6722
6723    #[test]
6724    fn validate_state_change_singularity_fires_after_load_singularity() {
6725        // Diagnostic-precedence pin: an entry that violates *both*
6726        // singularities — duplicate load on "x" *and* duplicate
6727        // state-change on "m.lisp" — must surface the load-side
6728        // diagnostic first. The load axis precedes the migration axis
6729        // in the canonical OTP sequence (`code:load_module/1` then
6730        // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6731        // declaration order (LoadModule before StateChange), so the
6732        // load-side singularity is the load-bearing diagnostic when
6733        // both fire — the migration-side duplicate is meaningless
6734        // either way without a coherent load. Guards the call order in
6735        // `validate`: `validate_load_singularity` runs before
6736        // `validate_state_change_singularity`.
6737        let e = entry(
6738            "0.1.0",
6739            vec![
6740                UpgradeInstruction::LoadModule { module: "x".into() },
6741                UpgradeInstruction::LoadModule { module: "x".into() },
6742                UpgradeInstruction::StateChange {
6743                    script: PathBuf::from("lib/m.lisp"),
6744                },
6745                UpgradeInstruction::StateChange {
6746                    script: PathBuf::from("lib/m.lisp"),
6747                },
6748            ],
6749        );
6750        let err = e.validate().unwrap_err();
6751        assert_eq!(
6752            err,
6753            UpgradeError::DuplicateLoadModule {
6754                from: "0.1.0".into(),
6755                module: "x".into(),
6756            },
6757            "duplicate-load must surface before duplicate-state-change, got {err:?}"
6758        );
6759    }
6760
6761    #[test]
6762    fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6763        // Diagnostic-precedence pin: an entry that violates *both*
6764        // singularities — duplicate state-change on "m.lisp" *and*
6765        // duplicate cleanup on "y-old" — must surface the migration-
6766        // side diagnostic first. The migration axis precedes the
6767        // cleanup axis in the canonical OTP sequence
6768        // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6769        // [`UpgradeInstruction`] declaration order (StateChange before
6770        // SoftPurge/Purge), so the migration-side singularity is the
6771        // load-bearing diagnostic when both fire — the cleanup-side
6772        // duplicate is irrelevant once the migration has corrupted
6773        // state by double-applying. Guards the call order in
6774        // `validate`: `validate_state_change_singularity` runs before
6775        // `validate_cleanup_singularity`.
6776        let e = entry(
6777            "0.1.0",
6778            vec![
6779                UpgradeInstruction::LoadModule { module: "x".into() },
6780                UpgradeInstruction::StateChange {
6781                    script: PathBuf::from("lib/m.lisp"),
6782                },
6783                UpgradeInstruction::StateChange {
6784                    script: PathBuf::from("lib/m.lisp"),
6785                },
6786                UpgradeInstruction::SoftPurge {
6787                    module: "y-old".into(),
6788                },
6789                UpgradeInstruction::SoftPurge {
6790                    module: "y-old".into(),
6791                },
6792            ],
6793        );
6794        let err = e.validate().unwrap_err();
6795        assert_eq!(
6796            err,
6797            UpgradeError::DuplicateStateChange {
6798                from: "0.1.0".into(),
6799                script: PathBuf::from("lib/m.lisp"),
6800            },
6801            "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6802        );
6803    }
6804
6805    #[test]
6806    fn validate_state_change_singularity_reports_first_collision() {
6807        // Determinism pin: with three state-changes on the same script
6808        // the gate reports the *first* collision (the second
6809        // occurrence) and stops — the third's duplicate is masked by
6810        // the first surfaced one. Mirrors
6811        // `validate_load_singularity_reports_first_collision` /
6812        // `validate_cleanup_singularity_reports_first_collision` on the
6813        // sibling singularity axes and every peer duplicate gate's
6814        // first-collision discipline.
6815        let e = entry(
6816            "0.1.0",
6817            vec![
6818                UpgradeInstruction::LoadModule { module: "x".into() },
6819                UpgradeInstruction::StateChange {
6820                    script: PathBuf::from("lib/m.lisp"),
6821                },
6822                UpgradeInstruction::StateChange {
6823                    script: PathBuf::from("lib/m.lisp"),
6824                },
6825                UpgradeInstruction::StateChange {
6826                    script: PathBuf::from("lib/m.lisp"),
6827                },
6828            ],
6829        );
6830        let err = e.validate().unwrap_err();
6831        assert_eq!(
6832            err,
6833            UpgradeError::DuplicateStateChange {
6834                from: "0.1.0".into(),
6835                script: PathBuf::from("lib/m.lisp"),
6836            },
6837            "the first colliding occurrence must surface, not the later third-migration collision"
6838        );
6839    }
6840
6841    #[test]
6842    fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6843        // The whole-list entry-point surfaces the per-entry singularity
6844        // error (mirrors
6845        // `validate_load_singularity_threads_through_validate_upgrade_from`
6846        // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6847        // the gate is reachable from the LayoutInvariants call site,
6848        // not only from a direct `entry.validate()`.
6849        let entries = vec![entry(
6850            "0.1.0",
6851            vec![
6852                UpgradeInstruction::LoadModule { module: "x".into() },
6853                UpgradeInstruction::StateChange {
6854                    script: PathBuf::from("lib/m.lisp"),
6855                },
6856                UpgradeInstruction::StateChange {
6857                    script: PathBuf::from("lib/m.lisp"),
6858                },
6859            ],
6860        )];
6861        let err = validate_upgrade_from(&entries).unwrap_err();
6862        assert!(
6863            matches!(err, UpgradeError::DuplicateStateChange { .. }),
6864            "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6865        );
6866    }
6867
6868    #[test]
6869    fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6870        // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6871        // per-instruction `StateChange`-arm script-path projection must
6872        // route through the sibling lifted
6873        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6874        // accessor, not the raw
6875        // `match instr { UpgradeInstruction::StateChange { script } =>
6876        // script.as_path(), _ => continue }` open-coded pattern-match
6877        // the gate previously carried.
6878        //
6879        // Structurally: the gate's projection accept-set is the union
6880        // of every [`UpgradeInstruction`] variant for which
6881        // `declared_path().is_some()` — today exactly
6882        // [`UpgradeInstruction::StateChange`] per the sibling
6883        // `declared_path_only_for_state_change` pin, so a
6884        // duplicate-scripts input trips `DuplicateStateChange` and a
6885        // non-`StateChange` input (module-bearing / terminal) leaves
6886        // `seen` empty and the gate returns `Ok(())` byte-identical to
6887        // the pattern-match shape.
6888        //
6889        // Byte-equal today (`declared_path` returns `Some(script)` iff
6890        // `StateChange`, byte-for-byte from the variant's own storage);
6891        // the pin catches any future accessor extension that promotes
6892        // an additional variant onto the `PathBuf`-carrying axis — the
6893        // gate then fires on duplicate scripts from that variant too,
6894        // and the singularity discipline the sibling
6895        // `validate_load_singularity` / `validate_cleanup_singularity`
6896        // gates share on the `String`-carrying axis's per-variant
6897        // consumers extends to the promoted variant by construction.
6898        //
6899        // Peer of the sibling four per-`UpgradeInstruction` consumers
6900        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6901        // sandbox-path fan-out, the layout-side per-`StateChange`
6902        // script-existence fan-out at
6903        // `caixa-core/src/layout.rs:1017`, the cross-slot
6904        // [`validate_upgrade_from_against_behavior`] gate's per-
6905        // `StateChange` detection loop, the peer
6906        // [`UpgradeInstruction::declared_module`] `String`-axis
6907        // per-variant unifier) — this gate now shares one typed
6908        // dispatch on the substrate primitive's `PathBuf`-carrying
6909        // axis with those consumers, so a future rebrand on the axis
6910        // migrates as a single caixa-core edit rather than a
6911        // coordinated rewrite of five call sites.
6912        //
6913        // Three-arm projective coverage:
6914        //   (a) `StateChange` scripts project through `declared_path()`
6915        //       byte-equal to the raw `script.as_path()` field access;
6916        //   (b) a duplicate-`StateChange` input trips the gate on the
6917        //       second occurrence with `DuplicateStateChange` carrying
6918        //       the offending script verbatim;
6919        //   (c) a non-`StateChange`-only input (`LoadModule` /
6920        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
6921        //       vacuous with `Ok(())` — the `declared_path().is_none()`
6922        //       arm's `continue` fall-through pins.
6923        //
6924        // Fail-before-pass-after verified locally: swapping the
6925        // production `let Some(script) = instr.declared_path() else {
6926        // continue };` back to `let script = match instr {
6927        // UpgradeInstruction::StateChange { script } =>
6928        // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6929        // passing but silently detaches the gate from the accessor's
6930        // typed dispatch — any future `declared_path` extension
6931        // (promotion of an additional variant onto the axis, an
6932        // operator-side pre-resolved-path cache the accessor
6933        // materializes) would then silently disagree between this
6934        // gate's raw pattern-match and the peer four sibling consumers
6935        // that route through the accessor.
6936        use std::path::PathBuf;
6937
6938        // (a) StateChange projection byte-equal via declared_path.
6939        let sc = UpgradeInstruction::StateChange {
6940            script: PathBuf::from("lib/m.lisp"),
6941        };
6942        assert_eq!(
6943            sc.declared_path().map(std::path::PathBuf::as_path),
6944            Some(PathBuf::from("lib/m.lisp").as_path()),
6945            "declared_path() must project the StateChange :script byte-equal to the raw \
6946             field access — accessor divergence would silently detach the gate from the \
6947             projection every peer per-`UpgradeInstruction` consumer routes through"
6948        );
6949
6950        // (b) Duplicate-StateChange input trips the gate.
6951        let dup = entry(
6952            "0.1.0",
6953            vec![
6954                UpgradeInstruction::LoadModule { module: "x".into() },
6955                UpgradeInstruction::StateChange {
6956                    script: PathBuf::from("lib/m.lisp"),
6957                },
6958                UpgradeInstruction::StateChange {
6959                    script: PathBuf::from("lib/m.lisp"),
6960                },
6961            ],
6962        );
6963        assert_eq!(
6964            dup.validate_state_change_singularity(),
6965            Err(UpgradeError::DuplicateStateChange {
6966                from: "0.1.0".into(),
6967                script: PathBuf::from("lib/m.lisp"),
6968            }),
6969            "duplicate StateChange scripts must trip the gate on the second occurrence \
6970             through the declared_path accessor's Some(script) arm"
6971        );
6972
6973        // (c) Non-StateChange-only inputs leave the gate vacuous.
6974        for instrs in [
6975            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6976            vec![
6977                UpgradeInstruction::LoadModule { module: "x".into() },
6978                UpgradeInstruction::SoftPurge {
6979                    module: "x-old".into(),
6980                },
6981            ],
6982            vec![
6983                UpgradeInstruction::LoadModule { module: "x".into() },
6984                UpgradeInstruction::Purge {
6985                    module: "x-old".into(),
6986                },
6987            ],
6988            vec![UpgradeInstruction::Restart],
6989        ] {
6990            for instr in &instrs {
6991                assert!(
6992                    instr.declared_path().is_none(),
6993                    "non-StateChange variants must project None through declared_path — \
6994                     accessor divergence would let this gate silently fire on a duplicate \
6995                     module reference far from any :state-change site"
6996                );
6997            }
6998            let e = entry("0.1.0", instrs);
6999            assert_eq!(
7000                e.validate_state_change_singularity(),
7001                Ok(()),
7002                "the state-change-singularity gate must return Ok(()) on an entry whose \
7003                 instructions all project None through declared_path — the accessor's \
7004                 continue arm the pattern-match's `_ => continue` previously carried"
7005            );
7006        }
7007    }
7008
7009    // ── within-entry state-change-before-cleanup ordering invariant ──
7010
7011    #[test]
7012    fn validate_rejects_state_change_after_soft_purge() {
7013        // Fail-before-pass-after pin: `:state-change` is the
7014        // gen_server:code_change/3 analog and folds the prior-version
7015        // state shape into the current shape; `:soft-purge` drains the
7016        // prior code. The operator runs instructions in declared order,
7017        // so a `:soft-purge` ahead of a `:state-change` drains the
7018        // prior module before the migration callback runs against the
7019        // state it held — the canonical OTP error mode
7020        // "`code_change/3` invoked on a purged module" the
7021        // release_handler closes by always ordering the migration
7022        // before the cleanup.
7023        let e = entry(
7024            "0.1.0",
7025            vec![
7026                UpgradeInstruction::LoadModule { module: "x".into() },
7027                UpgradeInstruction::SoftPurge {
7028                    module: "x-old".into(),
7029                },
7030                UpgradeInstruction::StateChange {
7031                    script: PathBuf::from("lib/m.lisp"),
7032                },
7033            ],
7034        );
7035        let err = e.validate().unwrap_err();
7036        assert_eq!(
7037            err,
7038            UpgradeError::StateChangeAfterCleanup {
7039                from: "0.1.0".into(),
7040                script: PathBuf::from("lib/m.lisp"),
7041                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7042                prior_cleanup_module: "x-old".into(),
7043            },
7044            "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
7045             naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
7046        );
7047    }
7048
7049    #[test]
7050    fn validate_rejects_state_change_after_purge() {
7051        // Per-arm coverage: `:purge` (immediate discard, no drain) is
7052        // the more catastrophic peer of `:soft-purge` on the cleanup
7053        // axis; same gate, same shape, the `prior_cleanup_kind` field
7054        // distinguishes the diagnostic so the author can grep their
7055        // caixa.lisp for the offending `(:purge …)` form.
7056        let e = entry(
7057            "0.1.0",
7058            vec![
7059                UpgradeInstruction::LoadModule { module: "x".into() },
7060                UpgradeInstruction::Purge {
7061                    module: "x-old".into(),
7062                },
7063                UpgradeInstruction::StateChange {
7064                    script: PathBuf::from("lib/m.lisp"),
7065                },
7066            ],
7067        );
7068        let err = e.validate().unwrap_err();
7069        assert_eq!(
7070            err,
7071            UpgradeError::StateChangeAfterCleanup {
7072                from: "0.1.0".into(),
7073                script: PathBuf::from("lib/m.lisp"),
7074                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7075                prior_cleanup_module: "x-old".into(),
7076            },
7077            "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
7078             `prior_cleanup_kind: \":purge\"`, got {err:?}"
7079        );
7080    }
7081
7082    #[test]
7083    fn validate_accepts_state_change_before_cleanup() {
7084        // Positive control: the canonical `(:load-module …)
7085        // (:state-change …) (:soft-purge …)` order validates — the
7086        // exact shape the module doc example and `validate_accepts_
7087        // well_formed` already pin, restated here on the new gate's
7088        // identity element so a future shortcut that runs the
7089        // singularity gates first doesn't silently mask a regression
7090        // here.
7091        let e = entry(
7092            "0.1.0",
7093            vec![
7094                UpgradeInstruction::LoadModule { module: "x".into() },
7095                UpgradeInstruction::StateChange {
7096                    script: PathBuf::from("lib/m.lisp"),
7097                },
7098                UpgradeInstruction::SoftPurge {
7099                    module: "x-old".into(),
7100                },
7101            ],
7102        );
7103        e.validate().unwrap();
7104    }
7105
7106    #[test]
7107    fn validate_accepts_cleanup_without_state_change() {
7108        // Empty-set identity: an entry that carries no `:state-change`
7109        // at all has nothing to order against the cleanup, so the gate
7110        // passes regardless of how the cleanups are placed (after the
7111        // single required `:load-module`). Mirrors the
7112        // `validate_accepts_multiple_purges_after_one_load` positive
7113        // control on the peer purge-ordering gate; metadata-only
7114        // upgrades with cleanup-but-no-migration land here.
7115        let e = entry(
7116            "0.1.0",
7117            vec![
7118                UpgradeInstruction::LoadModule { module: "x".into() },
7119                UpgradeInstruction::SoftPurge {
7120                    module: "x-old".into(),
7121                },
7122                UpgradeInstruction::Purge {
7123                    module: "x-oldest".into(),
7124                },
7125            ],
7126        );
7127        e.validate().unwrap();
7128    }
7129
7130    #[test]
7131    fn validate_accepts_state_change_without_cleanup() {
7132        // Empty-set identity on the dual axis: an entry that carries no
7133        // cleanup at all has nothing to order against the state-change,
7134        // so the gate passes — additive-upgrade shapes (load new code,
7135        // migrate state, leave old code resident for in-flight callers
7136        // to drain naturally) land here.
7137        let e = entry(
7138            "0.1.0",
7139            vec![
7140                UpgradeInstruction::LoadModule { module: "x".into() },
7141                UpgradeInstruction::StateChange {
7142                    script: PathBuf::from("lib/m.lisp"),
7143                },
7144            ],
7145        );
7146        e.validate().unwrap();
7147    }
7148
7149    #[test]
7150    fn validate_accepts_multiple_state_changes_before_cleanup() {
7151        // Coverage: every state-change must precede every cleanup, not
7152        // just the first. A chain `(load) (sc) (sc) (sp)` is the
7153        // canonical "two distinct migration scripts on a chained
7154        // upgrade" shape (one module's schema *and* another's
7155        // projection per the DuplicateStateChange diagnostic), and
7156        // it must pass when each state-change has distinct script
7157        // paths. Pinned here so a future shortcut that only checks
7158        // the first state-change doesn't silently accept a
7159        // `(load) (sc-1) (sp) (sc-2)` regression.
7160        let e = entry(
7161            "0.1.0",
7162            vec![
7163                UpgradeInstruction::LoadModule { module: "x".into() },
7164                UpgradeInstruction::StateChange {
7165                    script: PathBuf::from("lib/m1.lisp"),
7166                },
7167                UpgradeInstruction::StateChange {
7168                    script: PathBuf::from("lib/m2.lisp"),
7169                },
7170                UpgradeInstruction::SoftPurge {
7171                    module: "x-old".into(),
7172                },
7173            ],
7174        );
7175        e.validate().unwrap();
7176    }
7177
7178    #[test]
7179    fn validate_rejects_state_change_sandwiched_between_cleanups() {
7180        // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
7181        // (sp-2)` violates the gate because the state-change runs
7182        // after the first cleanup. The reported `prior_cleanup_*`
7183        // names the *first* cleanup (the load-bearing one), not the
7184        // last — mirrors every peer first-collision diagnostic
7185        // posture on this module (`validate_state_change_ordering`,
7186        // `validate_purge_ordering`, `validate_load_singularity`,
7187        // `validate_state_change_singularity`,
7188        // `validate_cleanup_singularity` all report the first
7189        // colliding instruction, not the last).
7190        let e = entry(
7191            "0.1.0",
7192            vec![
7193                UpgradeInstruction::LoadModule { module: "x".into() },
7194                UpgradeInstruction::SoftPurge {
7195                    module: "x-old".into(),
7196                },
7197                UpgradeInstruction::StateChange {
7198                    script: PathBuf::from("lib/m.lisp"),
7199                },
7200                UpgradeInstruction::Purge {
7201                    module: "y-old".into(),
7202                },
7203            ],
7204        );
7205        let err = e.validate().unwrap_err();
7206        assert_eq!(
7207            err,
7208            UpgradeError::StateChangeAfterCleanup {
7209                from: "0.1.0".into(),
7210                script: PathBuf::from("lib/m.lisp"),
7211                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7212                prior_cleanup_module: "x-old".into(),
7213            },
7214            "the first cleanup the state-change follows must surface (not the trailing one), \
7215             got {err:?}"
7216        );
7217    }
7218
7219    #[test]
7220    fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
7221        // Diagnostic-precedence pin: an entry like `((:soft-purge
7222        // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
7223        // *both* purge-without-load (the cleanup runs before the
7224        // load) and state-change-after-cleanup (the state-change
7225        // runs after the cleanup). The more-fundamental ordering
7226        // gate must win — the missing-load defect (a cleanup that
7227        // drains the only resident version to nothing) is load-
7228        // bearing, and surfacing the state-change-after-cleanup
7229        // diagnostic first would mask the drain-to-nothing defect
7230        // the peer purge-ordering gate exists to close. Guards the
7231        // call order in `validate` against silent reordering. Same
7232        // posture as `validate_purge_ordering_fires_after_state_
7233        // change_ordering` on the sibling ordering gate.
7234        //
7235        // Pin specifically uses the load-after-cleanup shape (rather
7236        // than load-less) so the state-change-ordering gate (which
7237        // would otherwise fire first on a `((:soft-purge …)
7238        // (:state-change …))` shape with no leading load) is
7239        // sidestepped: with the load present after the cleanup,
7240        // state-change-ordering passes (its `loaded` latch is set
7241        // before the state-change is encountered) but purge-ordering
7242        // still fails (the cleanup precedes the load). That isolates
7243        // the precedence between purge-ordering and this gate
7244        // cleanly.
7245        let e = entry(
7246            "0.1.0",
7247            vec![
7248                UpgradeInstruction::SoftPurge {
7249                    module: "x-old".into(),
7250                },
7251                UpgradeInstruction::LoadModule { module: "x".into() },
7252                UpgradeInstruction::StateChange {
7253                    script: PathBuf::from("lib/m.lisp"),
7254                },
7255            ],
7256        );
7257        let err = e.validate().unwrap_err();
7258        assert!(
7259            matches!(
7260                err,
7261                UpgradeError::PurgeWithoutPriorLoad {
7262                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7263                    ..
7264                }
7265            ),
7266            "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
7267        );
7268    }
7269
7270    #[test]
7271    fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
7272        // Diagnostic-precedence pin: an entry like `((:state-change
7273        // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
7274        // load (because no `:load-module` precedes the state-change)
7275        // but *not* state-change-after-cleanup (the state-change
7276        // precedes the cleanup textually). The state-change-ordering
7277        // gate must surface first regardless — the missing-load
7278        // defect on the migration axis is the load-bearing semantic
7279        // and surfacing a different ordering diagnostic would mask
7280        // the migration-against-stale-code defect. Guards the call
7281        // order in `validate` against silent reordering on a shape
7282        // that fires only the state-change-ordering gate (not this
7283        // one), pinning that the state-change-ordering gate wins
7284        // ahead of this gate's chance to look at the list.
7285        let e = entry(
7286            "0.1.0",
7287            vec![
7288                UpgradeInstruction::StateChange {
7289                    script: PathBuf::from("lib/m.lisp"),
7290                },
7291                UpgradeInstruction::SoftPurge {
7292                    module: "x-old".into(),
7293                },
7294            ],
7295        );
7296        let err = e.validate().unwrap_err();
7297        assert!(
7298            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
7299            "state-change-without-load must surface before purge-without-load (the canonical \
7300             validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
7301        );
7302    }
7303
7304    #[test]
7305    fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
7306        // Order pin: a malformed `:script` value on a `:state-change`
7307        // (an empty path) surfaces its narrower `EmptyScript`
7308        // diagnostic *before* the within-entry state-change-before-
7309        // cleanup gate fires. The per-instruction shape pass walks
7310        // the list inline before the ordering check, so the narrower
7311        // self-locating diagnostic surfaces first — mirrors the
7312        // empty-first cascade on every peer path-shape gate and the
7313        // `validate_purge_ordering_fires_after_per_instr_shape` pin
7314        // on the sibling ordering gate.
7315        let e = 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::new(),
7324                },
7325            ],
7326        );
7327        let err = e.validate().unwrap_err();
7328        assert_eq!(
7329            err,
7330            UpgradeError::EmptyScript,
7331            "malformed instruction must surface its narrower diagnostic before the \
7332             state-change-before-cleanup gate fires, got {err:?}"
7333        );
7334    }
7335
7336    #[test]
7337    fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
7338        // Diagnostic-precedence pin: an entry like `((:load-module
7339        // "x") (:soft-purge "x-old") (:state-change "m.lisp")
7340        // (:state-change "m.lisp"))` violates *both* this ordering
7341        // gate (the first state-change follows the cleanup) and the
7342        // state-change-singularity gate (the same script appears
7343        // twice). The ordering gate must win — the canonical
7344        // "ordering before singularity" precedence the peer
7345        // `validate_state_change_ordering` / `validate_purge_
7346        // ordering` gates already establish over their own singularity
7347        // gates, applied uniformly across the OTP canonical-sequence
7348        // ordering axis here. Guards the call order in `validate`:
7349        // `validate_state_change_before_cleanup` runs before the
7350        // per-instruction-class singularity gates.
7351        let e = entry(
7352            "0.1.0",
7353            vec![
7354                UpgradeInstruction::LoadModule { module: "x".into() },
7355                UpgradeInstruction::SoftPurge {
7356                    module: "x-old".into(),
7357                },
7358                UpgradeInstruction::StateChange {
7359                    script: PathBuf::from("lib/m.lisp"),
7360                },
7361                UpgradeInstruction::StateChange {
7362                    script: PathBuf::from("lib/m.lisp"),
7363                },
7364            ],
7365        );
7366        let err = e.validate().unwrap_err();
7367        assert!(
7368            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7369            "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7370        );
7371    }
7372
7373    #[test]
7374    fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7375        // The whole-list entry-point surfaces the per-entry ordering
7376        // error (mirrors `validate_purge_ordering_threads_through_
7377        // validate_upgrade_from` and every peer wiring pin): the gate
7378        // is reachable from the LayoutInvariants call site, not only
7379        // from a direct `entry.validate()`.
7380        let entries = vec![entry(
7381            "0.1.0",
7382            vec![
7383                UpgradeInstruction::LoadModule { module: "x".into() },
7384                UpgradeInstruction::SoftPurge {
7385                    module: "x-old".into(),
7386                },
7387                UpgradeInstruction::StateChange {
7388                    script: PathBuf::from("lib/m.lisp"),
7389                },
7390            ],
7391        )];
7392        let err = validate_upgrade_from(&entries).unwrap_err();
7393        assert!(
7394            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7395            "validate_upgrade_from must thread the state-change-before-cleanup error, \
7396             got {err:?}"
7397        );
7398    }
7399
7400    #[test]
7401    fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7402        // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7403        // per-instruction `StateChange`-arm script-path projection must
7404        // route through the sibling lifted
7405        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7406        // accessor, not the raw
7407        // `if let UpgradeInstruction::StateChange { script } = instr`
7408        // open-coded pattern-match the gate previously carried inside
7409        // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7410        //
7411        // Structurally: the gate's projection accept-set is the union
7412        // of every [`UpgradeInstruction`] variant for which
7413        // `declared_path().is_some()` — today exactly
7414        // [`UpgradeInstruction::StateChange`] per the sibling
7415        // `declared_path_only_for_state_change` pin, so a
7416        // state-change-after-cleanup input trips
7417        // `StateChangeAfterCleanup` and a non-`StateChange` input
7418        // (module-bearing / terminal) leaves the sticky-once latch
7419        // sweep quiet byte-identical to the pattern-match shape.
7420        //
7421        // Byte-equal today (`declared_path` returns `Some(script)` iff
7422        // `StateChange`, byte-for-byte from the variant's own storage);
7423        // the pin catches any future accessor extension that promotes
7424        // an additional variant onto the `PathBuf`-carrying axis — the
7425        // gate then fires on migrate-after-cleanup for that variant too,
7426        // and the migrate→cleanup ordering discipline the peer
7427        // [`validate_state_change_singularity`] /
7428        // [`validate_upgrade_from_against_behavior`] gates share on the
7429        // same axis extends to the promoted variant by construction.
7430        //
7431        // Peer of the sibling four per-`UpgradeInstruction` consumers
7432        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7433        // sandbox-path fan-out, the layout-side per-`StateChange`
7434        // script-existence fan-out at
7435        // `caixa-core/src/layout.rs:1058`, the within-entry
7436        // [`UpgradeFromEntry::validate_state_change_singularity`]
7437        // per-`StateChange` script-projection fan-out, the cross-slot
7438        // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7439        // detection loop) — the fifth (and last unlifted inside
7440        // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7441        // the `PathBuf`-carrying axis to now route through the accessor.
7442        // Same shape as the sibling
7443        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7444        // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7445        // pins extended onto the within-entry migrate→cleanup ordering
7446        // gate.
7447        //
7448        // Three-arm projective coverage:
7449        //   (a) `StateChange` scripts project through `declared_path()`
7450        //       byte-equal to the raw `script.clone()` field access
7451        //       the diagnostic previously carried;
7452        //   (b) a `:state-change`-after-cleanup input trips the gate
7453        //       with `StateChangeAfterCleanup` carrying the offending
7454        //       script + the prior cleanup's kind/module verbatim;
7455        //   (c) a non-`StateChange`-only input (`LoadModule` /
7456        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7457        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7458        //       arm's fall-through pins.
7459        //
7460        // Fail-before-pass-after verified structurally: swapping the
7461        // production
7462        //   `else if let Some(script) = instr.declared_path() && … { … }`
7463        // back to
7464        //   `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7465        // keeps arms (a)-(c) passing but silently detaches this within-
7466        // entry ordering gate from the accessor's typed dispatch — any
7467        // future `declared_path` extension (promotion of an additional
7468        // variant onto the axis, an operator-side pre-resolved-path
7469        // cache the accessor materializes) would then silently disagree
7470        // between this gate's raw pattern-match and the peer four
7471        // sibling consumers that route through the accessor.
7472
7473        // (a) StateChange projection byte-equal via declared_path.
7474        let sc = UpgradeInstruction::StateChange {
7475            script: PathBuf::from("lib/m.lisp"),
7476        };
7477        assert_eq!(
7478            sc.declared_path().cloned(),
7479            Some(PathBuf::from("lib/m.lisp")),
7480            "declared_path() must project the StateChange :script byte-equal to the raw \
7481             field access — accessor divergence would silently detach this within-entry \
7482             migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7483             consumer routes through"
7484        );
7485
7486        // (b) StateChange-after-cleanup trips the gate through the accessor.
7487        let after = entry(
7488            "0.1.0",
7489            vec![
7490                UpgradeInstruction::LoadModule { module: "x".into() },
7491                UpgradeInstruction::SoftPurge {
7492                    module: "x-old".into(),
7493                },
7494                UpgradeInstruction::StateChange {
7495                    script: PathBuf::from("lib/m.lisp"),
7496                },
7497            ],
7498        );
7499        assert_eq!(
7500            after.validate(),
7501            Err(UpgradeError::StateChangeAfterCleanup {
7502                from: "0.1.0".into(),
7503                script: PathBuf::from("lib/m.lisp"),
7504                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7505                prior_cleanup_module: "x-old".into(),
7506            }),
7507            "a :state-change following a cleanup must trip the gate through the declared_path \
7508             accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7509             kind/module verbatim byte-identical to the pattern-match shape"
7510        );
7511
7512        // (c) Non-StateChange-only inputs leave the gate vacuous.
7513        for instrs in [
7514            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7515            vec![
7516                UpgradeInstruction::LoadModule { module: "x".into() },
7517                UpgradeInstruction::SoftPurge {
7518                    module: "x-old".into(),
7519                },
7520            ],
7521            vec![
7522                UpgradeInstruction::LoadModule { module: "x".into() },
7523                UpgradeInstruction::Purge {
7524                    module: "x-old".into(),
7525                },
7526            ],
7527            vec![UpgradeInstruction::Restart],
7528        ] {
7529            for instr in &instrs {
7530                assert!(
7531                    instr.declared_path().is_none(),
7532                    "non-StateChange variants must project None through declared_path — \
7533                     accessor divergence would let this within-entry ordering gate silently \
7534                     fire on a cleanup-only sequence far from any :state-change site"
7535                );
7536            }
7537            let e = entry("0.1.0", instrs);
7538            assert_eq!(
7539                e.validate(),
7540                Ok(()),
7541                "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7542                 instructions all project None through declared_path — the accessor's \
7543                 None arm the pattern-match's implicit fall-through previously carried"
7544            );
7545        }
7546    }
7547
7548    #[test]
7549    fn validate_restart_order_independent() {
7550        // Position-agnostic: `(:restart)` leading or trailing the
7551        // mixed sequence surfaces the same RestartNotExclusive shape.
7552        // Mirrors OTP appup's order-insensitive
7553        // `restart_emulator | restart_new_emulator` terminal rule —
7554        // the position of the restart instruction in the script is
7555        // irrelevant; what matters is the script *contains* it
7556        // alongside other instructions at all. The gate must not
7557        // gain a false positive by depending on instruction ordering.
7558        let leading = entry(
7559            "0.1.0",
7560            vec![
7561                UpgradeInstruction::Restart,
7562                UpgradeInstruction::LoadModule { module: "x".into() },
7563            ],
7564        );
7565        let trailing = entry(
7566            "0.1.0",
7567            vec![
7568                UpgradeInstruction::LoadModule { module: "x".into() },
7569                UpgradeInstruction::Restart,
7570            ],
7571        );
7572        let middle = entry(
7573            "0.1.0",
7574            vec![
7575                UpgradeInstruction::LoadModule { module: "a".into() },
7576                UpgradeInstruction::Restart,
7577                UpgradeInstruction::SoftPurge {
7578                    module: "a-old".into(),
7579                },
7580            ],
7581        );
7582        for e in [&leading, &trailing, &middle] {
7583            assert!(
7584                matches!(
7585                    e.validate().unwrap_err(),
7586                    UpgradeError::RestartNotExclusive {
7587                        restart_count: 1,
7588                        ..
7589                    }
7590                ),
7591                "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7592                 instruction order, got {:?}",
7593                e.validate()
7594            );
7595        }
7596    }
7597
7598    #[test]
7599    fn validate_restart_exclusive_fires_after_per_instr_shape() {
7600        // Order pin: a malformed `:module` value on a Module-bearing
7601        // instruction (an empty string) surfaces its narrower
7602        // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7603        // entry restart-exclusivity gate fires. The per-instruction
7604        // shape pass walks the list inline before the restart-
7605        // exclusive check, so the narrower self-locating diagnostic
7606        // surfaces first — mirrors the empty-first cascade on every
7607        // peer DNS-1123 gate (`validate_module`,
7608        // `validate_membro_caixa`, `validate_placement_cluster`) and
7609        // the `*_invalid_fires_before_duplicate_check` arm-ordering
7610        // pins on every typed-graph axis. Without this pin a future
7611        // shortcut that runs the restart-exclusive check ahead of
7612        // per-instruction shape would surface a less-actionable
7613        // RestartNotExclusive over an instruction list that's also
7614        // malformed at the per-instruction layer.
7615        let e = entry(
7616            "0.1.0",
7617            vec![
7618                UpgradeInstruction::LoadModule {
7619                    module: String::new(),
7620                },
7621                UpgradeInstruction::Restart,
7622            ],
7623        );
7624        let err = e.validate().unwrap_err();
7625        assert_eq!(
7626            err,
7627            UpgradeError::ModuleEmpty {
7628                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7629            },
7630            "malformed instruction must surface its kind-tagged diagnostic before the \
7631             restart-exclusivity gate fires, got {err:?}"
7632        );
7633    }
7634
7635    fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7636        // Helper for the cross-slot composition gate's pass arm: a
7637        // BehaviorSpec carrying just the `:on-state-change` callback,
7638        // the runtime hook the per-version `(:state-change "…")`
7639        // instruction is delivered through during hot upgrade. Mirrors
7640        // the canonical authoring shape pinned in the module doc.
7641        crate::BehaviorSpec {
7642            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7643            ..Default::default()
7644        }
7645    }
7646
7647    #[test]
7648    fn behavior_gate_rejects_state_change_without_any_behavior() {
7649        // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7650        // caixa carries no `:behavior` at all surfaces the missing-
7651        // callback diagnostic naming the offending entry's `:from` +
7652        // script. The "I added the upgrade path but never declared
7653        // `:behavior`" footgun: `:behavior` is optional at the typed
7654        // root, the typed `:upgrade-from` slot validates on its own
7655        // merits, and the operator's hot-upgrade dispatch reaches for
7656        // a callback that doesn't exist.
7657        let entries = vec![entry(
7658            "0.1.0",
7659            vec![
7660                UpgradeInstruction::LoadModule { module: "x".into() },
7661                UpgradeInstruction::StateChange {
7662                    script: PathBuf::from("lib/m.lisp"),
7663                },
7664            ],
7665        )];
7666        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7667        assert_eq!(
7668            err,
7669            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7670                from: "0.1.0".into(),
7671                script: PathBuf::from("lib/m.lisp"),
7672            },
7673        );
7674    }
7675
7676    #[test]
7677    fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7678        // `:behavior` declared with *other* callbacks set
7679        // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7680        // None still surfaces the missing-callback diagnostic — only
7681        // the `:on-state-change` axis matters for this gate. The
7682        // "I declared `:behavior` but missed the migration callback"
7683        // footgun: a caixa that registers its lifecycle hooks but
7684        // forgets the migration delivery path leaves the
7685        // `:state-change` instruction with no runtime hook to
7686        // dispatch through.
7687        let entries = vec![entry(
7688            "0.1.0",
7689            vec![
7690                UpgradeInstruction::LoadModule { module: "x".into() },
7691                UpgradeInstruction::StateChange {
7692                    script: PathBuf::from("lib/m.lisp"),
7693                },
7694            ],
7695        )];
7696        let b = crate::BehaviorSpec {
7697            on_init: Some(PathBuf::from("lib/init.lisp")),
7698            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7699            ..Default::default()
7700        };
7701        let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7702        assert_eq!(
7703            err,
7704            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7705                from: "0.1.0".into(),
7706                script: PathBuf::from("lib/m.lisp"),
7707            },
7708            "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7709             the missing migration hook"
7710        );
7711    }
7712
7713    #[test]
7714    fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7715        // The canonical composition shape: a per-version
7716        // `(:state-change "lib/m.lisp")` instruction paired with the
7717        // `:behavior :on-state-change "lib/migrations.lisp"` callback
7718        // it is delivered through at hot-upgrade time. Pins the gate's
7719        // pass arm — drift here = a future tighten that rejects the
7720        // canonical OTP-shape composition surfaces as a regression at
7721        // this positive-control pin.
7722        let entries = vec![entry(
7723            "0.1.0",
7724            vec![
7725                UpgradeInstruction::LoadModule { module: "x".into() },
7726                UpgradeInstruction::StateChange {
7727                    script: PathBuf::from("lib/m.lisp"),
7728                },
7729            ],
7730        )];
7731        let b = behavior_with_state_change_callback();
7732        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7733    }
7734
7735    #[test]
7736    fn behavior_gate_accepts_entries_without_any_state_change() {
7737        // Empty-set identity: entries carrying no `:state-change`
7738        // instruction at all (load + cleanup only — the metadata-only
7739        // upgrade shape the module doc names, "On any failure, the
7740        // current version stays load-bearing — a typed atomic
7741        // upgrade") leave the gate vacuous. The composition only
7742        // requires a callback when the per-version script exists; a
7743        // load + cleanup pair has no migration to deliver, so the
7744        // absence of `:on-state-change` is coherent.
7745        let entries = vec![entry(
7746            "0.1.0",
7747            vec![
7748                UpgradeInstruction::LoadModule { module: "x".into() },
7749                UpgradeInstruction::SoftPurge {
7750                    module: "x-old".into(),
7751                },
7752            ],
7753        )];
7754        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7755    }
7756
7757    #[test]
7758    fn behavior_gate_accepts_restart_only_entry() {
7759        // The terminal-fallback `((:restart))` shape carries no
7760        // `:state-change` — the operator restarts the pod and the
7761        // new version comes up fresh against its initial state, no
7762        // migration. Pinned alongside the metadata-only positive
7763        // control above as the second empty-state-change shape.
7764        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7765        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7766    }
7767
7768    #[test]
7769    fn behavior_gate_accepts_empty_entries_list() {
7770        // Empty `:upgrade-from` (a caixa with no declared upgrade
7771        // paths — the v0.1.0 caixa before any upgrade entries are
7772        // added) trivially passes the gate. Pinned so the gate
7773        // doesn't accidentally fire on a caixa that hasn't yet
7774        // declared any upgrades.
7775        let entries: Vec<UpgradeFromEntry> = vec![];
7776        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7777    }
7778
7779    #[test]
7780    fn behavior_gate_reports_first_state_change_in_first_entry() {
7781        // First-collision determinism: with multiple `:state-change`
7782        // instructions across multiple entries, the gate reports the
7783        // *first* one encountered in declaration order — the entry's
7784        // declaration order first, then the within-entry instruction
7785        // order. Mirrors every peer first-collision diagnostic posture
7786        // on this module (`validate_state_change_ordering`,
7787        // `validate_purge_ordering`, the singularity gates), so a
7788        // future shortcut that walks the list in reverse or returns
7789        // the last collision surfaces as a regression here.
7790        let entries = vec![
7791            entry(
7792                "0.1.0",
7793                vec![
7794                    UpgradeInstruction::LoadModule { module: "x".into() },
7795                    UpgradeInstruction::StateChange {
7796                        script: PathBuf::from("lib/m1.lisp"),
7797                    },
7798                    UpgradeInstruction::StateChange {
7799                        script: PathBuf::from("lib/m2.lisp"),
7800                    },
7801                ],
7802            ),
7803            entry(
7804                "0.1.5",
7805                vec![
7806                    UpgradeInstruction::LoadModule { module: "x".into() },
7807                    UpgradeInstruction::StateChange {
7808                        script: PathBuf::from("lib/m3.lisp"),
7809                    },
7810                ],
7811            ),
7812        ];
7813        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7814        assert_eq!(
7815            err,
7816            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7817                from: "0.1.0".into(),
7818                script: PathBuf::from("lib/m1.lisp"),
7819            },
7820            "the first :state-change in the first entry must surface, not later collisions"
7821        );
7822    }
7823
7824    #[test]
7825    fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7826        // Cross-entry pin: a first entry with no `:state-change` (just
7827        // a load + cleanup) leaves the gate's per-entry walk continuing
7828        // to the second entry, where the offending instruction lives.
7829        // The diagnostic names the *second* entry's `:from` because
7830        // that's where the missing-callback shape is exposed — pinned
7831        // so a shortcut that bails on the first entry without a
7832        // `:state-change` (rather than continuing) doesn't mask the
7833        // defect in a later entry.
7834        let entries = vec![
7835            entry(
7836                "0.1.0",
7837                vec![
7838                    UpgradeInstruction::LoadModule { module: "x".into() },
7839                    UpgradeInstruction::SoftPurge {
7840                        module: "x-old".into(),
7841                    },
7842                ],
7843            ),
7844            entry(
7845                "0.1.5",
7846                vec![
7847                    UpgradeInstruction::LoadModule { module: "x".into() },
7848                    UpgradeInstruction::StateChange {
7849                        script: PathBuf::from("lib/m.lisp"),
7850                    },
7851                ],
7852            ),
7853        ];
7854        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7855        assert_eq!(
7856            err,
7857            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7858                from: "0.1.5".into(),
7859                script: PathBuf::from("lib/m.lisp"),
7860            },
7861            "the offending entry's `:from` must surface even when an earlier entry carries no \
7862             :state-change"
7863        );
7864    }
7865
7866    #[test]
7867    fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7868        // Positive control: a multi-entry `:upgrade-from` (chained
7869        // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7870        // a `:state-change` passes when the callback is declared once
7871        // at the caixa root. The callback is a single per-caixa
7872        // runtime hook; one declaration covers every entry's
7873        // `:state-change`, mirroring OTP's
7874        // `release_handler:install_release/1` which dispatches every
7875        // appup's `code_change` instruction through the single
7876        // `gen_server:code_change/3` callback registered on the
7877        // module.
7878        let entries = vec![
7879            entry(
7880                "0.1.0",
7881                vec![
7882                    UpgradeInstruction::LoadModule { module: "x".into() },
7883                    UpgradeInstruction::StateChange {
7884                        script: PathBuf::from("lib/m1.lisp"),
7885                    },
7886                ],
7887            ),
7888            entry(
7889                "0.1.5",
7890                vec![
7891                    UpgradeInstruction::LoadModule { module: "x".into() },
7892                    UpgradeInstruction::StateChange {
7893                        script: PathBuf::from("lib/m2.lisp"),
7894                    },
7895                ],
7896            ),
7897        ];
7898        let b = behavior_with_state_change_callback();
7899        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7900    }
7901
7902    #[test]
7903    fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7904        // Symmetry pin: the gate's pass arm doesn't depend on the
7905        // entry actually carrying a `:state-change` — if no
7906        // `:state-change` is declared, the gate is vacuous regardless
7907        // of the callback (an `:on-state-change` declared without a
7908        // matching per-version script is fine, the callback is the
7909        // runtime default for any *future* migration the author hasn't
7910        // yet added). Pins that a caixa author can declare the
7911        // callback ahead of any migration without the gate
7912        // complaining.
7913        let entries = vec![entry(
7914            "0.1.0",
7915            vec![
7916                UpgradeInstruction::LoadModule { module: "x".into() },
7917                UpgradeInstruction::SoftPurge {
7918                    module: "x-old".into(),
7919                },
7920            ],
7921        )];
7922        let b = behavior_with_state_change_callback();
7923        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7924    }
7925
7926    #[test]
7927    fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7928        // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7929        // per-instruction `StateChange`-arm script-path projection must
7930        // route through the sibling lifted
7931        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7932        // accessor, not the raw
7933        // `if let UpgradeInstruction::StateChange { script } = instr`
7934        // open-coded pattern-match the cross-slot gate previously
7935        // carried at caixa-core/src/upgrade.rs:1365.
7936        //
7937        // Structurally: the gate's projection accept-set is the union
7938        // of every [`UpgradeInstruction`] variant for which
7939        // `declared_path().is_some()` — today exactly
7940        // [`UpgradeInstruction::StateChange`] per the sibling
7941        // `declared_path_only_for_state_change` pin, so a
7942        // `:state-change`-carrying entry without an `:on-state-change`
7943        // callback trips `StateChangeWithoutOnStateChangeCallback` and
7944        // a non-`StateChange` entry (load-only / cleanup-only /
7945        // restart-only / empty-`:instructions`) leaves the per-entry
7946        // walk continuing past every non-projecting instruction
7947        // byte-identical to the pattern-match shape.
7948        //
7949        // Byte-equal today (`declared_path` returns `Some(script)` iff
7950        // `StateChange`, byte-for-byte from the variant's own storage);
7951        // the pin catches any future accessor extension that promotes
7952        // an additional variant onto the `PathBuf`-carrying axis — the
7953        // gate then fires on scripts from that variant too, and the
7954        // cross-slot composition discipline the sibling per-
7955        // `UpgradeInstruction` consumers share on the `PathBuf`-
7956        // carrying axis extends to the promoted variant by
7957        // construction.
7958        //
7959        // Peer of the sibling four per-`UpgradeInstruction` consumers
7960        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7961        // sandbox-path fan-out, the layout-side per-`StateChange`
7962        // script-existence fan-out at
7963        // `caixa-core/src/layout.rs:1058`, the within-entry
7964        // [`UpgradeFromEntry::validate_state_change_singularity`]
7965        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7966        // peer [`UpgradeInstruction::declared_module`] `String`-axis
7967        // per-variant unifier) — the fourth (and last) per-
7968        // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7969        // to now route through the accessor. Same shape as the
7970        // sibling
7971        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7972        // pin extended onto the cross-slot composition gate.
7973        //
7974        // Three-arm projective coverage:
7975        //   (a) `StateChange` scripts project through `declared_path()`
7976        //       byte-equal to the raw `script.clone()` field access
7977        //       the diagnostic previously carried;
7978        //   (b) a `:state-change`-carrying entry with `behavior: None`
7979        //       trips the gate with `StateChangeWithoutOnStateChangeCallback`
7980        //       carrying the offending script verbatim;
7981        //   (c) a non-`StateChange`-only entry (`LoadModule` /
7982        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7983        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7984        //       arm's fall-through pins.
7985        //
7986        // Fail-before-pass-after verified structurally: swapping the
7987        // production
7988        //   `if let Some(script) = instr.declared_path() { … }`
7989        // back to
7990        //   `if let UpgradeInstruction::StateChange { script } = instr { … }`
7991        // keeps arms (a)-(c) passing but silently detaches the gate
7992        // from the accessor's typed dispatch — any future
7993        // `declared_path` extension (promotion of an additional
7994        // variant onto the axis, an operator-side pre-resolved-path
7995        // cache the accessor materializes) would then silently
7996        // disagree between this cross-slot gate's raw pattern-match
7997        // and the peer four sibling consumers that route through the
7998        // accessor.
7999
8000        // (a) StateChange projection byte-equal via declared_path.
8001        let sc = UpgradeInstruction::StateChange {
8002            script: PathBuf::from("lib/m.lisp"),
8003        };
8004        assert_eq!(
8005            sc.declared_path().cloned(),
8006            Some(PathBuf::from("lib/m.lisp")),
8007            "declared_path() must project the StateChange :script byte-equal to the raw \
8008             field access — accessor divergence would silently detach this cross-slot \
8009             composition gate from the projection every peer per-`UpgradeInstruction` \
8010             consumer routes through"
8011        );
8012
8013        // (b) StateChange-carrying entry with behavior: None trips gate.
8014        let entries = vec![entry(
8015            "0.1.0",
8016            vec![
8017                UpgradeInstruction::LoadModule { module: "x".into() },
8018                UpgradeInstruction::StateChange {
8019                    script: PathBuf::from("lib/m.lisp"),
8020                },
8021            ],
8022        )];
8023        assert_eq!(
8024            validate_upgrade_from_against_behavior(&entries, None),
8025            Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
8026                from: "0.1.0".into(),
8027                script: PathBuf::from("lib/m.lisp"),
8028            }),
8029            "a :state-change-carrying entry with behavior: None must trip the gate through \
8030             the declared_path accessor's Some(script) arm — carrying the offending script \
8031             verbatim byte-identical to the pattern-match shape"
8032        );
8033
8034        // (c) Non-StateChange-only inputs leave the gate vacuous.
8035        for instrs in [
8036            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8037            vec![
8038                UpgradeInstruction::LoadModule { module: "x".into() },
8039                UpgradeInstruction::SoftPurge {
8040                    module: "x-old".into(),
8041                },
8042            ],
8043            vec![
8044                UpgradeInstruction::LoadModule { module: "x".into() },
8045                UpgradeInstruction::Purge {
8046                    module: "x-old".into(),
8047                },
8048            ],
8049            vec![UpgradeInstruction::Restart],
8050        ] {
8051            for instr in &instrs {
8052                assert!(
8053                    instr.declared_path().is_none(),
8054                    "non-StateChange variants must project None through declared_path — \
8055                     accessor divergence would let this cross-slot composition gate silently \
8056                     fire on a module reference far from any :state-change site"
8057                );
8058            }
8059            let entries = vec![entry("0.1.0", instrs)];
8060            assert_eq!(
8061                validate_upgrade_from_against_behavior(&entries, None),
8062                Ok(()),
8063                "the cross-slot composition gate must return Ok(()) on an entry whose \
8064                 instructions all project None through declared_path — the accessor's \
8065                 None arm the pattern-match's implicit fall-through previously carried"
8066            );
8067        }
8068    }
8069
8070    #[test]
8071    fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
8072        // Wiring pin: the within-entry restart-exclusivity gate fires
8073        // through [`validate_upgrade_from`] (which delegates to
8074        // [`UpgradeFromEntry::validate`] per entry) before the cross-
8075        // entry duplicate-`:from` gate would have a chance to run on
8076        // the malformed entry. Pinned here so a future refactor that
8077        // walks the cross-entry gate first doesn't accidentally
8078        // surface a DuplicateFrom over an entry that's also malformed
8079        // at the within-entry restart-exclusivity layer.
8080        let entries = vec![
8081            entry(
8082                "0.1.0",
8083                vec![
8084                    UpgradeInstruction::LoadModule { module: "x".into() },
8085                    UpgradeInstruction::Restart,
8086                ],
8087            ),
8088            entry("0.1.0", vec![UpgradeInstruction::Restart]),
8089        ];
8090        let err = validate_upgrade_from(&entries).unwrap_err();
8091        assert!(
8092            matches!(
8093                err,
8094                UpgradeError::RestartNotExclusive {
8095                    restart_count: 1,
8096                    ..
8097                }
8098            ),
8099            "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
8100             duplicate-`:from` gate fires, got {err:?}"
8101        );
8102    }
8103
8104    // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
8105
8106    #[test]
8107    fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
8108        // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
8109        // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
8110        // name the exact camelCase JSON keys the `#[serde(rename_all =
8111        // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
8112        // test-side probe across the caixa-core / caixa-flux renderer
8113        // test fixtures navigates into each element of the rendered
8114        // `:upgrade-from` overlay sequence by consulting one of these two
8115        // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
8116        // and pin that each canonical byte-sequence appears verbatim in
8117        // the JSON — a future accidental `rename_all = "snake_case"` /
8118        // `"kebab-case"` / verbatim-field-name flip at the derive
8119        // attribute (any of which would silently break every test-side
8120        // probe that reaches for one of the two consts) surfaces here as
8121        // a build-time test failure at `upgrade.rs`, not as an apply-time
8122        // `.get(<stale-canonical-const>)` returning `None` far from the
8123        // derive-attr drift's commit. Same discipline the sibling
8124        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
8125        // (d8b8b4f) and
8126        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
8127        // (21fe462) pins established on the peer `:limits` / `:behavior`
8128        // sub-slot axes: one canonical byte-string per typed sub-key
8129        // axis, pinned to the load-bearing serde derivation at the type
8130        // itself.
8131        let e = UpgradeFromEntry {
8132            from: "0.1.0".into(),
8133            instructions: vec![UpgradeInstruction::LoadModule {
8134                module: "hello-rio".into(),
8135            }],
8136        };
8137        let json = serde_json::to_string(&e).unwrap();
8138        for key in [
8139            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8140            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8141        ] {
8142            let quoted = format!("\"{key}\"");
8143            assert!(
8144                json.contains(&quoted),
8145                "serialized UpgradeFromEntry must carry the lifted \
8146                 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
8147                 the JSON emission (got: {json})",
8148            );
8149        }
8150    }
8151
8152    #[test]
8153    fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
8154        // Cross-axis drift-detection pin: a future collapse of the two
8155        // canonical sub-key byte-strings onto the same value (e.g. an
8156        // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
8157        // to also read `"from"`) would silently reroute every test-side
8158        // probe on one axis onto the sibling axis's per-entry field and
8159        // pass every propagation-probe test that expected only the stale
8160        // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
8161        // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
8162        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8163        let all = [
8164            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8165            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8166        ];
8167        for (i, a) in all.iter().enumerate() {
8168            for b in all.iter().skip(i + 1) {
8169                assert_ne!(
8170                    a, b,
8171                    "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
8172                     canonical byte-sequences — got `{a}` == `{b}`",
8173                );
8174            }
8175        }
8176    }
8177
8178    #[test]
8179    fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
8180        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8181        // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
8182        // tagged variant-discriminator key axis: the
8183        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
8184        // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
8185        // attribute on [`UpgradeInstruction`] emits, and every downstream
8186        // consumer that navigates the serialized instruction blob to
8187        // route by variant (the caixa-core reflection-vs-serde round-trip
8188        // check in `dispatcher_registration.rs` that probes
8189        // `v.get("kind")` against every variant's expected kebab-case
8190        // tag, the future M4 admission-webhook path, any wasm-operator
8191        // dispatch step consuming the serialized instruction blob) reads
8192        // through the same `&'static str`. Serialize every variant and
8193        // pin that the const's byte-sequence appears verbatim as the
8194        // tag-slot JSON key with the expected kebab-case value — a
8195        // future accidental `tag = "type"` / `tag = "op"` /
8196        // `tag = "instruction"` rebrand at the derive attribute (any of
8197        // which would silently break every consumer probe reaching for
8198        // the stale-tag-key const) surfaces here as a build-time test
8199        // failure at `upgrade.rs`, not as an apply-time
8200        // `.get(<stale-tag-key>)` returning `None` far from the derive-
8201        // attr drift's commit.
8202        //
8203        // Same "one canonical byte-string per typed axis" discipline the
8204        // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8205        // pin (36ffe65) established on the peer `:upgrade-from` per-entry
8206        // outer-container axis — this pin extends the discipline one
8207        // altitude deeper onto the per-instruction *tag* axis inside
8208        // each element of the `:instructions` list, completing the
8209        // typed coverage of the `:upgrade-from :instructions` dual
8210        // (key = "kind" + five variant-value tags): the five
8211        // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
8212        // per-variant kebab-case *values*; this pin pins the tag *key*
8213        // above them.
8214        let samples: [(UpgradeInstruction, &'static str); 5] = [
8215            (
8216                UpgradeInstruction::LoadModule {
8217                    module: "hello-rio".into(),
8218                },
8219                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
8220            ),
8221            (
8222                UpgradeInstruction::StateChange {
8223                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8224                },
8225                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
8226            ),
8227            (
8228                UpgradeInstruction::SoftPurge {
8229                    module: "hello-rio-old".into(),
8230                },
8231                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
8232            ),
8233            (
8234                UpgradeInstruction::Purge {
8235                    module: "hello-rio-old".into(),
8236                },
8237                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
8238            ),
8239            (
8240                UpgradeInstruction::Restart,
8241                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
8242            ),
8243        ];
8244        for (sample, expected_value) in &samples {
8245            let v: serde_json::Value = serde_json::to_value(sample).unwrap();
8246            let got = v
8247                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
8248                .and_then(|k| k.as_str());
8249            assert_eq!(
8250                got,
8251                Some(*expected_value),
8252                "serialized {sample:?} must carry the lifted \
8253                 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
8254                 ({:?}) verbatim as the tag-slot JSON key, holding the \
8255                 expected kebab-case value {expected_value:?} (got: {v})",
8256                crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
8257            );
8258        }
8259    }
8260
8261    #[test]
8262    fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
8263        // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
8264        // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8265        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8266        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8267        // whitespace / colons / dots) — the canonical shape a serde
8268        // internally-tagged discriminator key takes across every peer
8269        // enum in this crate. A future flip to a non-camelCase byte at
8270        // the const surfaces here at build time. Peer of
8271        // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
8272        // sibling per-entry outer-container axis.
8273        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8274        assert!(
8275            !key.is_empty(),
8276            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
8277        );
8278        let first = key.chars().next().unwrap();
8279        assert!(
8280            first.is_ascii_lowercase(),
8281            "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
8282             byte (got {key:?}, leads with {first:?})",
8283        );
8284        assert!(
8285            key.chars().all(|c| c.is_ascii_alphanumeric()),
8286            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
8287             — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8288        );
8289    }
8290
8291    #[test]
8292    fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
8293        // Cross-axis drift-detection pin: the tag-slot key
8294        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
8295        // disjoint from every per-variant data-field key the
8296        // internally-tagged serialization also emits (`"module"` for
8297        // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
8298        // future accidental rebrand that collapses `tag = "kind"` onto
8299        // one of the data-field names (e.g. `tag = "module"`) would
8300        // silently corrupt every serialized LoadModule blob (the
8301        // module string and the variant tag would collide on the same
8302        // JSON key) and every consumer probe would either misread the
8303        // tag or fail to distinguish variants. Pin the disjointness at
8304        // build time. Same cross-axis discipline the sibling
8305        // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
8306        // (36ffe65) established on the outer container's own
8307        // `from`/`instructions` pair.
8308        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8309        // Enumerate every per-variant data-field key across all five
8310        // variants of [`UpgradeInstruction`], routing through the two
8311        // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
8312        // that name the same per-variant data-field JSON keys the
8313        // `variant_fields` reflection in
8314        // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
8315        // per-variant struct-field rebrand (`module` → `component`,
8316        // `script` → `path`) lands as an edit to exactly one const and
8317        // reaches this disjointness pin by construction — the two axes
8318        // (tag-slot key on one side, per-variant data-field keys on the
8319        // other) share one source of truth per axis.
8320        for data_field in [
8321            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8322            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8323        ] {
8324            assert_ne!(
8325                key, data_field,
8326                "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
8327                 must be disjoint from every UpgradeInstruction per-variant \
8328                 data-field key — got tag-key {key:?} colliding with \
8329                 data-field {data_field:?}, which would silently corrupt \
8330                 the internally-tagged serialization",
8331            );
8332        }
8333    }
8334
8335    #[test]
8336    fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
8337        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8338        // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
8339        // data-field JSON key axis: the two
8340        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
8341        // `_SCRIPT`) name the exact per-variant field JSON keys the
8342        // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
8343        // [`UpgradeInstruction`] emits alongside the tag-slot key from the
8344        // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
8345        // const — the `module: String` struct-field on
8346        // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
8347        // struct-field on `StateChange` are promoted to sibling JSON keys
8348        // at the same nesting level as the tag by the internally-tagged
8349        // serialization, and every downstream consumer that navigates the
8350        // serialized instruction blob to reach the payload (the caixa-core
8351        // reflection round-trip in `dispatcher_registration.rs` that
8352        // consults `variant_fields`, the sibling disjointness pin below,
8353        // any future wasm-operator upgrade-dispatch step consuming the
8354        // serialized instruction blob to route the per-module load /
8355        // soft-purge / purge action or the per-script state-change action)
8356        // reads through the same `&'static str`. Serialize one Module-
8357        // bearing variant and one Script-bearing variant, then pin that
8358        // each const's byte-sequence appears verbatim in the JSON emission
8359        // — a future accidental struct-field rebrand (`module: String` →
8360        // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8361        // either variant surfaces here as a build-time test failure at
8362        // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8363        // returning `None` far from the field-name drift's commit.
8364        //
8365        // Same "one canonical byte-string per typed axis" discipline the
8366        // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8367        // pin established on the peer tag-slot key axis on the same
8368        // enum — this pin extends the discipline onto the per-variant
8369        // data-field key axis, completing the `:upgrade-from :instructions`
8370        // variant-JSON dual (tag key + tag values + per-variant field keys)
8371        // fully into caixa-core.
8372        let module_sample = UpgradeInstruction::LoadModule {
8373            module: "hello-rio".into(),
8374        };
8375        let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8376        assert_eq!(
8377            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8378                .and_then(|k| k.as_str()),
8379            Some("hello-rio"),
8380            "serialized {module_sample:?} must carry the lifted \
8381             M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8382             ({:?}) verbatim as the data-field JSON key holding the \
8383             module string (got: {v})",
8384            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8385        );
8386
8387        let script_sample = UpgradeInstruction::StateChange {
8388            script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8389        };
8390        let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8391        assert_eq!(
8392            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8393                .and_then(|k| k.as_str()),
8394            Some("lib/migrations/v01-to-v02.lisp"),
8395            "serialized {script_sample:?} must carry the lifted \
8396             M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8397             ({:?}) verbatim as the data-field JSON key holding the \
8398             script path (got: {v})",
8399            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8400        );
8401    }
8402
8403    #[test]
8404    fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8405        // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8406        // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8407        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8408        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8409        // whitespace / colons / dots) — the canonical shape a Rust
8410        // struct-field name promoted to a JSON key by serde takes on this
8411        // internally-tagged variant surface, matching the sibling
8412        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8413        // shape. A future flip to a non-camelCase byte at either const
8414        // (an accidental `rename_all` regime interleave, or a struct-
8415        // field flip like `module` → `module_name`) surfaces here at
8416        // build time. Peer of
8417        // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8418        // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8419        // the sibling wire-key axes.
8420        for key in [
8421            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8422            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8423        ] {
8424            assert!(
8425                !key.is_empty(),
8426                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8427            );
8428            let first = key.chars().next().unwrap();
8429            assert!(
8430                first.is_ascii_lowercase(),
8431                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8432                 byte (got {key:?}, leads with {first:?})",
8433            );
8434            assert!(
8435                key.chars().all(|c| c.is_ascii_alphanumeric()),
8436                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8437                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8438            );
8439        }
8440    }
8441
8442    #[test]
8443    fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8444        // Cross-axis drift-detection pin: a future collapse of the two
8445        // canonical per-variant data-field byte-strings onto the same
8446        // value (e.g. an accidental copy-paste flip of
8447        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8448        // `"module"`) would silently reroute every test-side probe on one
8449        // variant's payload onto the sibling variant's payload and pass
8450        // every propagation-probe test that expected only the stale
8451        // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8452        // on the sibling per-entry outer-container axis, and of
8453        // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8454        // on the sibling tag-slot key ↔ per-variant data-field key axis.
8455        let all = [
8456            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8457            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8458        ];
8459        for (i, a) in all.iter().enumerate() {
8460            for b in all.iter().skip(i + 1) {
8461                assert_ne!(
8462                    a, b,
8463                    "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8464                     canonical byte-sequences — got `{a}` == `{b}`",
8465                );
8466            }
8467        }
8468    }
8469
8470    #[test]
8471    fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8472        // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8473        // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8474        // `kebab-case` hyphens, no `PascalCase` leading capital, no
8475        // whitespace / colons / dots) — the canonical shape the
8476        // `#[serde(rename_all = "camelCase")]` derive produces on
8477        // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8478        // at the derive surfaces both here (this test fails on the
8479        // stale-constant shape) and at
8480        // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8481        // (that test fails on the mismatch between const and derive).
8482        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8483        // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8484        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8485        for key in [
8486            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8487            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8488        ] {
8489            assert!(
8490                !key.is_empty(),
8491                "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8492            );
8493            let first = key.chars().next().unwrap();
8494            assert!(
8495                first.is_ascii_lowercase(),
8496                "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8497                 byte (got {key:?}, leads with {first:?})",
8498            );
8499            assert!(
8500                key.chars().all(|c| c.is_ascii_alphanumeric()),
8501                "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8502                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8503            );
8504        }
8505    }
8506
8507    #[test]
8508    fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8509        // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8510        // OTP-appup variant-tag axis: the five canonical author-facing
8511        // kebab-case labels (`:load-module` / `:state-change` /
8512        // `:soft-purge` / `:purge` / `:restart`) the substrate's
8513        // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8514        // from and every downstream consumer probes for verbatim. Same
8515        // scalar-value discipline the peer
8516        // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8517        // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8518        // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8519        // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8520        // (be40492) established for the sibling M2 / M3 / Supervisor
8521        // top-level and sub-slot author-facing-label axes. Fail-before-
8522        // pass-after locally verified by mutating
8523        // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8524        // pin fires as expected; restoring passes.
8525        //
8526        // A future OTP-lineage per-variant rebrand (e.g.
8527        // `:load-module` → `:load` matching Erlang's abbreviated
8528        // `code:load_module` name, `:state-change` → `:code-change`
8529        // matching Erlang's verbatim `code_change/3` callback,
8530        // `:soft-purge` → `:drain` matching a hypothetical operator-side
8531        // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8532        // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8533        // matching a supervisor-tree vocabulary alignment) lands as an
8534        // edit to exactly one const, and every consumer that reaches for
8535        // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8536        // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8537        // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8538        // `prior_cleanup_kind:` diagnostic field, the
8539        // [`LayoutError::UpgradeViolation`] `issue:` probe in
8540        // `layout.rs`) picks it up at build time rather than at runtime
8541        // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8542        // far from the rename's commit.
8543        assert_eq!(
8544            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8545            ":load-module"
8546        );
8547        assert_eq!(
8548            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8549            ":state-change"
8550        );
8551        assert_eq!(
8552            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8553            ":soft-purge"
8554        );
8555        assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8556        assert_eq!(
8557            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8558            ":restart"
8559        );
8560    }
8561
8562    #[test]
8563    fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8564        // Cross-arm drift-detection pin on the M2
8565        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8566        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8567        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8568        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8569        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8570        // closed-set OTP-appup variant-tag pentad: a future collapse
8571        // of two canonical variant byte-strings onto the same value
8572        // (an accidental copy-paste flip of
8573        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8574        // to also read `":purge"`, a per-arm rebrand that lands one
8575        // const without touching its paired peer) would silently
8576        // reroute every downstream OTP-appup dispatcher's per-
8577        // instruction branch onto the sibling arm's runtime
8578        // behavior and pass every propagation-probe test that
8579        // expected only the stale arm's tag — a `:soft-purge`
8580        // instruction (drain-then-swap: existing callers finish
8581        // under the old module, new callers land on the new one)
8582        // would come up under the `:purge` reconcile branch
8583        // (drop-existing: every in-flight caller terminates
8584        // immediately) on every hot-upgrade cycle, so a rolling
8585        // module swap would silently downgrade to a hard cutover
8586        // against its declared appup discipline, with no field
8587        // naming the instruction-tag drift root cause. Every
8588        // [`crate::UpgradeError`] diagnostic that surfaces the tag
8589        // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8590        // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8591        // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8592        // `prior_cleanup_kind:` field, the
8593        // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8594        // `layout.rs`) would emit the sibling arm's stale bytes at
8595        // the operator's console, far from the source rebrand
8596        // commit. Peer of the sibling
8597        // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8598        // (09ffb2d) /
8599        // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8600        // (ccdf955) /
8601        // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8602        // (d739850) distinctness pins on the sibling OTP-shape /
8603        // caixa-kind closed-set typed-enum discriminator axes —
8604        // the fifth closed-set OTP-appup / typed-enum axis to
8605        // converge on the same
8606        // "pairwise-distinct-by-construction" discipline, and the
8607        // canonical companion to the peer
8608        // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8609        // (ff980bb) distinctness pin on the sibling internally-
8610        // tagged-JSON per-variant data-field-key axis (the tag axis
8611        // this pin covers vs. the data-field-key axis its peer
8612        // covers — two paired axes on the same
8613        // [`crate::UpgradeInstruction`] typed enum surface).
8614        //
8615        // Fail-before-pass-after locally verified by mutating
8616        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8617        // to also read `":purge"` — this pin fires as expected;
8618        // restoring passes.
8619        let all = [
8620            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8621            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8622            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8623            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8624            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8625        ];
8626        for (i, a) in all.iter().enumerate() {
8627            for (j, b) in all.iter().enumerate() {
8628                if i != j {
8629                    assert_ne!(
8630                        a, b,
8631                        "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8632                         distinct — got duplicate {a:?} at indices {i} and {j}",
8633                    );
8634                }
8635            }
8636        }
8637    }
8638
8639    #[test]
8640    fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8641        // Production-through-const pin: the five per-variant labels
8642        // [`UpgradeInstruction::lisp_form`] returns route through the
8643        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8644        // so a future rebrand that reaches the const but not the
8645        // dispatch (or vice versa) surfaces here at build time rather
8646        // than at runtime as a downstream
8647        // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8648        // diagnostic drift far from the rename's commit. Mirror of the
8649        // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8650        // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8651        // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8652        // (f49c8b0) production-through-const pins on the sibling M3 /
8653        // M2 top-level slot axes.
8654        //
8655        // Fail-before-pass-after locally verified by mutating
8656        // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8657        // `":purge-drift"` — this pin fires as expected; restoring
8658        // passes.
8659        let cases: &[(UpgradeInstruction, &'static str)] = &[
8660            (
8661                UpgradeInstruction::LoadModule { module: "x".into() },
8662                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8663            ),
8664            (
8665                UpgradeInstruction::StateChange {
8666                    script: PathBuf::from("lib/m.lisp"),
8667                },
8668                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8669            ),
8670            (
8671                UpgradeInstruction::SoftPurge {
8672                    module: "x-old".into(),
8673                },
8674                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8675            ),
8676            (
8677                UpgradeInstruction::Purge {
8678                    module: "x-old".into(),
8679                },
8680                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8681            ),
8682            (
8683                UpgradeInstruction::Restart,
8684                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8685            ),
8686        ];
8687        for (instr, expected) in cases {
8688            assert_eq!(
8689                instr.lisp_form(),
8690                *expected,
8691                "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8692                 const (expected {expected:?})",
8693            );
8694        }
8695    }
8696
8697    #[test]
8698    fn upgrade_instruction_as_str_routes_through_lifted_wire_consts() {
8699        // Production-through-const pin on the peer wire-form axis: the
8700        // five per-variant un-prefixed kebab byte-strings
8701        // [`UpgradeInstruction::as_str`] returns route through the
8702        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_*`] consts,
8703        // so a future rebrand that reaches the const but not the
8704        // dispatch (or vice versa) surfaces here at build time rather
8705        // than at runtime as a divergent JSON `"kind"` tag between the
8706        // serde-derived wire byte-string and the accessor-routed
8707        // source of truth on every K8s-CR round-trip / structured-log
8708        // line / dispatcher-catalog lookup. Peer of the sibling
8709        // [`upgrade_instruction_lisp_form_routes_through_lifted_kind_consts`]
8710        // pin on the tatara-lisp author-surface form axis — the
8711        // two-axis discipline (author-surface `:load-module` /
8712        // wire-form `load-module`) is now fully lifted into caixa-core
8713        // through paired `M2_UPGRADE_INSTRUCTION_KIND_*` +
8714        // `M2_UPGRADE_INSTRUCTION_WIRE_*` const families, so a per-
8715        // consumer rebrand at either axis lands at exactly one edit
8716        // site and every downstream projection picks it up by
8717        // construction.
8718        //
8719        // Fail-before-pass-after locally verified by mutating
8720        // `UpgradeInstruction::as_str`'s `Self::Purge` arm to return
8721        // `"purge-drift"` — this pin fires as expected; restoring
8722        // passes.
8723        let cases: &[(UpgradeInstruction, &'static str)] = &[
8724            (
8725                UpgradeInstruction::LoadModule { module: "x".into() },
8726                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
8727            ),
8728            (
8729                UpgradeInstruction::StateChange {
8730                    script: PathBuf::from("lib/m.lisp"),
8731                },
8732                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
8733            ),
8734            (
8735                UpgradeInstruction::SoftPurge {
8736                    module: "x-old".into(),
8737                },
8738                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
8739            ),
8740            (
8741                UpgradeInstruction::Purge {
8742                    module: "x-old".into(),
8743                },
8744                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
8745            ),
8746            (
8747                UpgradeInstruction::Restart,
8748                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
8749            ),
8750        ];
8751        for (instr, expected) in cases {
8752            assert_eq!(
8753                instr.as_str(),
8754                *expected,
8755                "UpgradeInstruction::as_str on {instr:?} must route through the lifted \
8756                 M2_UPGRADE_INSTRUCTION_WIRE_* const (expected {expected:?})",
8757            );
8758            assert!(
8759                !expected.starts_with(':'),
8760                "M2_UPGRADE_INSTRUCTION_WIRE_* entry {expected:?} must \
8761                 not open with a `:` prefix — a bare `:` -prefixed entry \
8762                 would collide the wire-form axis with the peer tatara-\
8763                 lisp author-surface form the M2_UPGRADE_INSTRUCTION_KIND_* \
8764                 family carries",
8765            );
8766        }
8767    }
8768
8769    #[test]
8770    fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8771        // Return-lifetime pin on the substrate primitive: because
8772        // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8773        // (threaded verbatim from the paired
8774        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8775        // roster's program-lifetime storage), the label survives
8776        // dropping the borrow through `self` — a downstream logger
8777        // that stashes the tag in a `&'static`-bounded position
8778        // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8779        // accept-set, a static formatter's `%s` argument) reads it
8780        // without re-borrowing through the instruction reference. A
8781        // future refactor that accidentally narrows the return to
8782        // `&str` (lifetime-bound to `&self`) — say by projecting through
8783        // an owned `String` intermediate — would fail this compile-time
8784        // pin at build time far from the runtime-side lifetime
8785        // regression at every downstream `&'static str` consumer. Peer
8786        // pin discipline the sibling
8787        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8788        // `pub const _: &str = "..."` shape already carries at the
8789        // paired wire-form axis.
8790        //
8791        // The pin fires by taking the label from an instruction that
8792        // goes out of scope before the label is read — if
8793        // `lisp_form` returned a `&str` tied to `&self`, this would
8794        // fail to compile with "borrowed value does not live long
8795        // enough". Fail-before-pass-after locally verified: narrowing
8796        // the signature to `fn lisp_form(&self) -> &str { … }`
8797        // reproduces the compile error.
8798        fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8799            instr.lisp_form()
8800        }
8801        let label = {
8802            let instr = UpgradeInstruction::LoadModule {
8803                module: "ephemeral".into(),
8804            };
8805            stash_label_as_static(&instr)
8806            // instr drops here; label must survive
8807        };
8808        assert_eq!(
8809            label,
8810            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8811            "the &'static str return must survive the borrowed \
8812             UpgradeInstruction going out of scope — a lifetime narrowing \
8813             to &str would fail this pin at build time",
8814        );
8815    }
8816
8817    #[test]
8818    fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8819        // Const-position pin on the substrate primitive: because
8820        // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8821        // consumers can call it in `const` contexts — a `const`
8822        // declaration threading the label through, a `static` lookup
8823        // table pre-computed at compile time, a `match` arm's
8824        // `const`-eligible branch label. `pub` matters here: a
8825        // `pub(crate) const fn` would compile in-crate const contexts
8826        // but no external caixa-<target> renderer or feira verb could
8827        // reach the projection in a const context. Fail-before-pass-
8828        // after locally verified: reverting the visibility to
8829        // `pub(crate) const fn` (or removing `pub`) makes this pin
8830        // fail to compile at the const-context call site below.
8831        const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8832        assert_eq!(
8833            RESTART_LABEL,
8834            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8835            "const-position dispatch on Restart must yield the lifted \
8836             M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8837        );
8838    }
8839
8840    #[test]
8841    fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8842        // The canonical per-`:upgrade-from :instructions` OTP-appup
8843        // migration-instruction-list slice-shape pin:
8844        // [`UpgradeFromEntry::instructions`] must return the
8845        // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8846        // a `&[UpgradeInstruction]` slice-view over the same backing
8847        // buffer the raw `self.instructions.as_slice()` field access
8848        // borrows from, byte-equal across every representative fixture
8849        // in the accept-set — the empty slice (the "no-op upgrade" /
8850        // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8851        // field's own docstring names), the singleton slice on every
8852        // variant of the [`UpgradeInstruction`] arm-space
8853        // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8854        // `Restart` — the five OTP-appup runtime-primitive variants),
8855        // and multi-instruction cohorts (the canonical
8856        // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8857        // load + state-migration triad the module doc names as the
8858        // "runs the instructions in order" example).
8859        //
8860        // Pins against a future silent detour that returned
8861        // `&Vec<UpgradeInstruction>` (which would type-check but leak
8862        // the storage-side `Vec`'s grow/push/reserve surface no
8863        // consumer of the typed view reaches for), a fresh-allocated
8864        // `Vec<UpgradeInstruction>` copy (which would type-check via
8865        // a coercion but silently break every downstream caller that
8866        // relied on the slice sharing the backing buffer's identity),
8867        // or an out-of-order or length-drifted projection (which
8868        // would silently split the paired within-entry cross-
8869        // instruction ordering gates' inputs from the peer per-
8870        // instruction shape-check loop's input, one seven-gate cohort
8871        // silently drifting from the peer gate's actual traversal
8872        // input).
8873        //
8874        // Peer of the sibling
8875        // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8876        // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8877        // `:contratos` edge-list axis, extended onto the M2 per-
8878        // `:upgrade-from :instructions` migration-instruction-list
8879        // axis — the fifth `&[T]`-return byte-equal pin, closing the
8880        // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8881        let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8882            Vec::new(),
8883            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8884            vec![UpgradeInstruction::StateChange {
8885                script: PathBuf::from("lib/m.lisp"),
8886            }],
8887            vec![UpgradeInstruction::SoftPurge {
8888                module: "x-old".into(),
8889            }],
8890            vec![UpgradeInstruction::Purge {
8891                module: "x-old".into(),
8892            }],
8893            vec![UpgradeInstruction::Restart],
8894            vec![
8895                UpgradeInstruction::LoadModule { module: "x".into() },
8896                UpgradeInstruction::StateChange {
8897                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8898                },
8899                UpgradeInstruction::SoftPurge {
8900                    module: "x-old".into(),
8901                },
8902            ],
8903        ];
8904        for instructions in fixtures {
8905            let e = UpgradeFromEntry {
8906                from: "0.1.0".into(),
8907                instructions: instructions.clone(),
8908            };
8909            assert_eq!(
8910                e.instructions(),
8911                e.instructions.as_slice(),
8912                "UpgradeFromEntry::instructions must project the raw \
8913                 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8914                 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8915                 (fixture: {instructions:?})",
8916            );
8917            assert_eq!(
8918                e.instructions().len(),
8919                instructions.len(),
8920                "UpgradeFromEntry::instructions length must match the raw \
8921                 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8922            );
8923        }
8924    }
8925
8926    #[test]
8927    fn validate_reads_through_lifted_instructions_accessor() {
8928        // Three-consumer coherence pin on the lifted
8929        // [`UpgradeFromEntry::instructions`] slice-return accessor:
8930        // exercises three of the nine paired production consumers of
8931        // the per-`:upgrade-from :instructions` OTP-appup migration-
8932        // instruction-list surface through end-to-end validate() paths
8933        // that require the accessor to reach each of the fixture's
8934        // instructions.
8935        //
8936        // (1) The per-instruction shape-check fan-out
8937        // ([`UpgradeFromEntry::validate`]'s `for instr in
8938        // self.instructions()` loop): pass the well-formed load →
8939        // state-change → soft-purge triad — `validate()` must accept
8940        // it, which requires the accessor to project every entry so
8941        // each `instr.validate()` fires.
8942        //
8943        // (2) The within-entry state-change-ordering gate
8944        // ([`Self::validate_state_change_ordering`]): pass a
8945        // `((:state-change …))` singleton — `validate()` must return
8946        // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8947        // requires the accessor to reach the state-change so the
8948        // no-prior-load probe fires.
8949        //
8950        // (3) The within-entry per-module cleanup-singularity gate
8951        // ([`Self::validate_cleanup_singularity`]): pass a
8952        // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8953        // "x-old"))` cohort — `validate()` must return
8954        // [`UpgradeError::DuplicateCleanup`], which requires the
8955        // accessor to iterate the whole list so the second `SoftPurge`
8956        // matches the first via the `seen` set.
8957        //
8958        // Peer of the sibling
8959        // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8960        // three-consumer coherence pin on the M3 per-`:contratos`
8961        // edge-list axis, extended onto the M2 per-`:upgrade-from
8962        // :instructions` migration-instruction-list axis.
8963
8964        // (1) accept the well-formed OTP two-phase code-load triad
8965        let well_formed = entry(
8966            "0.1.0",
8967            vec![
8968                UpgradeInstruction::LoadModule { module: "x".into() },
8969                UpgradeInstruction::StateChange {
8970                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8971                },
8972                UpgradeInstruction::SoftPurge {
8973                    module: "x-old".into(),
8974                },
8975            ],
8976        );
8977        assert!(
8978            well_formed.validate().is_ok(),
8979            "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8980             the per-instruction shape-check fan-out requires the accessor to reach every entry"
8981        );
8982
8983        // (2) refuse a `((:state-change …))` singleton — the
8984        // state-change-without-prior-load gate must fire, which
8985        // requires the accessor to reach the single instruction.
8986        let no_prior_load = entry(
8987            "0.1.0",
8988            vec![UpgradeInstruction::StateChange {
8989                script: PathBuf::from("lib/m.lisp"),
8990            }],
8991        );
8992        match no_prior_load.validate() {
8993            Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8994            other => panic!(
8995                "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8996                 — the within-entry state-change-ordering gate must reach the single \
8997                 instruction through the lifted accessor; got: {other:?}"
8998            ),
8999        }
9000
9001        // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
9002        // (:soft-purge "x-old"))` cohort — the per-module cleanup-
9003        // singularity gate must fire on the second `SoftPurge`, which
9004        // requires the accessor to iterate the whole list.
9005        let duplicate_cleanup = entry(
9006            "0.1.0",
9007            vec![
9008                UpgradeInstruction::LoadModule { module: "x".into() },
9009                UpgradeInstruction::SoftPurge {
9010                    module: "x-old".into(),
9011                },
9012                UpgradeInstruction::SoftPurge {
9013                    module: "x-old".into(),
9014                },
9015            ],
9016        );
9017        match duplicate_cleanup.validate() {
9018            Err(UpgradeError::DuplicateCleanup { module, .. }) => {
9019                assert_eq!(
9020                    module, "x-old",
9021                    "DuplicateCleanup must name the colliding module `x-old` — the per-module \
9022                     cleanup-singularity gate must iterate through the lifted accessor to \
9023                     match the second SoftPurge against the first via the `seen` set"
9024                );
9025            }
9026            other => panic!(
9027                "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
9028                 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
9029                 iterate the whole list through the lifted accessor; got: {other:?}"
9030            ),
9031        }
9032
9033        // Path::new suppresses the unused-import warning if the
9034        // outer module trims `use std::path::Path;` in a future edit.
9035        let _ = Path::new("lib/m.lisp");
9036    }
9037
9038    // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
9039    // macro definition (see the paired doc-block above the macro
9040    // definition) — every generated `<ctor>(from: &str, script: &Path)
9041    // -> Self` constructor folds the uniform `Self::<Variant> { from:
9042    // from.to_string(), script: script.to_path_buf() }` two-field
9043    // struct-literal onto one substrate primitive. The three per-variant
9044    // equivalence pins below (fail-before-pass-after by construction — a
9045    // byte-mismatched macro arm would trip its equivalence pin first)
9046    // lock each generated constructor to its struct-literal peer under
9047    // `PartialEq`, so every wire-up in
9048    // [`UpgradeFromEntry::validate_state_change_ordering`],
9049    // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
9050    // [`validate_state_change_on_state_change_callback`] on that
9051    // variant produces a byte-equal `UpgradeError` to the pre-lift
9052    // open-coded struct-literal. The cross-axis pin that follows
9053    // (non-default `(from, script)` pair) routes both constructor input
9054    // axes through `.to_string()` / `.to_path_buf()`, so the fold does
9055    // not silently collapse onto a fixed `from` / `script` value.
9056    //
9057    // Peer of the sibling `empty_child_version_ctor_matches_struct_
9058    // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
9059    // literal_wrap` / `child_supervises_self_ctor_matches_struct_
9060    // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
9061    // to_string` equivalence + cross-axis pins the sibling
9062    // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
9063    // established on the peer `SupervisorError` envelope; extended
9064    // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
9065    // two-slot envelope so every substrate-primitive ctor family in
9066    // caixa-core guarantees the same-shape fold every wire-up on the
9067    // family reads through one dispatch.
9068
9069    #[test]
9070    fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
9071        let from = "0.1.0";
9072        let script = Path::new("lib/migrations/v01-to-v02.lisp");
9073        assert_eq!(
9074            UpgradeError::state_change_without_prior_load(from, script),
9075            UpgradeError::StateChangeWithoutPriorLoad {
9076                from: from.to_string(),
9077                script: script.to_path_buf(),
9078            },
9079            "generated state_change_without_prior_load ctor must produce \
9080             byte-equal UpgradeError to the open-coded struct-literal \
9081             wrap on the same (&str, &Path) fixture",
9082        );
9083    }
9084
9085    #[test]
9086    fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
9087        let from = "0.1.0";
9088        let script = Path::new("lib/migrations/v01-to-v02.lisp");
9089        assert_eq!(
9090            UpgradeError::duplicate_state_change(from, script),
9091            UpgradeError::DuplicateStateChange {
9092                from: from.to_string(),
9093                script: script.to_path_buf(),
9094            },
9095            "generated duplicate_state_change ctor must produce byte-equal \
9096             UpgradeError to the open-coded struct-literal wrap on the \
9097             same (&str, &Path) fixture",
9098        );
9099    }
9100
9101    #[test]
9102    fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
9103        let from = "0.1.0";
9104        let script = Path::new("lib/migrations/v01-to-v02.lisp");
9105        assert_eq!(
9106            UpgradeError::state_change_without_on_state_change_callback(from, script),
9107            UpgradeError::StateChangeWithoutOnStateChangeCallback {
9108                from: from.to_string(),
9109                script: script.to_path_buf(),
9110            },
9111            "generated state_change_without_on_state_change_callback ctor \
9112             must produce byte-equal UpgradeError to the open-coded \
9113             struct-literal wrap on the same (&str, &Path) fixture",
9114        );
9115    }
9116
9117    #[test]
9118    fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
9119        // Cross-axis pin: sweep both constructor input axes (`from:
9120        // &str`, `script: &Path`) through non-default fixtures against
9121        // every generated arm in the [`upgrade_from_script_ctors!`]
9122        // macro, so any wrapper-side lowercase / trim / truncate /
9123        // re-order / fixed-path substitution on the two-field
9124        // construction surfaces here rather than at a downstream
9125        // diagnostic-shape mismatch. Also exercises the `&Path`
9126        // parameter under both `&Path` (direct `Path::new`) and
9127        // `&PathBuf` (via Deref coercion), matching the two shapes the
9128        // three wire-up sites thread through — the ordering /
9129        // callback-declaration gates hand a `&PathBuf` from
9130        // `instr.declared_path()`; the uniqueness gate hands a `&Path`
9131        // from `script.as_path()`. Peer of the sibling
9132        // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
9133        // cross-axis pin on the peer `SupervisorError` `{ caixa:
9134        // String }` envelope.
9135        let from = "1.2.3-rc.1";
9136        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9137        let script_ref: &Path = script_owned.as_path();
9138        for script in [script_ref, &script_owned as &Path] {
9139            assert_eq!(
9140                UpgradeError::state_change_without_prior_load(from, script),
9141                UpgradeError::StateChangeWithoutPriorLoad {
9142                    from: from.to_string(),
9143                    script: script.to_path_buf(),
9144                },
9145            );
9146            assert_eq!(
9147                UpgradeError::duplicate_state_change(from, script),
9148                UpgradeError::DuplicateStateChange {
9149                    from: from.to_string(),
9150                    script: script.to_path_buf(),
9151                },
9152            );
9153            assert_eq!(
9154                UpgradeError::state_change_without_on_state_change_callback(from, script),
9155                UpgradeError::StateChangeWithoutOnStateChangeCallback {
9156                    from: from.to_string(),
9157                    script: script.to_path_buf(),
9158                },
9159            );
9160        }
9161    }
9162
9163    // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
9164    // macro definition (see the paired doc-block above the macro
9165    // definition) — every generated `<ctor>(script: &Path) -> Self`
9166    // constructor folds the uniform `Self::<Variant> { script:
9167    // script.to_path_buf() }` one-field struct-literal onto one substrate
9168    // primitive. The three per-variant equivalence pins below
9169    // (fail-before-pass-after by construction — a byte-mismatched macro
9170    // arm would trip its equivalence pin first) lock each generated
9171    // constructor to its struct-literal peer under `PartialEq`, so every
9172    // closure passed to [`crate::render::require_sandboxed_lisp_path`]
9173    // at [`UpgradeInstruction::validate`] on that variant produces a
9174    // byte-equal `UpgradeError` to the pre-lift open-coded
9175    // struct-literal. The cross-axis pin that follows (non-default
9176    // `script` path, both `&Path` and `&PathBuf` shapes) routes the
9177    // constructor input axis through `.to_path_buf()`, so the fold does
9178    // not silently collapse onto a fixed `script` value or drop the
9179    // Deref-coercion arm the wire-up sites depend on.
9180    //
9181    // Peer of the sibling
9182    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
9183    // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
9184    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
9185    // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
9186    // equivalence + cross-axis pins the sibling
9187    // [`upgrade_from_script_ctors!`] family (8e67041) established on the
9188    // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
9189    // extended here onto the `{ script: PathBuf }` one-slot envelope
9190    // shape so every substrate-primitive ctor family on `UpgradeError`
9191    // guarantees the same-shape fold every wire-up on the family reads
9192    // through one dispatch.
9193
9194    #[test]
9195    fn absolute_script_ctor_matches_struct_literal_wrap() {
9196        let script = Path::new("/etc/nope.lisp");
9197        assert_eq!(
9198            UpgradeError::absolute_script(script),
9199            UpgradeError::AbsoluteScript {
9200                script: script.to_path_buf(),
9201            },
9202            "generated absolute_script ctor must produce byte-equal \
9203             UpgradeError to the open-coded struct-literal wrap on the \
9204             same &Path fixture",
9205        );
9206    }
9207
9208    #[test]
9209    fn parent_escape_script_ctor_matches_struct_literal_wrap() {
9210        let script = Path::new("../oops.lisp");
9211        assert_eq!(
9212            UpgradeError::parent_escape_script(script),
9213            UpgradeError::ParentEscapeScript {
9214                script: script.to_path_buf(),
9215            },
9216            "generated parent_escape_script ctor must produce byte-equal \
9217             UpgradeError to the open-coded struct-literal wrap on the \
9218             same &Path fixture",
9219        );
9220    }
9221
9222    #[test]
9223    fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
9224        let script = Path::new("lib/migrations.rs");
9225        assert_eq!(
9226            UpgradeError::non_lisp_extension_script(script),
9227            UpgradeError::NonLispExtensionScript {
9228                script: script.to_path_buf(),
9229            },
9230            "generated non_lisp_extension_script ctor must produce \
9231             byte-equal UpgradeError to the open-coded struct-literal \
9232             wrap on the same &Path fixture",
9233        );
9234    }
9235
9236    #[test]
9237    fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
9238        // Cross-axis pin: sweep the constructor input axis (`script:
9239        // &Path`) through a non-default fixture against every generated
9240        // arm in the [`upgrade_script_only_ctors!`] macro, so any
9241        // wrapper-side lowercase / trim / truncate / re-order /
9242        // fixed-path substitution on the one-field construction
9243        // surfaces here rather than at a downstream diagnostic-shape
9244        // mismatch. Also exercises the `&Path` parameter under both
9245        // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
9246        // coercion), matching the shape the three closures at
9247        // [`UpgradeInstruction::validate`] thread through — the
9248        // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
9249        // each closure, so the Deref-coercion arm the ctor advertises
9250        // must actually route through `.to_path_buf()` and not
9251        // silently swap in a fixed path.
9252        //
9253        // Peer of the sibling
9254        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9255        // cross-axis pin on the sibling `{ from, script }` two-slot
9256        // envelope shape.
9257        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9258        let script_ref: &Path = script_owned.as_path();
9259        for script in [script_ref, &script_owned as &Path] {
9260            assert_eq!(
9261                UpgradeError::absolute_script(script),
9262                UpgradeError::AbsoluteScript {
9263                    script: script.to_path_buf(),
9264                },
9265            );
9266            assert_eq!(
9267                UpgradeError::parent_escape_script(script),
9268                UpgradeError::ParentEscapeScript {
9269                    script: script.to_path_buf(),
9270                },
9271            );
9272            assert_eq!(
9273                UpgradeError::non_lisp_extension_script(script),
9274                UpgradeError::NonLispExtensionScript {
9275                    script: script.to_path_buf(),
9276                },
9277            );
9278        }
9279    }
9280
9281    // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
9282    // macro definition (see the paired doc-block above the macro
9283    // definition) — every generated `<ctor>(from: &str, <axis>: &str)
9284    // -> Self` constructor folds the uniform `Self::<Variant> { from:
9285    // from.to_string(), <axis>: <axis>.to_string() }` two-field
9286    // struct-literal onto one substrate primitive. The three per-variant
9287    // equivalence pins below (fail-before-pass-after by construction — a
9288    // byte-mismatched macro arm would trip its equivalence pin first)
9289    // lock each generated constructor to its struct-literal peer under
9290    // `PartialEq`, so every wire-up in
9291    // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
9292    // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
9293    // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
9294    // `:from < :versao` gate on that variant produces a byte-equal
9295    // `UpgradeError` to the pre-lift open-coded struct-literal. The
9296    // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
9297    // pair) routes both constructor input axes through `.to_string()`
9298    // in declared field order, so the fold does not silently swap `from`
9299    // and the middle `<axis>` field, or silently collapse onto a fixed
9300    // `from` / `<axis>` value on any one variant.
9301    //
9302    // Peer of the sibling `state_change_without_prior_load_ctor_matches_
9303    // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
9304    // struct_literal_wrap` / `state_change_without_on_state_change_
9305    // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
9306    // ctors_route_from_and_script_verbatim` equivalence + cross-axis
9307    // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
9308    // established on the sibling `{ from: String, script: PathBuf }`
9309    // two-slot envelope shape; extended here onto the `{ from: String,
9310    // <axis>: String }` two-slot envelope shape so every substrate-
9311    // primitive ctor family on `UpgradeError` guarantees the same-shape
9312    // fold every wire-up on the family reads through one dispatch. Also
9313    // mirror-symmetric peer of the sibling
9314    // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9315    // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
9316    // <axis>: String }` two-slot envelope shape.
9317
9318    #[test]
9319    fn from_invalid_ctor_matches_struct_literal_wrap() {
9320        let from = "not-a-semver";
9321        let reason = "unexpected character '-' at position 3";
9322        assert_eq!(
9323            UpgradeError::from_invalid(from, reason),
9324            UpgradeError::FromInvalid {
9325                from: from.to_string(),
9326                reason: reason.to_string(),
9327            },
9328            "generated from_invalid ctor must produce byte-equal \
9329             UpgradeError to the open-coded struct-literal wrap on the \
9330             same (&str, &str) fixture",
9331        );
9332    }
9333
9334    #[test]
9335    fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
9336        let from = "0.2.0";
9337        let versao = "0.1.0";
9338        assert_eq!(
9339            UpgradeError::from_not_before_versao(from, versao),
9340            UpgradeError::FromNotBeforeVersao {
9341                from: from.to_string(),
9342                versao: versao.to_string(),
9343            },
9344            "generated from_not_before_versao ctor must produce byte-equal \
9345             UpgradeError to the open-coded struct-literal wrap on the \
9346             same (&str, &str) fixture",
9347        );
9348    }
9349
9350    #[test]
9351    fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
9352        let from = "0.1.0";
9353        let module = "hello-rio";
9354        assert_eq!(
9355            UpgradeError::duplicate_load_module(from, module),
9356            UpgradeError::DuplicateLoadModule {
9357                from: from.to_string(),
9358                module: module.to_string(),
9359            },
9360            "generated duplicate_load_module ctor must produce byte-equal \
9361             UpgradeError to the open-coded struct-literal wrap on the \
9362             same (&str, &str) fixture",
9363        );
9364    }
9365
9366    #[test]
9367    fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
9368        // Cross-axis routing pin: sweep the two constructor input axes
9369        // (`from: &str`, `<axis>: &str`) through distinct-per-axis
9370        // fixtures against every generated arm in the
9371        // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
9372        // lowercase / trim / truncate at codegen time — a silent field
9373        // swap between `from` and the middle `<axis>` field, or a
9374        // `<axis>` axis silently rerouted through the wrong field on any
9375        // one variant — surfaces here rather than at a downstream
9376        // diagnostic-shape mismatch. Peer of the sibling
9377        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9378        // (8e67041) cross-axis pin on the same envelope's sibling
9379        // `{ from: String, script: PathBuf }` two-slot family, and of the
9380        // sibling
9381        // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9382        // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
9383        // String, <axis>: String }` two-slot envelope. Distinct-per-
9384        // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
9385        // that would still pass a same-fixture-per-axis pin. Both
9386        // `&str`-literal and `&String` (via Deref coercion) carriers
9387        // are exercised because the three wire-up sites hand a mix of
9388        // both (the `from_invalid` site hands `&e.to_string()` — an
9389        // owned `String` — for `reason`; the `duplicate_load_module`
9390        // site hands a `&str` slice for `module`; the
9391        // `from_not_before_versao` site hands the caller-supplied
9392        // `versao: &str` for `versao`).
9393        let from = "0.1.0";
9394        let axis = "distinct-axis-value";
9395        let from_owned: String = from.to_string();
9396        let axis_owned: String = axis.to_string();
9397        for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
9398            assert_eq!(
9399                UpgradeError::from_invalid(from_in, axis_in),
9400                UpgradeError::FromInvalid {
9401                    from: from.to_string(),
9402                    reason: axis.to_string(),
9403                },
9404                "from_invalid must route `from` → `from`, `axis` → `reason` \
9405                 in declared field order",
9406            );
9407            assert_eq!(
9408                UpgradeError::from_not_before_versao(from_in, axis_in),
9409                UpgradeError::FromNotBeforeVersao {
9410                    from: from.to_string(),
9411                    versao: axis.to_string(),
9412                },
9413                "from_not_before_versao must route `from` → `from`, \
9414                 `axis` → `versao` in declared field order",
9415            );
9416            assert_eq!(
9417                UpgradeError::duplicate_load_module(from_in, axis_in),
9418                UpgradeError::DuplicateLoadModule {
9419                    from: from.to_string(),
9420                    module: axis.to_string(),
9421                },
9422                "duplicate_load_module must route `from` → `from`, \
9423                 `axis` → `module` in declared field order",
9424            );
9425        }
9426    }
9427
9428    // Per-variant equivalence + accessor-fidelity + cross-axis pins for
9429    // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
9430    // the paired doc-block above the ctor definition) — the fold of the
9431    // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9432    // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9433    // duplicate gate onto one substrate primitive on the
9434    // [`UpgradeError`] envelope, projecting through the paired
9435    // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9436    // primitive. A byte-mismatched ctor body would trip the equivalence
9437    // pin first, ahead of any downstream diagnostic-shape drift.
9438    //
9439    // Peer of the sibling standalone-ctor equivalence pins on the peer
9440    // one-off variants across caixa-core:
9441    // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9442    // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9443    // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9444    // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9445    // envelope, the sibling
9446    // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9447    // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9448    // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9449    // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9450
9451    #[test]
9452    fn duplicate_from_ctor_matches_struct_literal_wrap() {
9453        // Equivalence pin: the ctor produces byte-equal
9454        // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9455        // struct-literal that read the same `from` field through
9456        // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9457        // addition / reordering / string-conversion tweak on the
9458        // variant. Same equivalence-pin shape as the sibling
9459        // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9460        // (b30edfe) on the paired two-slot `{ caixa, wit }`
9461        // envelope inside `impl AplicacaoSpec`.
9462        let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9463        let lifted = UpgradeError::duplicate_from(&entry);
9464        let struct_literal = UpgradeError::DuplicateFrom {
9465            from: entry.prior_versao().to_string(),
9466        };
9467        assert_eq!(lifted, struct_literal);
9468    }
9469
9470    #[test]
9471    fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9472        // Routing pin sweeping a non-default `:from` value
9473        // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9474        // release and build metadata) through the paired
9475        // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9476        // wrapper-side lowercase / trim / truncate on the one-field
9477        // construction surfaces here rather than at a downstream
9478        // diagnostic-shape drift. Peer of the sibling
9479        // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9480        // (b30edfe) routing pin on the sibling two-slot envelope.
9481        //
9482        // The pre-release + build-metadata carrier value is deliberately
9483        // chosen to exercise the `.to_string()` path against a `:from`
9484        // shape [`semver::Version::PartialEq`] treats as distinct from
9485        // its release-only sibling (per the
9486        // `validate_upgrade_from_treats_pre_release_as_distinct` and
9487        // build-metadata-tightening-note doc-block on
9488        // [`validate_upgrade_from`]) — so any silent normalization at
9489        // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9490        // `.split_once('-')` collapse) would drop bytes from the
9491        // rendered diagnostic and surface here.
9492        let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9493        let built = UpgradeError::duplicate_from(&entry);
9494        match built {
9495            UpgradeError::DuplicateFrom { from } => {
9496                assert_eq!(
9497                    from, "1.2.3-rc.4+build.5",
9498                    "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9499                     preserving pre-release + build-metadata bytes"
9500                );
9501            }
9502            other => panic!("expected DuplicateFrom, got {other:?}"),
9503        }
9504    }
9505
9506    #[test]
9507    fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9508        // Accessor-fidelity pin: the ctor's `from` slot keys off the
9509        // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9510        // the pre-lift open-coded body's field selection), not any
9511        // stringified rendering of the full entry (e.g. the
9512        // `impl Display for UpgradeFromEntry` output, if one were later
9513        // added, or a `format!("{:?}", entry)` debug dump). Pins the
9514        // projection axis so a silent swap at the ctor body — say, a
9515        // future refactor that projects through `entry.instructions()`
9516        // in shape (dropping the `:from` axis entirely) or through a
9517        // whole-entry `format!` — surfaces here rather than at a
9518        // downstream diagnostic mis-attribution far from the duplicate
9519        // gate's owner.
9520        //
9521        // A future consumer that constructs the ctor against a not-yet-
9522        // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9523        // CR admission webhook re-checking a per-`:upgrade-from`-patched
9524        // candidate before the cross-entry duplicate gate re-fires, a
9525        // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9526        // `(:from …)` introduced by a cluster-local `:upgrade-from`
9527        // override) needs the pre-lift projection axis pinned.
9528        //
9529        // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9530        // paired with a distinctive multi-instruction sequence so a
9531        // silent swap that projects through the whole-entry rendering
9532        // instead of the paired scalar accessor would land debug bytes
9533        // from the `:instructions` list into the `from` slot and trip
9534        // the assertion here.
9535        let entry = entry(
9536            "0.2.0-alpha.7",
9537            vec![
9538                UpgradeInstruction::LoadModule {
9539                    module: "distinctive-load-target".into(),
9540                },
9541                UpgradeInstruction::StateChange {
9542                    script: PathBuf::from("lib/distinctive-migrate.lisp"),
9543                },
9544                UpgradeInstruction::Restart,
9545            ],
9546        );
9547        let built = UpgradeError::duplicate_from(&entry);
9548        match built {
9549            UpgradeError::DuplicateFrom { from } => {
9550                assert_eq!(
9551                    from, "0.2.0-alpha.7",
9552                    "from slot must project UpgradeFromEntry::prior_versao() \
9553                     (not any whole-entry rendering)"
9554                );
9555            }
9556            other => panic!("expected DuplicateFrom, got {other:?}"),
9557        }
9558    }
9559
9560    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9561    // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9562    // ctor (see the paired doc-block above the ctor definition) — the
9563    // fold of the last open-coded three-slot `{ from: String, kind:
9564    // &'static str, module: String }` struct-literal wire-up on
9565    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9566    // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9567    // load-family sticky-latch dispatch onto one substrate primitive.
9568    // A byte-mismatched ctor body would trip the equivalence pin first,
9569    // ahead of any downstream diagnostic-shape drift.
9570    //
9571    // Peer of the sibling standalone-ctor equivalence + routing pins on
9572    // the sibling one-off variants across `UpgradeError`
9573    // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9574    // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9575    // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9576    // the paired one-slot `{ from: String }` envelope) and across
9577    // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9578    // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9579    // String }` `AplicacaoError` envelope).
9580
9581    #[test]
9582    fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9583        // Equivalence pin: the ctor produces byte-equal
9584        // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9585        // open-coded three-field struct-literal on the same `(&str,
9586        // &'static str, &str)` fixture. Guards any future field-
9587        // addition / reordering / string-conversion tweak on the
9588        // variant. Same equivalence-pin shape as the sibling
9589        // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9590        // on the peer one-slot `{ from: String }` envelope.
9591        let from = "0.1.0";
9592        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9593        let module = "hello-rio-old";
9594        assert_eq!(
9595            UpgradeError::purge_without_prior_load(from, kind, module),
9596            UpgradeError::PurgeWithoutPriorLoad {
9597                from: from.to_string(),
9598                kind,
9599                module: module.to_string(),
9600            },
9601            "generated purge_without_prior_load ctor must produce \
9602             byte-equal UpgradeError to the open-coded struct-literal \
9603             wrap on the same (&str, &'static str, &str) fixture",
9604        );
9605    }
9606
9607    #[test]
9608    fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9609        // Cross-axis routing pin: sweep the three constructor input
9610        // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9611        // through distinct-per-axis fixtures across every cleanup-family
9612        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9613        // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9614        // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9615        // wrapper-side lowercase / trim / truncate / silent axis-swap
9616        // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9617        // three-field construction surfaces at assert time rather than
9618        // at a downstream diagnostic consumer that reads the fields
9619        // back and gets a different value than the one it stored. Both
9620        // `&str`-literal and `&String` (via Deref coercion) carriers
9621        // are exercised for `from` / `module` because the sole wire-up
9622        // hands `self.prior_versao()` (a `&str` accessor) and
9623        // `instr.declared_module().expect(…)` (also a `&str`) — the
9624        // ctor must accept both shapes without a pre-conversion.
9625        let kinds: [&'static str; 2] = [
9626            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9627            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9628        ];
9629        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9630        let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9631        for kind in kinds {
9632            for from in froms {
9633                for module in modules {
9634                    let from_owned: String = from.to_string();
9635                    let module_owned: String = module.to_string();
9636                    for (from_in, module_in) in
9637                        [(from, module), (from_owned.as_str(), module_owned.as_str())]
9638                    {
9639                        assert_eq!(
9640                            UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9641                            UpgradeError::PurgeWithoutPriorLoad {
9642                                from: from.to_string(),
9643                                kind,
9644                                module: module.to_string(),
9645                            },
9646                            "purge_without_prior_load must route from → from, \
9647                             kind → kind, module → module in declared field \
9648                             order verbatim on ({from:?}, {kind:?}, {module:?})",
9649                        );
9650                    }
9651                }
9652            }
9653        }
9654    }
9655
9656    #[test]
9657    fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9658        // End-to-end wire-up pin: build an entry whose declared
9659        // `:instructions` list places a `:soft-purge` (and separately a
9660        // `:purge`) before any `:load-module` so
9661        // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9662        // sticky-latch dispatch surfaces
9663        // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9664        // observed `Err` byte-equals the substrate-primitive
9665        // [`UpgradeError::purge_without_prior_load`] ctor's output on
9666        // the same fixture. A future silent de-lift of the wire-up back
9667        // to the open-coded struct-literal (or a silent axis-swap on
9668        // the three-field construction at the wire-up site) trips at
9669        // caixa-core test time rather than at a downstream diagnostic
9670        // consumer far from the wire-up commit. Same end-to-end-wire-up
9671        // discipline as the sibling
9672        // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9673        // on the peer cross-entry duplicate-`:from` gate; both key off
9674        // exactly one typed dispatch on the substrate primitive.
9675        let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9676            (
9677                "0.1.0",
9678                UpgradeInstruction::SoftPurge {
9679                    module: "hello-rio-old".into(),
9680                },
9681                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9682                "hello-rio-old",
9683            ),
9684            (
9685                "1.2.3-rc.1",
9686                UpgradeInstruction::Purge {
9687                    module: "cache-v2-ancient".into(),
9688                },
9689                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9690                "cache-v2-ancient",
9691            ),
9692        ];
9693        for (from, instr, kind, module) in cases {
9694            let e = entry(from, vec![instr]);
9695            let observed = e.validate().unwrap_err();
9696            assert_eq!(
9697                observed,
9698                UpgradeError::purge_without_prior_load(from, kind, module),
9699                "validate_purge_ordering must route its refusal through \
9700                 UpgradeError::purge_without_prior_load(from, kind, \
9701                 module) on a bare-cleanup {kind:?} entry, byte-equal \
9702                 to the pre-lift open-coded struct-literal wrap on the \
9703                 same fixture",
9704            );
9705        }
9706    }
9707
9708    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9709    // the standalone [`UpgradeError::state_change_after_cleanup`]
9710    // inherent ctor (see the paired doc-block above the ctor
9711    // definition) — the fold of the last open-coded four-slot `{ from:
9712    // String, script: PathBuf, prior_cleanup_kind: &'static str,
9713    // prior_cleanup_module: String }` struct-literal wire-up on
9714    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9715    // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9716    // migrate-family sticky-latch dispatch onto one substrate primitive.
9717    // A byte-mismatched ctor body would trip the equivalence pin first,
9718    // ahead of any downstream diagnostic-shape drift. Peer of the
9719    // sibling standalone-ctor equivalence + routing pins on the sibling
9720    // one-off variants across `UpgradeError`
9721    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9722    // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9723    // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9724    // on the paired three-slot `{ from, kind, module }` envelope;
9725    // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9726    // one-slot `{ from }` envelope).
9727
9728    #[test]
9729    fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9730        // Equivalence pin: the ctor produces byte-equal
9731        // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9732        // open-coded four-field struct-literal on the same `(&str,
9733        // &Path, &'static str, &str)` fixture. Guards any future
9734        // field-addition / reordering / string-conversion tweak on the
9735        // variant. Same equivalence-pin shape as the sibling
9736        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9737        // (9752da1) on the peer three-slot envelope.
9738        let from = "0.1.0";
9739        let script = Path::new("lib/m.lisp");
9740        let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9741        let prior_cleanup_module = "x-old";
9742        assert_eq!(
9743            UpgradeError::state_change_after_cleanup(
9744                from,
9745                script,
9746                prior_cleanup_kind,
9747                prior_cleanup_module,
9748            ),
9749            UpgradeError::StateChangeAfterCleanup {
9750                from: from.to_string(),
9751                script: script.to_path_buf(),
9752                prior_cleanup_kind,
9753                prior_cleanup_module: prior_cleanup_module.to_string(),
9754            },
9755            "generated state_change_after_cleanup ctor must produce \
9756             byte-equal UpgradeError to the open-coded struct-literal \
9757             wrap on the same (&str, &Path, &'static str, &str) fixture",
9758        );
9759    }
9760
9761    #[test]
9762    fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9763        // Cross-axis routing pin: sweep the four constructor input
9764        // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9765        // &'static str`, `prior_cleanup_module: &str`) through
9766        // distinct-per-axis fixtures across every cleanup-family
9767        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9768        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9769        // build-metadata, zero), sibling-`.lisp` script-path shapes
9770        // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9771        // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9772        // lowercase / trim / truncate / silent axis-swap
9773        // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9774        // `from`, `prior_cleanup_kind` misrouted onto
9775        // `prior_cleanup_module`) on the four-field construction
9776        // surfaces at assert time rather than at a downstream diagnostic
9777        // consumer that reads the fields back and gets a different value
9778        // than the one it stored. Both `&str`-literal and `&String` (via
9779        // Deref coercion) carriers are exercised for `from` /
9780        // `prior_cleanup_module` because the sole wire-up hands
9781        // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9782        // (also `&str`, from `declared_module().expect(…)`) — the ctor
9783        // must accept both shapes without a pre-conversion. Both
9784        // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9785        // are exercised for `script` because the sole wire-up hands a
9786        // `&PathBuf` sticky-latch projection from `declared_path()`'s
9787        // `Option<&PathBuf>` return — the ctor must accept both shapes
9788        // without a pre-conversion.
9789        let kinds: [&'static str; 2] = [
9790            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9791            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9792        ];
9793        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9794        let scripts: [&str; 3] = [
9795            "m.lisp",
9796            "lib/migrations.lisp",
9797            "lib/migrations/v01/step-1.lisp",
9798        ];
9799        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9800        for kind in kinds {
9801            for from in froms {
9802                for script_str in scripts {
9803                    for module in modules {
9804                        let from_owned: String = from.to_string();
9805                        let module_owned: String = module.to_string();
9806                        let script_path = Path::new(script_str);
9807                        let script_pathbuf = PathBuf::from(script_str);
9808                        for (from_in, module_in, script_in) in [
9809                            (from, module, script_path),
9810                            (
9811                                from_owned.as_str(),
9812                                module_owned.as_str(),
9813                                script_pathbuf.as_path(),
9814                            ),
9815                        ] {
9816                            assert_eq!(
9817                                UpgradeError::state_change_after_cleanup(
9818                                    from_in, script_in, kind, module_in,
9819                                ),
9820                                UpgradeError::StateChangeAfterCleanup {
9821                                    from: from.to_string(),
9822                                    script: PathBuf::from(script_str),
9823                                    prior_cleanup_kind: kind,
9824                                    prior_cleanup_module: module.to_string(),
9825                                },
9826                                "state_change_after_cleanup must route from → from, \
9827                                 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9828                                 prior_cleanup_module → prior_cleanup_module in declared \
9829                                 field order verbatim on ({from:?}, {script_str:?}, \
9830                                 {kind:?}, {module:?})",
9831                            );
9832                        }
9833                    }
9834                }
9835            }
9836        }
9837    }
9838
9839    #[test]
9840    fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9841        // End-to-end wire-up pin: build an entry whose declared
9842        // `:instructions` list places a `:soft-purge` (and separately a
9843        // `:purge`) before a `:state-change` so
9844        // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9845        // migrate-family sticky-latch dispatch surfaces
9846        // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9847        // observed `Err` byte-equals the substrate-primitive
9848        // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9849        // the same fixture. A future silent de-lift of the wire-up back
9850        // to the open-coded struct-literal (or a silent axis-swap on
9851        // the four-field construction at the wire-up site) trips at
9852        // caixa-core test time rather than at a downstream diagnostic
9853        // consumer far from the wire-up commit. Same end-to-end-wire-up
9854        // discipline as the sibling
9855        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9856        // on the peer load → cleanup ordering gate; both key off
9857        // exactly one typed dispatch on the substrate primitive. Every
9858        // entry here front-loads a `:load-module` so the sole surviving
9859        // ordering refusal is the migrate → cleanup one this gate
9860        // owns — the peer `validate_purge_ordering` load → cleanup gate
9861        // returns `Ok(())` on these fixtures, so the migrate-after-
9862        // cleanup arm is the only path to an `Err`.
9863        let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9864            (
9865                "0.1.0",
9866                UpgradeInstruction::SoftPurge {
9867                    module: "hello-rio-old".into(),
9868                },
9869                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9870                "hello-rio-old",
9871                "lib/migrations/v01.lisp",
9872            ),
9873            (
9874                "1.2.3-rc.1",
9875                UpgradeInstruction::Purge {
9876                    module: "cache-v2-ancient".into(),
9877                },
9878                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9879                "cache-v2-ancient",
9880                "lib/migrations/v02.lisp",
9881            ),
9882        ];
9883        for (from, cleanup, kind, module, script_str) in cases {
9884            let script = PathBuf::from(script_str);
9885            let e = entry(
9886                from,
9887                vec![
9888                    UpgradeInstruction::LoadModule {
9889                        module: "hello-rio".into(),
9890                    },
9891                    cleanup,
9892                    UpgradeInstruction::StateChange {
9893                        script: script.clone(),
9894                    },
9895                ],
9896            );
9897            let observed = e.validate().unwrap_err();
9898            assert_eq!(
9899                observed,
9900                UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9901                "validate_state_change_before_cleanup must route its \
9902                 refusal through \
9903                 UpgradeError::state_change_after_cleanup(from, script, \
9904                 prior_cleanup_kind, prior_cleanup_module) on a \
9905                 `:state-change` after a bare-cleanup {kind:?} entry, \
9906                 byte-equal to the pre-lift open-coded struct-literal \
9907                 wrap on the same fixture",
9908            );
9909        }
9910    }
9911
9912    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9913    // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9914    // (see the paired doc-block above the ctor definition) — the fold of
9915    // the last open-coded three-slot `{ from: String, module: String,
9916    // kinds: Vec<&'static str> }` struct-literal wire-up on
9917    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9918    // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9919    // cleanup-family dedup arm onto one substrate primitive. A byte-
9920    // mismatched ctor body would trip the equivalence pin first, ahead of
9921    // any downstream diagnostic-shape drift. Peer of the sibling
9922    // standalone-ctor equivalence + routing pins on the sibling one-off
9923    // variants across `UpgradeError`
9924    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9925    // paired three-slot `{ from, kind, module }` envelope for the sibling
9926    // load → cleanup ordering axis;
9927    // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9928    // the paired four-slot `{ from, script, prior_cleanup_kind,
9929    // prior_cleanup_module }` envelope for the migrate → cleanup
9930    // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9931    // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9932    // `:from` gate).
9933
9934    #[test]
9935    fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9936        // Equivalence pin: the ctor produces byte-equal
9937        // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9938        // three-field struct-literal on the same `(&str, &str,
9939        // Vec<&'static str>)` fixture. Guards any future field-addition /
9940        // reordering / string-conversion tweak on the variant. Same
9941        // equivalence-pin shape as the sibling
9942        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9943        // (9752da1) on the peer three-slot envelope.
9944        let from = "0.1.0";
9945        let module = "x-old";
9946        let kinds: Vec<&'static str> = vec![
9947            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9948            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9949        ];
9950        assert_eq!(
9951            UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9952            UpgradeError::DuplicateCleanup {
9953                from: from.to_string(),
9954                module: module.to_string(),
9955                kinds,
9956            },
9957            "generated duplicate_cleanup ctor must produce byte-equal \
9958             UpgradeError to the open-coded struct-literal wrap on the \
9959             same (&str, &str, Vec<&'static str>) fixture",
9960        );
9961    }
9962
9963    #[test]
9964    fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9965        // Cross-axis routing pin: sweep the three constructor input axes
9966        // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9967        // through distinct-per-axis fixtures across every ordered pair of
9968        // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9969        // four `(prior_kind, kind)` combinations `validate_cleanup_
9970        // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9971        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9972        // build-metadata, zero) + DNS-1123 module shapes (leaf,
9973        // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9974        // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9975        // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9976        // owned `Vec<&'static str>`) on the three-field construction
9977        // surfaces at assert time rather than at a downstream diagnostic
9978        // consumer that reads the fields back and gets a different value
9979        // than the one it stored. Both `&str`-literal and `&String` (via
9980        // Deref coercion) carriers are exercised for `from` / `module`
9981        // because the sole wire-up hands `self.prior_versao()` (a `&str`
9982        // accessor) and `module` (also `&str`, from `declared_module().
9983        // expect(…)`) — the ctor must accept both shapes without a
9984        // pre-conversion.
9985        let all_kinds: [&'static str; 2] = [
9986            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9987            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9988        ];
9989        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9990        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9991        for prior_kind in all_kinds {
9992            for kind in all_kinds {
9993                for from in froms {
9994                    for module in modules {
9995                        let from_owned: String = from.to_string();
9996                        let module_owned: String = module.to_string();
9997                        for (from_in, module_in) in
9998                            [(from, module), (from_owned.as_str(), module_owned.as_str())]
9999                        {
10000                            let kinds: Vec<&'static str> = vec![prior_kind, kind];
10001                            assert_eq!(
10002                                UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
10003                                UpgradeError::DuplicateCleanup {
10004                                    from: from.to_string(),
10005                                    module: module.to_string(),
10006                                    kinds,
10007                                },
10008                                "duplicate_cleanup must route from → from, \
10009                                 module → module, kinds → kinds in declared \
10010                                 field order verbatim on ({from:?}, \
10011                                 {module:?}, [{prior_kind:?}, {kind:?}])",
10012                            );
10013                        }
10014                    }
10015                }
10016            }
10017        }
10018    }
10019
10020    #[test]
10021    fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
10022        // End-to-end wire-up pin: build an entry whose declared
10023        // `:instructions` list front-loads a `:load-module` (so the
10024        // sibling `validate_purge_ordering` load → cleanup gate returns
10025        // `Ok(())` on the fixture) and then places two cleanup
10026        // instructions targeting the same module so
10027        // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
10028        // cleanup-family dedup arm surfaces
10029        // `UpgradeError::DuplicateCleanup`, then pin that the observed
10030        // `Err` byte-equals the substrate-primitive
10031        // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
10032        // fixture. A future silent de-lift of the wire-up back to the
10033        // open-coded struct-literal (or a silent axis-swap on the three-
10034        // field construction at the wire-up site, or a kinds-pair
10035        // reorder) trips at caixa-core test time rather than at a
10036        // downstream diagnostic consumer far from the wire-up commit.
10037        // Same end-to-end-wire-up discipline as the sibling
10038        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
10039        // on the peer load → cleanup ordering gate and
10040        // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
10041        // on the peer migrate → cleanup boundary; all three key off
10042        // exactly one typed dispatch on the substrate primitive.
10043        let cases: [(
10044            &str,
10045            UpgradeInstruction,
10046            UpgradeInstruction,
10047            &str,
10048            [&'static str; 2],
10049        ); 4] = [
10050            (
10051                "0.1.0",
10052                UpgradeInstruction::SoftPurge {
10053                    module: "hello-rio-old".into(),
10054                },
10055                UpgradeInstruction::SoftPurge {
10056                    module: "hello-rio-old".into(),
10057                },
10058                "hello-rio-old",
10059                [
10060                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10061                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10062                ],
10063            ),
10064            (
10065                "1.2.3-rc.1",
10066                UpgradeInstruction::Purge {
10067                    module: "cache-v2-ancient".into(),
10068                },
10069                UpgradeInstruction::Purge {
10070                    module: "cache-v2-ancient".into(),
10071                },
10072                "cache-v2-ancient",
10073                [
10074                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10075                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10076                ],
10077            ),
10078            (
10079                "0.2.0-alpha.7+build.5",
10080                UpgradeInstruction::SoftPurge {
10081                    module: "x-old".into(),
10082                },
10083                UpgradeInstruction::Purge {
10084                    module: "x-old".into(),
10085                },
10086                "x-old",
10087                [
10088                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10089                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10090                ],
10091            ),
10092            (
10093                "0.0.0",
10094                UpgradeInstruction::Purge {
10095                    module: "x-old".into(),
10096                },
10097                UpgradeInstruction::SoftPurge {
10098                    module: "x-old".into(),
10099                },
10100                "x-old",
10101                [
10102                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10103                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10104                ],
10105            ),
10106        ];
10107        for (from, first, second, module, kinds) in cases {
10108            let e = entry(
10109                from,
10110                vec![
10111                    UpgradeInstruction::LoadModule {
10112                        module: "hello-rio".into(),
10113                    },
10114                    first,
10115                    second,
10116                ],
10117            );
10118            let observed = e.validate().unwrap_err();
10119            assert_eq!(
10120                observed,
10121                UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
10122                "validate_cleanup_singularity must route its refusal \
10123                 through UpgradeError::duplicate_cleanup(from, module, \
10124                 kinds) on a two-cleanup {kinds:?} entry targeting the \
10125                 same module, byte-equal to the pre-lift open-coded \
10126                 struct-literal wrap on the same fixture",
10127            );
10128        }
10129    }
10130
10131    #[test]
10132    fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
10133        // Equivalence pin: the ctor produces byte-equal
10134        // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
10135        // three-field struct-literal on the same `(&str, usize,
10136        // Vec<&'static str>)` fixture. Guards any future field-addition /
10137        // reordering / string-conversion tweak on the variant. Same
10138        // equivalence-pin shape as the sibling
10139        // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
10140        // on the peer three-slot envelope.
10141        let from = "0.1.0";
10142        let restart_count: usize = 1;
10143        let other_kinds: Vec<&'static str> =
10144            vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
10145        assert_eq!(
10146            UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10147            UpgradeError::RestartNotExclusive {
10148                from: from.to_string(),
10149                restart_count,
10150                other_kinds,
10151            },
10152            "generated restart_not_exclusive ctor must produce byte-equal \
10153             UpgradeError to the open-coded struct-literal wrap on the \
10154             same (&str, usize, Vec<&'static str>) fixture",
10155        );
10156    }
10157
10158    #[test]
10159    fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
10160        // Cross-axis routing pin: sweep the three constructor input axes
10161        // (`from: &str`, `restart_count: usize`, `other_kinds:
10162        // Vec<&'static str>`) through distinct-per-axis fixtures across a
10163        // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
10164        // pre-release + build-metadata, zero) × non-degenerate
10165        // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
10166        // pure-duplication shape, 3 — the deeply-duplicated shape) ×
10167        // ordered `other_kinds` lisp-form lists spanning the four
10168        // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
10169        // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
10170        // empty (the `((:restart) (:restart))` shape), singleton
10171        // (`((:load-module …) (:restart))`), and the full typed sequence
10172        // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
10173        // …) (:restart))`) — so any wrapper-side silent lowercase / trim
10174        // / truncate / silent axis-swap (`from` ↔ swap onto
10175        // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
10176        // duplicate on the four-element owned `Vec<&'static str>`,
10177        // `other_kinds` reorder against declared instruction order) on
10178        // the three-field construction surfaces at assert time rather
10179        // than at a downstream diagnostic consumer that reads the fields
10180        // back and gets a different value than the one it stored. Both
10181        // `&str`-literal and `&String` (via Deref coercion) carriers are
10182        // exercised for `from` because the sole wire-up hands
10183        // `self.prior_versao()` (a `&str` accessor).
10184        let all_typed_kinds: [&'static str; 4] = [
10185            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10186            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10187            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10188            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10189        ];
10190        let other_kinds_matrix: [Vec<&'static str>; 3] =
10191            [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
10192        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
10193        let restart_counts: [usize; 3] = [1, 2, 3];
10194        for other_kinds in &other_kinds_matrix {
10195            for restart_count in restart_counts {
10196                for from in froms {
10197                    let from_owned: String = from.to_string();
10198                    for from_in in [from, from_owned.as_str()] {
10199                        assert_eq!(
10200                            UpgradeError::restart_not_exclusive(
10201                                from_in,
10202                                restart_count,
10203                                other_kinds.clone(),
10204                            ),
10205                            UpgradeError::RestartNotExclusive {
10206                                from: from.to_string(),
10207                                restart_count,
10208                                other_kinds: other_kinds.clone(),
10209                            },
10210                            "restart_not_exclusive must route from → from, \
10211                             restart_count → restart_count, other_kinds → \
10212                             other_kinds in declared field order verbatim \
10213                             on ({from:?}, {restart_count:?}, \
10214                             {other_kinds:?})",
10215                        );
10216                    }
10217                }
10218            }
10219        }
10220    }
10221
10222    #[test]
10223    fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
10224        // End-to-end wire-up pin: sweep the three canonical exclusivity-
10225        // violation shapes the `validate_restart_exclusive` gate can
10226        // refuse — restart + one typed instruction (`restart_count: 1,
10227        // other_kinds: [load-module]`), restart + full typed sequence
10228        // (`restart_count: 1, other_kinds: [load-module, state-change,
10229        // soft-purge, purge]`), and duplicated restart only
10230        // (`restart_count: 2, other_kinds: []`) — and pin that each
10231        // observed `Err` byte-equals the substrate-primitive
10232        // [`UpgradeError::restart_not_exclusive`] ctor's output on the
10233        // same fixture. A future silent de-lift of the wire-up back to
10234        // the open-coded struct-literal (or a silent axis-swap on the
10235        // three-field construction at the wire-up site, or an
10236        // `other_kinds` reorder / drop) trips at caixa-core test time
10237        // rather than at a downstream diagnostic consumer far from the
10238        // wire-up commit. Same end-to-end-wire-up discipline as the
10239        // sibling
10240        // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
10241        // (10a5b48) on the peer per-module cleanup-singularity axis and
10242        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
10243        // on the peer load → cleanup ordering gate; all three key off
10244        // exactly one typed dispatch on the substrate primitive.
10245        let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
10246            (
10247                "0.1.0",
10248                vec![
10249                    UpgradeInstruction::LoadModule {
10250                        module: "hello-rio".into(),
10251                    },
10252                    UpgradeInstruction::Restart,
10253                ],
10254                1,
10255                vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
10256            ),
10257            (
10258                "1.2.3-rc.1",
10259                vec![
10260                    UpgradeInstruction::LoadModule {
10261                        module: "hello-rio".into(),
10262                    },
10263                    UpgradeInstruction::StateChange {
10264                        script: PathBuf::from("lib/m.lisp"),
10265                    },
10266                    UpgradeInstruction::SoftPurge {
10267                        module: "hello-rio-old".into(),
10268                    },
10269                    UpgradeInstruction::Purge {
10270                        module: "hello-rio-old".into(),
10271                    },
10272                    UpgradeInstruction::Restart,
10273                ],
10274                1,
10275                vec![
10276                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10277                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10278                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10279                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10280                ],
10281            ),
10282            (
10283                "0.0.0",
10284                vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
10285                2,
10286                vec![],
10287            ),
10288        ];
10289        for (from, instructions, restart_count, other_kinds) in cases {
10290            let e = entry(from, instructions);
10291            let observed = e.validate().unwrap_err();
10292            assert_eq!(
10293                observed,
10294                UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10295                "validate_restart_exclusive must route its refusal \
10296                 through UpgradeError::restart_not_exclusive(from, \
10297                 restart_count, other_kinds) on a mixed-`(:restart)` \
10298                 entry, byte-equal to the pre-lift open-coded struct-\
10299                 literal wrap on the same fixture",
10300            );
10301        }
10302    }
10303
10304    #[test]
10305    fn module_invalid_ctor_matches_struct_literal_wrap() {
10306        // Fail-before-pass-after equivalence pin on
10307        // [`UpgradeError::module_invalid`] — the constructor must
10308        // produce a byte-equal `UpgradeError` to the pre-lift open-
10309        // coded `Self::ModuleInvalid { kind, module: module.to_string(),
10310        // reason }` struct-literal on the same `(:load-module …)` /
10311        // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
10312        // mismatched constructor body (a stray `.trim()`, a rebased
10313        // field order, a `String::new()` reason substitution) would
10314        // trip this pin first, byte-for-byte against the sibling
10315        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
10316        // [`crate::SupervisorError::child_caixa_invalid`] /
10317        // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
10318        // discipline on the peer three-slot `{ *, reason: String }`
10319        // invalid-arm ctor family.
10320        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10321        let module = "Hello-Rio";
10322        let reason = "must be lowercase alphanumeric or `-`";
10323        assert_eq!(
10324            UpgradeError::module_invalid(kind, module, reason),
10325            UpgradeError::ModuleInvalid {
10326                kind,
10327                module: module.to_string(),
10328                reason: reason.to_string(),
10329            },
10330            "generated module_invalid ctor must produce byte-equal \
10331             UpgradeError to the open-coded struct-literal wrap on the \
10332             same (kind, module, reason) fixture",
10333        );
10334    }
10335
10336    #[test]
10337    fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10338        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10339        // input across every [`UpgradeInstruction::declared_module`]-
10340        // bearing variant's canonical
10341        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10342        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10343        // canonical `":phantom"` fourth arm proving the ctor does not
10344        // silently clamp `kind` to the three-arm roster. The
10345        // `reason: impl Into<String>` bound accepts both `&str`
10346        // literals and the [`String`] the underlying
10347        // [`crate::render::is_dns_1123_label`] predicate returns via
10348        // `.into()`, matching the peer
10349        // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
10350        // sweep on the sibling `:contratos` per-edge envelope.
10351        let module = "Hello-Rio";
10352        let reason = "must be lowercase alphanumeric or `-`";
10353        for kind in [
10354            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10355            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10356            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10357            ":phantom",
10358        ] {
10359            assert_eq!(
10360                UpgradeError::module_invalid(kind, module, reason),
10361                UpgradeError::ModuleInvalid {
10362                    kind,
10363                    module: module.to_string(),
10364                    reason: reason.to_string(),
10365                },
10366                "module_invalid ctor must thread kind={kind:?} verbatim",
10367            );
10368        }
10369    }
10370
10371    #[test]
10372    fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
10373        // End-to-end wire-up pin: [`validate_module`]'s
10374        // [`crate::render::require_valid_dns_1123_label`] invalid-arm
10375        // must emit a diagnostic byte-equal to the ctor's output on the
10376        // same `(kind, module)` fixture — the fold's invariant that
10377        // [`validate_module`]'s cascade reaches the
10378        // [`UpgradeError::ModuleInvalid`] envelope through the
10379        // substrate primitive [`UpgradeError::module_invalid`] rather
10380        // than the pre-lift open-coded struct-literal. Sweep every
10381        // [`UpgradeInstruction::declared_module`]-bearing variant
10382        // against a canonical footgun (`"Hello-Rio"` — the uppercase-
10383        // lead footgun the peer `validate_rejects_non_dns_1123_module`
10384        // test above already carries) so every wire-up on the invalid-
10385        // arm cascade lands on the ctor's output. Matches the peer
10386        // sibling end-to-end pin
10387        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
10388        // carries on `validate_contrato_caixa`'s
10389        // `require_valid_dns_1123_label` invalid-arm.
10390        let module = "Hello-Rio";
10391        let cases: &[(UpgradeInstruction, &'static str)] = &[
10392            (
10393                UpgradeInstruction::LoadModule {
10394                    module: module.to_string(),
10395                },
10396                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10397            ),
10398            (
10399                UpgradeInstruction::SoftPurge {
10400                    module: module.to_string(),
10401                },
10402                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10403            ),
10404            (
10405                UpgradeInstruction::Purge {
10406                    module: module.to_string(),
10407                },
10408                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10409            ),
10410        ];
10411        for (instr, expected_kind) in cases {
10412            let observed = instr.validate().unwrap_err();
10413            let UpgradeError::ModuleInvalid {
10414                reason: observed_reason,
10415                ..
10416            } = &observed
10417            else {
10418                panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
10419            };
10420            assert_eq!(
10421                observed,
10422                UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
10423                "validate_module must route its invalid-arm refusal \
10424                 through UpgradeError::module_invalid(kind, module, \
10425                 reason) on {instr:?}, byte-equal to the pre-lift open-\
10426                 coded struct-literal wrap on the same fixture",
10427            );
10428        }
10429    }
10430
10431    #[test]
10432    fn module_empty_ctor_matches_struct_literal_wrap() {
10433        // Fail-before-pass-after equivalence pin on
10434        // [`UpgradeError::module_empty`] — the constructor must produce
10435        // a byte-equal `UpgradeError` to the pre-lift open-coded
10436        // `Self::ModuleEmpty { kind }` struct-literal on the same
10437        // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10438        // axis-tag fixture. A byte-mismatched constructor body (a stray
10439        // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10440        // one of the three canonical arms, a fixed-slot substitution)
10441        // would trip this pin first, matching the sibling
10442        // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10443        // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10444        // pin discipline on the peer one-slot `{ *: &'static str }`
10445        // empty-arm ctor family.
10446        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10447        assert_eq!(
10448            UpgradeError::module_empty(kind),
10449            UpgradeError::ModuleEmpty { kind },
10450            "generated module_empty ctor must produce byte-equal \
10451             UpgradeError to the open-coded struct-literal wrap on the \
10452             same kind fixture",
10453        );
10454    }
10455
10456    #[test]
10457    fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10458        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10459        // input across every [`UpgradeInstruction::declared_module`]-
10460        // bearing variant's canonical
10461        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10462        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10463        // canonical `":phantom"` fourth arm proving the ctor does not
10464        // silently clamp `kind` to the three-arm roster (a future
10465        // fourth `declared_module`-bearing `UpgradeInstruction` variant
10466        // lands on this ctor without a per-arm rewrite). Matches the
10467        // sibling [`Self::module_invalid`] cross-axis sweep at
10468        // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10469        // on the paired three-slot invalid-arm envelope so both arms of
10470        // the [`validate_module`] two-closure cascade carry the same
10471        // axis-invariance guarantee.
10472        for kind in [
10473            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10474            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10475            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10476            ":phantom",
10477        ] {
10478            assert_eq!(
10479                UpgradeError::module_empty(kind),
10480                UpgradeError::ModuleEmpty { kind },
10481                "module_empty ctor must thread kind={kind:?} verbatim",
10482            );
10483        }
10484    }
10485
10486    #[test]
10487    fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10488        // End-to-end wire-up pin: [`validate_module`]'s
10489        // [`crate::render::require_valid_dns_1123_label`] empty-arm
10490        // must emit a diagnostic byte-equal to the ctor's output on the
10491        // same `(kind, "")` fixture — the fold's invariant that
10492        // [`validate_module`]'s cascade reaches the
10493        // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10494        // primitive [`UpgradeError::module_empty`] rather than the
10495        // pre-lift open-coded struct-literal. Sweep every
10496        // [`UpgradeInstruction::declared_module`]-bearing variant
10497        // against the empty-string module value so every wire-up on the
10498        // empty-arm cascade lands on the ctor's output. Closes the pair
10499        // on the [`validate_module`] two-closure cascade the sibling
10500        // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10501        // (3d0d64a) already anchors on the invalid-arm.
10502        let cases: &[(UpgradeInstruction, &'static str)] = &[
10503            (
10504                UpgradeInstruction::LoadModule {
10505                    module: String::new(),
10506                },
10507                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10508            ),
10509            (
10510                UpgradeInstruction::SoftPurge {
10511                    module: String::new(),
10512                },
10513                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10514            ),
10515            (
10516                UpgradeInstruction::Purge {
10517                    module: String::new(),
10518                },
10519                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10520            ),
10521        ];
10522        for (instr, expected_kind) in cases {
10523            assert_eq!(
10524                instr.validate().unwrap_err(),
10525                UpgradeError::module_empty(expected_kind),
10526                "validate_module must route its empty-arm refusal \
10527                 through UpgradeError::module_empty(kind) on {instr:?}, \
10528                 byte-equal to the pre-lift open-coded struct-literal \
10529                 wrap on the same fixture",
10530            );
10531        }
10532    }
10533
10534    /// Fixture roster covering every [`UpgradeInstruction`] arm — a
10535    /// concrete-instance witness per variant so the four
10536    /// canonical-projection-triple pin tests below sweep the same five
10537    /// arms without duplicating the arm-shape declaration at each
10538    /// probe site. A future arm addition (a `Discard` peer the
10539    /// `code:delete/1` analog might inspire, a `SoftPurge` split into
10540    /// `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-down policy
10541    /// grows a two-arm shape) extends this fixture list as a single
10542    /// edit; the pin sweeps below then reach the new arm by iteration
10543    /// rather than a hand-authored per-arm probe.
10544    fn upgrade_instruction_arm_roster() -> Vec<(UpgradeInstruction, &'static str)> {
10545        vec![
10546            (
10547                UpgradeInstruction::LoadModule {
10548                    module: "hello-rio".into(),
10549                },
10550                "load-module",
10551            ),
10552            (
10553                UpgradeInstruction::StateChange {
10554                    script: std::path::PathBuf::from("lib/migrations/v01-to-v02.lisp"),
10555                },
10556                "state-change",
10557            ),
10558            (
10559                UpgradeInstruction::SoftPurge {
10560                    module: "hello-rio-old".into(),
10561                },
10562                "soft-purge",
10563            ),
10564            (
10565                UpgradeInstruction::Purge {
10566                    module: "hello-rio-old".into(),
10567                },
10568                "purge",
10569            ),
10570            (UpgradeInstruction::Restart, "restart"),
10571        ]
10572    }
10573
10574    #[test]
10575    fn upgrade_instruction_as_str_returns_canonical_kebab_wire_bytes() {
10576        // Fail-before-pass-after pin on the [`UpgradeInstruction::as_str`]
10577        // canonical-projection accessor: the five match arms each return
10578        // the un-prefixed kebab wire byte-string every serde-carried CR /
10579        // structured-log / fleet-catalog identity consumer converges onto.
10580        // A future variant rename or a per-arm typo (e.g. dropping the
10581        // hyphen from `"load-module"` → `"loadmodule"`) trips at
10582        // caixa-core test time rather than surfacing as a downstream K8s-
10583        // CR round-trip miss where the paired `Deserialize` derive
10584        // rejects the drifted arm on every apply.
10585        for (variant, expected) in upgrade_instruction_arm_roster() {
10586            assert_eq!(
10587                variant.as_str(),
10588                expected,
10589                "UpgradeInstruction::{variant:?}.as_str() must return the \
10590                 canonical un-prefixed kebab wire byte-string"
10591            );
10592        }
10593    }
10594
10595    #[test]
10596    fn upgrade_instruction_as_str_matches_discriminant_derive() {
10597        // Load-bearing pin on the two-source alignment: the hand-authored
10598        // [`UpgradeInstruction::as_str`] match arms must byte-equal the
10599        // [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
10600        // per-arm output for every variant. `.discriminant()` is the
10601        // fleet-wide dispatcher-catalog identity (registered under
10602        // `"caixa.upgrade-instruction"` by the sibling
10603        // `gen_platform::register_dispatcher!` macro invocation at
10604        // upgrade.rs:88); [`Self::as_str`] is the standard-library
10605        // `AsRef<str>` / [`std::fmt::Display`]-routed diagnostic byte-
10606        // string. Both must stay aligned so a consumer that reaches
10607        // through either path lands on the same per-arm byte-string.
10608        // A future rename on either side (a per-arm serde-attribute
10609        // drift silently splitting the derive's kebab output from the
10610        // hand-authored arms, a hand-authored typo on the [`Self::as_str`]
10611        // match arm silently splitting the standard-library-routed path
10612        // from the catalog identity) trips here at caixa-core test time
10613        // rather than as a divergent per-consumer dispatch at some future
10614        // downstream site.
10615        for (variant, _expected) in upgrade_instruction_arm_roster() {
10616            assert_eq!(
10617                variant.as_str(),
10618                variant.discriminant(),
10619                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10620                 the gen_platform::Discriminant-derived discriminant() \
10621                 output — the two axes are the substrate's kebab-case wire \
10622                 identity and must stay aligned by construction"
10623            );
10624        }
10625    }
10626
10627    #[test]
10628    fn upgrade_instruction_as_str_matches_serialize_wire_kind_tag() {
10629        // Load-bearing pin on the derive-to-hand alignment on the *wire*
10630        // axis: the hand-authored [`UpgradeInstruction::as_str`] match
10631        // arms must byte-equal the JSON tag the un-`rename`d
10632        // `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
10633        // emits under the paired
10634        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag key.
10635        // A future accidental `rename_all = "snake_case"` /
10636        // `"UPPERCASE"` attribute drift at the derive surface, or a
10637        // per-variant `#[serde(rename = "…")]` overlay silently
10638        // targeting one arm, would silently split the wire byte-shape
10639        // every K8s-CR / tatara-lisp round-trip / fleet-catalog
10640        // consumer reads through the two paths — pinning the identity
10641        // here makes any such drift a caixa-core-test-time failure.
10642        // Sibling in shape to
10643        // [`crate::kind::tests::caixa_kind_wire_name_matches_serialize_wire_byte_string`]
10644        // on the top-level [`crate::CaixaKind`] axis.
10645        for (variant, _expected) in upgrade_instruction_arm_roster() {
10646            let json = serde_json::to_value(&variant).expect("serialize must succeed");
10647            let kind_tag = json
10648                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
10649                .and_then(serde_json::Value::as_str)
10650                .unwrap_or_else(|| {
10651                    panic!(
10652                        "serialized UpgradeInstruction::{variant:?} must \
10653                         carry the M2_UPGRADE_INSTRUCTION_KEY_KIND tag as \
10654                         a JSON string"
10655                    )
10656                });
10657            assert_eq!(
10658                variant.as_str(),
10659                kind_tag,
10660                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10661                 the serde-derived JSON \"kind\" tag — a mismatch means \
10662                 either the derive attributes drifted or the as_str match \
10663                 arms drifted; either way downstream K8s-CR round-trip \
10664                 silently splits from the accessor-routed source of truth"
10665            );
10666        }
10667    }
10668
10669    #[test]
10670    fn upgrade_instruction_display_routes_through_as_str_helper() {
10671        // Fail-before-pass-after pin on the two-path convergence: pre-
10672        // lift [`UpgradeInstruction`] carried no [`std::fmt::Display`]
10673        // surface at all — every consumer past the wire format had to
10674        // pick between [`Self::lisp_form`] returning the tatara-lisp
10675        // author-surface with `:` prefix or `format!("{v:?}")` on the
10676        // `Debug` derive returning the PascalCase variant name plus
10677        // struct-literal fields. Wiring [`std::fmt::Display`] through
10678        // [`Self::as_str`] closes the drift footgun: every
10679        // `format!("{v}")` call reaches the same kebab wire byte-string
10680        // the [`Self::as_str`] helper returns, so a future variant
10681        // rename lands at exactly one place. Pin the routing here so a
10682        // future `impl std::fmt::Display for UpgradeInstruction`
10683        // reimplementation that hand-rolls the arms instead of
10684        // delegating to [`Self::as_str`] fails at caixa-core build
10685        // time. Peer of the sibling
10686        // [`crate::supervisor::tests::restart_strategy_display_routes_through_as_str_helper`]
10687        // /
10688        // [`crate::supervisor::tests::restart_policy_display_routes_through_as_str_helper`]
10689        // /
10690        // [`crate::kind::tests::caixa_kind_display_routes_through_as_str_helper`]
10691        // pins on the sibling closed-set typed-enum discriminator axes.
10692        for (variant, _expected) in upgrade_instruction_arm_roster() {
10693            assert_eq!(
10694                variant.to_string(),
10695                variant.as_str(),
10696                "UpgradeInstruction::{variant:?} Display must route \
10697                 through UpgradeInstruction::as_str (single source of \
10698                 truth: the kebab wire byte-string per arm)"
10699            );
10700        }
10701    }
10702
10703    #[test]
10704    fn upgrade_instruction_display_matches_as_str_and_not_lisp_form() {
10705        // Two-axis-split pin: the tatara-lisp author-surface form
10706        // ([`UpgradeInstruction::lisp_form`], with `:` prefix) and the
10707        // wire form ([`UpgradeInstruction::as_str`], without `:`
10708        // prefix) are structurally distinct by design. The pin here
10709        // makes the split load-bearing: a future accidental collapse
10710        // of either axis onto the other (routing `Display` through
10711        // [`Self::lisp_form`] via a mistaken match-arm re-inlining, or
10712        // routing [`Self::lisp_form`] through [`Self::as_str`] and
10713        // dropping the `:` prefix) would trip here at caixa-core
10714        // build time rather than silently merging the two axes at
10715        // some future consumer's per-instruction dispatch step. Peer
10716        // of the sibling
10717        // [`crate::kind::tests::caixa_kind_display_matches_as_str_and_not_serialize_wire`]
10718        // pin on the top-level [`crate::CaixaKind`] two-axis surface.
10719        for (variant, _expected) in upgrade_instruction_arm_roster() {
10720            let display = variant.to_string();
10721            let lisp = variant.lisp_form();
10722            assert_eq!(
10723                display,
10724                variant.as_str(),
10725                "UpgradeInstruction::{variant:?} Display must byte-equal \
10726                 as_str (kebab wire form, no `:` prefix)"
10727            );
10728            assert_ne!(
10729                display, lisp,
10730                "UpgradeInstruction::{variant:?} Display / as_str (wire \
10731                 kebab form) must stay structurally distinct from \
10732                 lisp_form (tatara-lisp author-surface with `:` prefix) — \
10733                 collapsing the two axes would break the tatara-lisp \
10734                 grep-and-fix workflow that keys off the `:` prefix"
10735            );
10736            assert!(
10737                lisp.starts_with(':'),
10738                "UpgradeInstruction::{variant:?}.lisp_form() must open \
10739                 with a `:` prefix (tatara-lisp author-surface form)"
10740            );
10741            assert!(
10742                !display.starts_with(':'),
10743                "UpgradeInstruction::{variant:?} Display must not open \
10744                 with a `:` prefix (wire form is un-prefixed kebab-case)"
10745            );
10746        }
10747    }
10748
10749    #[test]
10750    fn upgrade_instruction_as_ref_str_routes_through_as_str_accessor() {
10751        // Byte-parity pin on the standard-library `impl AsRef<str>`
10752        // route: every arm's `<UpgradeInstruction as
10753        // AsRef<str>>::as_ref(&v)` must byte-equal `v.as_str()`. Any
10754        // future silent detour that routes the impl through a
10755        // divergent projection (a per-arm inline `match self { … }`
10756        // re-inlining that opens a compile-time link to the un-lifted
10757        // arm-literal, a swap onto [`Self::lisp_form`] that would
10758        // collide the wire axis with the tatara-lisp author-surface
10759        // axis) trips here at caixa-core test time rather than at a
10760        // downstream `impl AsRef<str>`-bound consumer's silent split.
10761        // Peer of the sibling
10762        // [`crate::supervisor::tests::restart_strategy_as_ref_str_routes_through_as_str_accessor`]
10763        // /
10764        // [`crate::kind::tests::caixa_kind_as_ref_str_routes_through_as_str_accessor`]
10765        // pins.
10766        for (variant, _expected) in upgrade_instruction_arm_roster() {
10767            assert_eq!(
10768                <UpgradeInstruction as AsRef<str>>::as_ref(&variant),
10769                variant.as_str(),
10770                "UpgradeInstruction::{variant:?} AsRef<str> must route \
10771                 through UpgradeInstruction::as_str"
10772            );
10773        }
10774    }
10775
10776    #[test]
10777    fn upgrade_instruction_as_str_is_const_fn() {
10778        // Const-context pin: [`UpgradeInstruction::as_str`] must remain
10779        // `const fn`. Downstream consumers reaching for the accessor
10780        // from a `const` context (a module-scope `const _:() =
10781        // assert!(<variant>.as_str().len() > 0)` invariant pin, a
10782        // `const fn` per-instruction wire-shape audit table an M4
10783        // admission webhook materializes at build time) rely on the
10784        // const-ness. A future accidental downgrade to non-`const`
10785        // (an added runtime helper reachable only from a non-`const`
10786        // context, a manual hand-rolled `impl` that shadows this
10787        // method) trips at caixa-core build time rather than
10788        // surfacing as a downstream `const`-context regression far
10789        // from the accessor declaration. Peer of the sibling
10790        // [`crate::kind::tests::caixa_kind_wire_name_is_const_fn`] pin
10791        // on the top-level [`crate::CaixaKind`] axis.
10792        const RESTART_WIRE: &str = UpgradeInstruction::Restart.as_str();
10793        assert_eq!(RESTART_WIRE, "restart");
10794    }
10795
10796    #[test]
10797    fn upgrade_instruction_lisp_forms_covers_every_arm() {
10798        // Load-bearing pin on the substrate-canonical
10799        // [`UpgradeInstruction::LISP_FORMS`] exhaustive accept-set
10800        // roster: every arm of the shared
10801        // [`upgrade_instruction_arm_roster`] fixture must project
10802        // through [`UpgradeInstruction::lisp_form`] onto an entry the
10803        // [`UpgradeInstruction::LISP_FORMS`] roster carries, and the
10804        // roster's length must byte-equal the fixture's arm count so
10805        // a silent skew between the [`UpgradeInstruction::lisp_form`]
10806        // match's arm-set and the roster's arm-set trips here at
10807        // caixa-core test time rather than at a downstream M4
10808        // admission-webhook rejection body's accepted-set enumeration
10809        // miss / `feira lint --upgrade-from` per-instruction author-
10810        // audit unknown-tag-cascade miss / LSP hover completion
10811        // source's partial-position accepted-tag miss. A future arm
10812        // addition (a `Discard` peer the `code:delete/1` analog might
10813        // inspire, a `SoftPurge` split into `SoftPurgeCoop` /
10814        // `SoftPurgeForce` as the drain-cool-down policy grows a two-
10815        // arm shape) extends the shared
10816        // [`upgrade_instruction_arm_roster`] fixture as a single edit
10817        // and this pin sweeps the new arm by iteration; the paired
10818        // [`UpgradeInstruction::LISP_FORMS`] roster must grow in
10819        // lockstep or this assertion trips. Peer of the sibling
10820        // fieldless-enum roster round-trips
10821        // [`crate::supervisor::tests::restart_strategy_all_matches_from_wire_accept_set`]
10822        // /
10823        // [`crate::supervisor::tests::restart_policy_all_matches_from_wire_accept_set`]
10824        // /
10825        // [`crate::aplicacao::tests::placement_strategy_all_matches_from_wire_accept_set`]
10826        // /
10827        // [`crate::kind::tests::caixa_kind_all_matches_wire_name_emit_set`]
10828        // pins on the peer closed-set typed-enum exhaustive-iteration
10829        // surfaces, extended onto the discriminator axis of the
10830        // discriminated-union [`UpgradeInstruction`] enum where the
10831        // per-variant data payload rules out a `&'static [Self]`
10832        // roster.
10833        let fixture = upgrade_instruction_arm_roster();
10834        assert_eq!(
10835            UpgradeInstruction::LISP_FORMS.len(),
10836            fixture.len(),
10837            "UpgradeInstruction::LISP_FORMS.len() must byte-equal the \
10838             shared upgrade_instruction_arm_roster fixture's arm count \
10839             — a mismatch means the roster and the enum's arm-set \
10840             have drifted"
10841        );
10842        for (variant, _wire) in fixture {
10843            let lisp = variant.lisp_form();
10844            assert!(
10845                UpgradeInstruction::LISP_FORMS.contains(&lisp),
10846                "UpgradeInstruction::{variant:?}.lisp_form() = {lisp:?} \
10847                 must be a member of UpgradeInstruction::LISP_FORMS — \
10848                 the emitter and the roster have drifted out of lockstep"
10849            );
10850        }
10851        for tag in UpgradeInstruction::LISP_FORMS {
10852            assert!(
10853                tag.starts_with(':'),
10854                "UpgradeInstruction::LISP_FORMS entry {tag:?} must \
10855                 open with a `:` prefix (tatara-lisp author-surface \
10856                 form) — a bare kebab entry would collide the roster \
10857                 with the un-prefixed wire axis UpgradeInstruction::as_str \
10858                 emits"
10859            );
10860        }
10861    }
10862
10863    #[test]
10864    fn upgrade_instruction_wire_forms_covers_every_arm() {
10865        // Load-bearing pin on the peer substrate-canonical
10866        // [`UpgradeInstruction::WIRE_FORMS`] exhaustive accept-set
10867        // roster on the un-prefixed kebab wire-form axis: every arm
10868        // of the shared [`upgrade_instruction_arm_roster`] fixture
10869        // must project through [`UpgradeInstruction::as_str`] onto an
10870        // entry the [`UpgradeInstruction::WIRE_FORMS`] roster
10871        // carries, and the roster's length must byte-equal the
10872        // fixture's arm count so a silent skew between the
10873        // [`UpgradeInstruction::as_str`] match's arm-set and the
10874        // roster's arm-set trips here at caixa-core test time rather
10875        // than at a downstream M4 admission-webhook rejection body's
10876        // wire-form `"kind"` tag accepted-set enumeration miss / a
10877        // fleet-side operator's per-cluster catalog enumeration miss
10878        // on `"caixa.upgrade-instruction"` / a `caixa-actions`
10879        // workflow annotation drift on the accepted appup wire
10880        // vocabulary. A future arm addition (a `Discard` peer the
10881        // `code:delete/1` analog might inspire, a `SoftPurge` split
10882        // into `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-
10883        // down policy grows a two-arm shape) extends the shared
10884        // [`upgrade_instruction_arm_roster`] fixture as a single edit
10885        // and this pin sweeps the new arm by iteration; the paired
10886        // [`UpgradeInstruction::WIRE_FORMS`] roster must grow in
10887        // lockstep or this assertion trips. Peer of the sibling
10888        // [`upgrade_instruction_lisp_forms_covers_every_arm`] pin on
10889        // the tatara-lisp author-surface form axis — the two-axis
10890        // discipline (author-surface `:load-module` / wire-form
10891        // `load-module`) is now fully lifted into caixa-core through
10892        // paired `LISP_FORMS` + `WIRE_FORMS` roster consts, so a
10893        // per-consumer rebrand at either axis lands at exactly one
10894        // roster edit and every downstream projection picks it up by
10895        // construction. Every entry is pinned to *not* open with `:`
10896        // so a silent collapse of the two axes (a bare `:` -prefixed
10897        // entry that would let a wire-axis consumer accept the
10898        // lisp-axis byte-string) trips here rather than at a
10899        // downstream serde-round-trip miss.
10900        //
10901        // Fail-before-pass-after locally verified by mutating one
10902        // arm of the fixture's expected wire byte-string (e.g.
10903        // dropping the hyphen from `"load-module"` → `"loadmodule"`)
10904        // — this pin fires as expected on the `contains` check
10905        // before the paired [`UpgradeInstruction::as_str`] match
10906        // arm's routing is restored.
10907        let fixture = upgrade_instruction_arm_roster();
10908        assert_eq!(
10909            UpgradeInstruction::WIRE_FORMS.len(),
10910            fixture.len(),
10911            "UpgradeInstruction::WIRE_FORMS.len() must byte-equal the \
10912             shared upgrade_instruction_arm_roster fixture's arm count \
10913             — a mismatch means the roster and the enum's arm-set \
10914             have drifted"
10915        );
10916        for (variant, expected_wire) in fixture {
10917            let wire = variant.as_str();
10918            assert_eq!(
10919                wire, expected_wire,
10920                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10921                 the shared upgrade_instruction_arm_roster fixture's \
10922                 per-arm wire byte-string"
10923            );
10924            assert!(
10925                UpgradeInstruction::WIRE_FORMS.contains(&wire),
10926                "UpgradeInstruction::{variant:?}.as_str() = {wire:?} \
10927                 must be a member of UpgradeInstruction::WIRE_FORMS — \
10928                 the emitter and the roster have drifted out of lockstep"
10929            );
10930        }
10931        for tag in UpgradeInstruction::WIRE_FORMS {
10932            assert!(
10933                !tag.starts_with(':'),
10934                "UpgradeInstruction::WIRE_FORMS entry {tag:?} must not \
10935                 open with a `:` prefix (un-prefixed kebab wire form) \
10936                 — a `:` -prefixed entry would collide the roster with \
10937                 the tatara-lisp author-surface axis UpgradeInstruction::\
10938                 lisp_form emits"
10939            );
10940        }
10941    }
10942
10943    #[test]
10944    fn upgrade_instruction_lisp_and_wire_forms_are_length_aligned() {
10945        // Two-axis structural-lockstep pin: the paired
10946        // [`UpgradeInstruction::LISP_FORMS`] (tatara-lisp author-
10947        // surface, `:` -prefixed) and [`UpgradeInstruction::WIRE_FORMS`]
10948        // (un-prefixed kebab wire-form) rosters must always carry the
10949        // same arm count. Every arm of the discriminated-union
10950        // [`UpgradeInstruction`] enum projects through both accessors
10951        // ([`Self::lisp_form`] and [`Self::as_str`]) onto exactly one
10952        // entry of each roster by construction of the paired
10953        // `M2_UPGRADE_INSTRUCTION_KIND_*` / `M2_UPGRADE_INSTRUCTION_WIRE_*`
10954        // const families the two rosters route through; a future arm
10955        // addition (`Discard`, `SoftPurgeCoop` / `SoftPurgeForce`, …)
10956        // must extend both rosters in lockstep. The individual
10957        // [`upgrade_instruction_lisp_forms_covers_every_arm`] and
10958        // [`upgrade_instruction_wire_forms_covers_every_arm`] pins
10959        // each gate their own roster against the shared fixture; this
10960        // pin closes the transitive triangle so a hypothetical shared-
10961        // fixture drift that landed identically on one roster but not
10962        // the other (or a copy-paste that grew one roster without the
10963        // other) trips at caixa-core test time. Peer of the sibling
10964        // [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
10965        // pin on the scalar-accessor pair, extended here onto the
10966        // exhaustive-iteration roster pair.
10967        assert_eq!(
10968            UpgradeInstruction::LISP_FORMS.len(),
10969            UpgradeInstruction::WIRE_FORMS.len(),
10970            "UpgradeInstruction::LISP_FORMS.len() must byte-equal \
10971             UpgradeInstruction::WIRE_FORMS.len() — the two rosters \
10972             enumerate the same closed set of enum arms through \
10973             different axes, so a length mismatch means one axis's \
10974             roster grew without the other and downstream consumers \
10975             that fan through both will silently disagree on the \
10976             accepted arm-set"
10977        );
10978    }
10979}