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 per-`UpgradeInstruction` OTP-appup kind-tag
1709    /// projection every consumer that renders / classifies / grepping-
1710    /// projects an instruction's lisp form keys off — returns the
1711    /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1712    /// straight through the paired
1713    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1714    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1715    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1716    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1717    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1718    /// roster the substrate already carries at the wire-form axis.
1719    ///
1720    /// Consumers today: [`Self::validate`] threads the label through the
1721    /// per-variant [`UpgradeError::ModuleEmpty`] /
1722    /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1723    /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1724    /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1725    /// `(:purge …)` and fix it in one edit; every within-entry cross-
1726    /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1727    /// same accessor's `&'static str` return in place of hand-rolling
1728    /// the per-arm match.
1729    ///
1730    /// Promoted from `pub(self)` to `pub`: every future consumer that
1731    /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1732    /// by its OTP-appup lisp form outside caixa-core — a deferred
1733    /// wasm-operator `install_release/1` per-instruction dispatch
1734    /// logger tagging each executed instruction under its kebab-case
1735    /// kind, a `feira lint --upgrade-from` per-instruction author-time
1736    /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1737    /// webhook naming the offending instruction's kind in its rejection
1738    /// body, a future `caixa-actions` renderer that surfaces the
1739    /// declared appup instruction list in a workflow annotation, an
1740    /// LSP hover projecting the per-instruction kind onto a text-
1741    /// document diagnostic — reaches this projection through one call
1742    /// on the substrate primitive rather than open-coding the same
1743    /// five-arm match plus per-arm const imports at every consumer.
1744    /// A future variant addition (a `Discard` peer the `code:delete/1`
1745    /// analog inspires, an M4 `SoftPurge` split into
1746    /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1747    /// policy grows a two-arm shape) reaches every consumer at one edit
1748    /// — this method's match — rather than fanning out through hand-
1749    /// rolled per-arm dispatch across every downstream site.
1750    ///
1751    /// Peer of the sibling substrate-canonical arm-family accessors on
1752    /// the same closed-set enum: [`Self::declared_module`] on the
1753    /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1754    /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1755    /// [`Self::StateChange`] / [`Self::Restart`]),
1756    /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1757    /// (`Some(_)` for [`Self::StateChange`]), and
1758    /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1759    /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1760    /// generated per-variant `is_*` predicate family — every downstream
1761    /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1762    /// one typed dispatch on the substrate primitive rather than open-
1763    /// coding a per-arm match.
1764    ///
1765    /// `const fn` preserves the zero-runtime-work property of the pre-
1766    /// promotion body verbatim, and the `&'static str` return (not
1767    /// `&str` tied to `&self`'s lifetime) matches the paired
1768    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1769    /// program-lifetime discipline so callers can stash the returned
1770    /// label in `&'static`-bounded positions (a static logger's format
1771    /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1772    /// slice-of-`&'static str` accept-set) without re-borrowing through
1773    /// the instruction reference. Named `lisp_form` (not `kind_label` /
1774    /// `discriminant_label`) to name the axis the substrate already
1775    /// reaches for in the paired
1776    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1777    /// in every per-arm `UpgradeError` diagnostic that carries the
1778    /// kebab-case tag verbatim — the lisp author-surface term, not the
1779    /// Rust discriminant name.
1780    #[must_use]
1781    pub const fn lisp_form(&self) -> &'static str {
1782        match self {
1783            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1784            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1785            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1786            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1787            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1788        }
1789    }
1790
1791    /// Substrate-canonical per-`UpgradeInstruction` kebab-case wire-form
1792    /// discriminator every consumer that lands on the un-prefixed
1793    /// kebab byte-string (matching serde's
1794    /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive's
1795    /// per-variant tag output and the
1796    /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1797    /// fleet-catalog identity) reaches through — returns `"load-module"`
1798    /// / `"state-change"` / `"soft-purge"` / `"purge"` / `"restart"`,
1799    /// byte-for-byte the same five strings the JSON `"kind"` tag carries
1800    /// (per the sibling
1801    /// [`crate::tests::dispatcher_registration::reflection_round_trips_through_serde_tags`]
1802    /// pin) and the fleet-wide dispatcher-catalog registers under
1803    /// `"caixa.upgrade-instruction"` (per
1804    /// [`crate::tests::dispatcher_registration::variant_kinds_match_otp_appup_kebab`]).
1805    ///
1806    /// Distinct axis from the peer [`Self::lisp_form`] accessor, which
1807    /// returns the tatara-lisp author-surface form with the leading `:`
1808    /// prefix (`":load-module"` / `":state-change"` / `":soft-purge"` /
1809    /// `":purge"` / `":restart"`) that lands in `feira lint` per-
1810    /// instruction diagnostics and every
1811    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const's docstring.
1812    /// The two axes carry different bytes by design, not drift: the lisp
1813    /// form is the author-facing tag the caixa.lisp grep-and-fix
1814    /// workflow reaches for (`grep '(:load-module '` finds the offending
1815    /// entry verbatim), while [`Self::as_str`] is the wire-format byte-
1816    /// string every serde-serialized CR / [`std::fmt::Display`]-formatted
1817    /// diagnostic line / [`AsRef<str>`]-bound consumer / fleet-catalog
1818    /// identity converge onto — the same two-axis discipline the sibling
1819    /// [`crate::CaixaKind::as_str`] / [`crate::CaixaKind::wire_name`]
1820    /// pair (2aa6d23) documents on the top-level `:kind` closed-set
1821    /// discriminator, extended here onto the M2 OTP-appup
1822    /// per-instruction tag axis.
1823    ///
1824    /// Peer of the sibling closed-set typed enums' `as_str` /
1825    /// `as_suffix` canonical-projection accessors:
1826    /// [`crate::CaixaKind::as_str`] (6b1f4fb),
1827    /// [`crate::supervisor::RestartStrategy::as_str`] (09ffb2d),
1828    /// [`crate::supervisor::RestartPolicy::as_str`] (ccdf955),
1829    /// [`crate::aplicacao::PlacementStrategy::as_str`] (cc8f749),
1830    /// [`crate::aplicacao::RateLimitUnit::as_suffix`] (6bce03d) — the
1831    /// last closed-set typed enum on the caixa `:upgrade-from` surface
1832    /// to converge onto the substrate-canonical
1833    /// `(as_str, AsRef<str>, Display)` triple through one lifted
1834    /// `const fn` scalar accessor, so a future author-facing rebrand
1835    /// (a per-consumer disambiguation of the OTP-appup vocabulary, a
1836    /// hypothetical `:reload` collapse of `:load-module` under an
1837    /// Elixir/Phoenix hot-reload convergence, an M4-side rename of
1838    /// `:state-change` onto Erlang's own `code_change/3` verbatim) lands
1839    /// at one match arm — the paired [`std::fmt::Display`] impl and
1840    /// [`AsRef<str>`] impl route through this accessor by construction,
1841    /// so every consumer downstream of any of the three reaches the same
1842    /// per-arm byte-string in lockstep.
1843    ///
1844    /// `pub const fn` matches the peer accessors' const-context posture:
1845    /// downstream `const`-context callers (a module-scope
1846    /// `const _:() = assert!(<variant>.as_str().len() > 0)` invariant
1847    /// pin, a `const fn` per-instruction wire-shape audit table the M4
1848    /// admission webhook materializes at build time) reach the accessor
1849    /// through one dispatch on the substrate primitive without an
1850    /// intermediate non-`const` step. Returns `&'static str` (not
1851    /// `&str` bound to `&self`'s lifetime) so callers can stash the
1852    /// returned label in `&'static`-bounded positions (a static logger's
1853    /// format argument, a `HashMap<&'static str, _>` key, a `matches!`-
1854    /// style slice-of-`&'static str` accept-set) without re-borrowing
1855    /// through the instruction reference.
1856    #[must_use]
1857    pub const fn as_str(&self) -> &'static str {
1858        match self {
1859            Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
1860            Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
1861            Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
1862            Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
1863            Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
1864        }
1865    }
1866
1867    /// Validate the instruction's typed shape. Path existence is
1868    /// checked separately by [`crate::layout::StandardLayout`].
1869    ///
1870    /// The per-variant scalar the value-shape gates fire against is
1871    /// read through this method's two sibling accessors — the
1872    /// `String`-carrying axis via [`Self::declared_module`] (the
1873    /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1874    /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1875    /// carrying axis via [`Self::declared_path`] (the `StateChange`
1876    /// variant's tatara-lisp `:script`) — rather than the per-arm
1877    /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1878    /// Self::Purge { module }` pattern the module-axis previously
1879    /// open-coded and the per-arm `Self::StateChange { script }` the
1880    /// script-axis previously open-coded. Every scalar this enum
1881    /// carries now flows through one of the two `Option<&…>`
1882    /// accessors, so a future extension of either axis (a fifth
1883    /// module-bearing variant, an operator-side pre-parsed scalar
1884    /// cache the accessors materialize behind the same return
1885    /// contract, an M4 typed sub-slot the accessors could route
1886    /// alongside the existing scalar) migrates as a single edit on
1887    /// the accessor rather than a coordinated rewrite of every
1888    /// downstream value-shape gate. `Restart` (the only variant that
1889    /// carries neither scalar) falls through both `Option` checks and
1890    /// returns `Ok(())` — the terminal-fallback shape the
1891    /// [`Self::Restart`] variant doc pins.
1892    pub fn validate(&self) -> Result<(), UpgradeError> {
1893        if let Some(module) = self.declared_module() {
1894            return validate_module(self.lisp_form(), module);
1895        }
1896        if let Some(script) = self.declared_path() {
1897            // Delegate the four-arm cascade (empty / absolute /
1898            // parent-escape / non-`.lisp`-extension) to the lifted
1899            // [`crate::render::require_sandboxed_lisp_path`] helper —
1900            // same `Empty → Absolute → ParentEscape → NonLispExtension`
1901            // arm-ordering this method previously inlined verbatim,
1902            // now shared with [`crate::BehaviorSpec::validate`]'s
1903            // per-`:on-*`-callback gate so every author-supplied
1904            // tatara-lisp source path on every M2 typed slot consults
1905            // one gate, not two-and-counting verbatim copies of the
1906            // same four-arm cascade. Each closure wraps the tag in
1907            // the same `*Script` variant the original inline code
1908            // raised, so the diagnostic shape every caller depends
1909            // on (the `:state-change :script` self-locating error)
1910            // is preserved by construction. See
1911            // [`crate::render::require_sandboxed_lisp_path`] for the
1912            // smallest-scope-arm-fires-last ordering rationale.
1913            crate::render::require_sandboxed_lisp_path(
1914                script,
1915                || UpgradeError::EmptyScript,
1916                || UpgradeError::absolute_script(script),
1917                || UpgradeError::parent_escape_script(script),
1918                || UpgradeError::non_lisp_extension_script(script),
1919            )?;
1920        }
1921        // `Restart` (the only variant with no `Option<&…>`-carrying
1922        // scalar) falls through both accessor gates and returns
1923        // `Ok(())` — the terminal-fallback shape.
1924        Ok(())
1925    }
1926
1927    /// The `:module` scalar carried by this instruction — the
1928    /// K8s DNS-1123-label OTP-appup caixa-name reference every
1929    /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1930    /// variant declares against, and every author expects `feira lint`
1931    /// to name verbatim in per-instruction diagnostics. Returns `None`
1932    /// on [`Self::StateChange`] (which carries a `:script` — closed by
1933    /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1934    /// (which carries no data at all, the OTP terminal-fallback
1935    /// shape).
1936    ///
1937    /// Sibling in shape to [`Self::declared_path`] on the second and
1938    /// final scalar-carrying axis of [`UpgradeInstruction`]:
1939    /// `declared_path` closes the `PathBuf`-carrying arm
1940    /// (`StateChange`); `declared_module` closes the `String`-carrying
1941    /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1942    /// enum carries now routes through one of the two `Option<&…>`
1943    /// accessors — a caller that doesn't care which variant declared
1944    /// the scalar reads through one `if let Some(…)` rather than a
1945    /// per-variant pattern match. The pair is the enum-variant-
1946    /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1947    /// on the M3 side ([`crate::WitContract::source`] /
1948    /// [`crate::WitContract::destination`] /
1949    /// [`crate::WitContract::world_ref`] closing `:contratos`;
1950    /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1951    /// closing `:entrada`; [`crate::Membro::nome`] /
1952    /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1953    /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1954    /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1955    /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1956    /// closed OTP-shape supervisor family) — those peer accessors
1957    /// return a struct field verbatim; this pair unifies enum-
1958    /// variant-carried scalars into one accessor per typed axis.
1959    ///
1960    /// Byte-for-byte from the typed variant's own `String` storage;
1961    /// no cloning, no re-parsing. A future extension of the axis (an
1962    /// M4 typed sub-slot the module string is derived from, an
1963    /// operator-side pre-parsed caixa-name cache the accessor could
1964    /// materialize behind the same `&str` return contract, a fifth
1965    /// module-bearing OTP-appup variant the enum grows) migrates as
1966    /// a single caixa-core edit rather than a coordinated rewrite
1967    /// of every downstream module-axis consumer (currently
1968    /// [`Self::validate`]'s DNS-1123-label gate through
1969    /// [`validate_module`]; extensible to future consumers on the
1970    /// same axis without further per-variant match sites).
1971    #[must_use]
1972    pub const fn declared_module(&self) -> Option<&str> {
1973        match self {
1974            Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1975                Some(module.as_str())
1976            }
1977            Self::StateChange { .. } | Self::Restart => None,
1978        }
1979    }
1980
1981    /// If the instruction references an on-disk path, return it —
1982    /// used by the layout checker to verify the path resolves.
1983    ///
1984    /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1985    /// on the `String`-carrying axis: `declared_path` closes the
1986    /// `StateChange` arm's `:script`; `declared_module` closes the
1987    /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1988    /// they route every scalar this enum carries through one of two
1989    /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1990    /// gates dispatch on the accessor return rather than a per-variant
1991    /// pattern match on the enum shape itself.
1992    ///
1993    /// Four per-`UpgradeInstruction` consumers now key off this
1994    /// accessor's `PathBuf`-carrying axis:
1995    /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1996    /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1997    /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1998    /// within-entry
1999    /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
2000    /// per-`StateChange` script-projection fan-out, and the cross-slot
2001    /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
2002    /// :behavior` composition gate's per-`StateChange` detection loop
2003    /// — every downstream consumer of the `PathBuf`-carrying axis
2004    /// reaches through this one dispatch, so a future accessor
2005    /// extension (an M4 typed sub-slot the script path is derived from,
2006    /// an operator-side pre-resolved-path cache the accessor
2007    /// materializes behind the same `Option<&PathBuf>` return contract,
2008    /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
2009    /// migrates as a single caixa-core edit rather than a coordinated
2010    /// rewrite of four call sites.
2011    #[must_use]
2012    pub const fn declared_path(&self) -> Option<&PathBuf> {
2013        match self {
2014            Self::StateChange { script } => Some(script),
2015            _ => None,
2016        }
2017    }
2018
2019    /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
2020    /// family arm-discriminator predicate every within-entry cross-
2021    /// instruction cleanup-facing gate keys off — true iff `self` is
2022    /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
2023    /// named module until no process is running it, then GC) or
2024    /// [`Self::Purge`] (`code:purge/1` analog: discard the named
2025    /// module immediately, without waiting for drain), the two OTP
2026    /// two-phase-code-load cleanup arms the closed-set enum's
2027    /// non-terminal / non-migration / non-load variants exhaust.
2028    /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
2029    /// two-phase-load half, [`Self::StateChange`] on the
2030    /// `gen_server:code_change/3`-analog migration axis,
2031    /// [`Self::Restart`] on the OTP terminal-fallback shape)
2032    /// returns `false`.
2033    ///
2034    /// Prior to this lift the `Self::SoftPurge { module } |
2035    /// Self::Purge { module }` two-arm cleanup-family pattern-
2036    /// match sat inline at three within-entry cross-instruction
2037    /// gate sites, each hand-rolling its own copy of the union
2038    /// with no compile-time link back to the substrate primitive's
2039    /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
2040    /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
2041    /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
2042    /// arriving before a preceding [`Self::LoadModule`]),
2043    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
2044    /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
2045    /// recording the first-encountered cleanup so a subsequent
2046    /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
2047    /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
2048    /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
2049    /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
2050    /// second cleanup targeting the same `:module`). Three open-
2051    /// coded per-arm-union pattern-matches that expressed no
2052    /// compile-time link back to the substrate primitive. A future
2053    /// fifth cleanup-shaped variant (a `Discard` variant the
2054    /// `code:delete/1` peer inspires that folds under the same
2055    /// two-phase-load cleanup partition, an M4 `SoftPurge` split
2056    /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
2057    /// cool-down policy grows a two-arm shape, an operator-side
2058    /// pre-resolved cleanup-decision cache the predicate could
2059    /// route through the same `bool` return contract) would have
2060    /// had to be threaded through every open-coded per-arm-union
2061    /// pattern-match in lockstep or one gate would silently
2062    /// classify the new arm outside the cleanup family while the
2063    /// peer gates classified it in (or vice versa) — a
2064    /// classification split across the three within-entry cross-
2065    /// instruction gates at build time that lands far from the
2066    /// source [`UpgradeInstruction`] declaration with no field
2067    /// naming which gate carries the drifted arm-set. Lifting the
2068    /// resolution to a typed predicate on the substrate primitive
2069    /// means every downstream cleanup-facing consumer of the
2070    /// [`UpgradeInstruction`] closed-set enum reaches for exactly
2071    /// one typed dispatch — the resolver's arm-set migrates as a
2072    /// unit on any future arm addition composing under this
2073    /// predicate's `||` chain.
2074    ///
2075    /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
2076    /// derive-generated [`Self::is_restart`] terminal-fallback
2077    /// arm-discriminator predicate on the same closed-set
2078    /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
2079    /// family partition as one typed dispatch on the substrate
2080    /// primitive; `is_restart` on the single-arm terminal-
2081    /// fallback family, `is_cleanup` on the two-arm cleanup
2082    /// family), extended here from the single-arm case onto the
2083    /// two-arm arm-family union case. Composes through the
2084    /// [`gen_platform::IsVariant`]-derive-generated
2085    /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
2086    /// predicates rather than an open-coded raw `matches!`
2087    /// pattern-match, so a future rebrand on either underlying
2088    /// per-arm classifier flows through this predicate's one
2089    /// body without a coordinated per-consumer rewrite across
2090    /// the three within-entry cross-instruction gates that route
2091    /// through it. Peer of the sibling per-`:contratos`
2092    /// shape-family union predicates [`crate::WitContract::is_http`] /
2093    /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
2094    /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
2095    /// per-shape WIT-prefix rule the substrate primitive's arm-
2096    /// family partition names as one typed dispatch) — the same
2097    /// "one typed dispatch on the substrate primitive, thin
2098    /// projections at each consumer" discipline extended onto the
2099    /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
2100    /// cleanup-family axis.
2101    ///
2102    /// The name `is_cleanup` maps directly onto the canonical
2103    /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
2104    /// `code:soft_purge/1` — wait until no process is running v1,
2105    /// then discard. (`code:purge/1` kills v1 immediately if you
2106    /// don't care.)" — the two `code:*_purge/1` operations are
2107    /// the two-phase-load contract's cleanup half, paired under
2108    /// one concept), and the peer [`Self::validate_cleanup_singularity`]
2109    /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
2110    /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
2111    /// reaches for the same "cleanup" vocabulary in identifier +
2112    /// diagnostic form.
2113    #[must_use]
2114    pub const fn is_cleanup(&self) -> bool {
2115        self.is_soft_purge() || self.is_purge()
2116    }
2117}
2118
2119/// [`std::fmt::Display`] routed through [`UpgradeInstruction::as_str`],
2120/// so the pretty-printed byte-string every consumer that formats the
2121/// per-`:upgrade-from :instructions` entry's OTP-appup tag as user-
2122/// facing text lands on (the future wasm-operator's
2123/// `install_release/1` per-instruction dispatch log line, the future
2124/// `feira lint --upgrade-from` per-entry annotation, an M4
2125/// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection body
2126/// naming the offending instruction's kind, an LSP hover projecting
2127/// the instruction kind onto a text-document diagnostic) reaches for
2128/// the same wire byte-string the un-`rename`d
2129/// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive emits
2130/// under the paired [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
2131/// tag key.
2132///
2133/// Peer of the sibling closed-set typed enums' `Display` route through
2134/// their `as_str` accessor: [`crate::CaixaKind`] (2aa6d23),
2135/// [`crate::supervisor::RestartStrategy`] (supervisor.rs),
2136/// [`crate::supervisor::RestartPolicy`] (supervisor.rs), and
2137/// [`crate::aplicacao::PlacementStrategy`] (aplicacao.rs) — the last
2138/// M2 OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2139/// surface to converge onto the `Display`-through-`as_str` discipline.
2140///
2141/// Deliberately routes through the wire-aligned
2142/// [`UpgradeInstruction::as_str`] axis (kebab-case, no `:` prefix),
2143/// not the tatara-lisp author-surface [`UpgradeInstruction::lisp_form`]
2144/// axis (kebab-case, with `:` prefix): the two axes carry different
2145/// bytes by design, and Rust convention pairs [`std::fmt::Display`]
2146/// with the wire byte-string every serde-carried CR / structured-log /
2147/// catalog identity reaches. The two-axis split is preserved
2148/// structurally by the pin
2149/// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
2150/// so a future accidental collapse (routing `Display` through
2151/// [`Self::lisp_form`] via a mistaken match-arm re-inlining) trips at
2152/// caixa-core test time rather than silently merging the two axes at
2153/// some future consumer's per-instruction dispatch step.
2154///
2155/// Discards the per-variant scalar data (`module: String` on
2156/// `LoadModule` / `SoftPurge` / `Purge`; `script: PathBuf` on
2157/// `StateChange`) by design — the `Display` axis is the *tag*
2158/// projection, not a full value dump; consumers wanting the field
2159/// scalar reach for [`Self::declared_module`] /
2160/// [`Self::declared_path`] on the sibling scalar-accessor family. The
2161/// `{:?}` [`std::fmt::Debug`] derive stays untouched for callers that
2162/// want the full variant + field rendering.
2163impl std::fmt::Display for UpgradeInstruction {
2164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2165        f.write_str(self.as_str())
2166    }
2167}
2168
2169/// Substrate-canonical [`AsRef<str>`] projection on the M2 OTP-appup
2170/// per-instruction [`UpgradeInstruction`] closed-set typed enum —
2171/// routes through the same [`UpgradeInstruction::as_str`]
2172/// `pub const fn` scalar accessor the paired [`std::fmt::Display`]
2173/// impl and the un-`rename`d [`serde::Serialize`] derive already key
2174/// off, so any future consumer that binds an [`UpgradeInstruction`]
2175/// through the standard-library `impl AsRef<str>` bound (a deferred
2176/// wasm-operator per-instruction structured-log recorder that accepts
2177/// `impl AsRef<str>` at the `tracing::field::Value` `Str`-arm, a
2178/// [`std::collections::HashMap`] lookup keyed on the instruction wire
2179/// byte through `map.get::<str>(instr.as_ref())` on a future
2180/// per-instruction dispatch table an M4 admission webhook composes,
2181/// a [`std::process::Command::arg`] shell-out threading the instruction
2182/// tag through a deferred `feira upgrade-from --dry-run <kind>` verb)
2183/// reaches the same kebab-case wire byte-string the
2184/// [`Self::as_str`] accessor returns through one substrate-primitive
2185/// dispatch rather than an open-coded `.as_str()` projection at
2186/// every wire-up.
2187///
2188/// Peer of the sibling [`std::fmt::Display`] impl on the same
2189/// primitive — both delegate to the shared
2190/// [`UpgradeInstruction::as_str`] `pub const fn` accessor, so
2191/// `format!("{v}")`, `v.as_str()`, and
2192/// `<UpgradeInstruction as AsRef<str>>::as_ref(&v)` resolve to the
2193/// same byte-string per instance by construction. A future variant
2194/// rename or `#[serde(rename_all = "…")]` attribute-drift on the enum
2195/// reaches every one of the three paths (plus the wire-format
2196/// `Serialize` derive that already routes through the same kebab
2197/// vocabulary and the [`gen_platform::Discriminant`]-derived
2198/// [`Self::discriminant`] catalog identity) through exactly one
2199/// caixa-core edit — the [`Self::as_str`] match arms.
2200///
2201/// Same "route the trait impl through the substrate-primitive
2202/// accessor" discipline the sibling
2203/// [`crate::supervisor::RestartStrategy`] [`AsRef<str>`] impl
2204/// (63eb1a4), [`crate::supervisor::RestartPolicy`] [`AsRef<str>`]
2205/// impl (419ea81), [`crate::aplicacao::PlacementStrategy`]
2206/// [`AsRef<str>`] impl (d86edd2), [`crate::CaixaKind`]
2207/// [`AsRef<str>`] impl (cd2091f), [`crate::aplicacao::RateLimitUnit`]
2208/// [`AsRef<str>`] impl (d8136db), and [`crate::CaixaVersion`]
2209/// [`AsRef<str>`] impl (16d5c7e) carry — closes the substrate
2210/// primitive's [`AsRef<str>`] projection axis onto the last M2
2211/// OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2212/// surface, so every closed-set typed enum on the caixa typed
2213/// surface now carries the paired [`AsRef<str>`] +
2214/// [`fmt::Display`] + `as_str` triple.
2215///
2216/// Pinned load-bearing by
2217/// [`tests::upgrade_instruction_as_ref_str_routes_through_as_str_accessor`]
2218/// — any future silent detour that routes the impl through a
2219/// divergent projection (a per-arm inline `match self { … }`
2220/// re-inlining that opens a compile-time link to the un-lifted arm-
2221/// literal, a swap onto the [`Self::lisp_form`] tatara-lisp axis
2222/// that would collide the wire axis with the author-surface axis)
2223/// trips at caixa-core test time under `assert_eq!` rather than at a
2224/// downstream `impl AsRef<str>`-bound consumer's silent split.
2225impl AsRef<str> for UpgradeInstruction {
2226    fn as_ref(&self) -> &str {
2227        self.as_str()
2228    }
2229}
2230
2231/// Reject upgrade instruction `:module` values that aren't K8s
2232/// DNS-1123 labels. Thin wrapper around
2233/// [`crate::render::is_dns_1123_label`] that maps the shared
2234/// parser-shaped reason into the kind-tagged
2235/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
2236/// diagnostics, so the author can grep their caixa.lisp for the
2237/// offending `(:<kind> <module>)` form and fix it in one edit.
2238///
2239/// The contract — the same DNS-1123 label rule the K8s apiserver
2240/// enforces on every `metadata.name` / Service name / label value the
2241/// module name lands in. Each upgrade instruction's `:module` is a
2242/// reference to a caixa name (the wasm-engine resolves it through the
2243/// same `ComputeUnit` registry the operator manages), so the value must
2244/// match every downstream apiserver-side schema: the per-Servico
2245/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
2246/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
2247/// against the loaded-module table at hot-upgrade dispatch, and the
2248/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
2249/// per-module reference axis. Same trajectory as `:children :caixa`
2250/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
2251/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
2252/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
2253///
2254/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
2255/// variant before this predicate is consulted, mirroring
2256/// `validate_membro_caixa`'s empty-first cascade.
2257fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
2258    // Routes through the shared
2259    // [`crate::render::require_valid_dns_1123_label`] gate the peer
2260    // name axes each land on. The `kind: &'static str` field flows
2261    // through both error variants so the diagnostic names which
2262    // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
2263    // offending value came from.
2264    crate::render::require_valid_dns_1123_label(
2265        module,
2266        || UpgradeError::module_empty(kind),
2267        |reason| UpgradeError::module_invalid(kind, module, reason),
2268    )
2269}
2270
2271#[derive(Debug, Error, PartialEq, Eq)]
2272pub enum UpgradeError {
2273    #[error(
2274        ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
2275         consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
2276         `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
2277         every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
2278         the running version through `semver::Version::parse` and matches it against each entry's \
2279         `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
2280         SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2281         git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2282         requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2283    )]
2284    FromInvalid { from: String, reason: String },
2285    #[error(
2286        "upgrade instruction `{kind}` :module is empty (every appup module reference \
2287         must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2288         the instruction entirely)"
2289    )]
2290    ModuleEmpty { kind: &'static str },
2291    #[error(
2292        "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2293         {reason} (every appup module reference resolves to a caixa name, which lands \
2294         verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2295         creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2296         dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2297         axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2298         `\"cache-v2\"`)"
2299    )]
2300    ModuleInvalid {
2301        kind: &'static str,
2302        module: String,
2303        reason: String,
2304    },
2305    #[error("instruction's :script is empty")]
2306    EmptyScript,
2307    #[error(
2308        "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2309         root (Path::join would otherwise escape the project sandbox)",
2310        script.display()
2311    )]
2312    AbsoluteScript { script: PathBuf },
2313    #[error(
2314        "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2315         above the caixa root",
2316        script.display()
2317    )]
2318    ParentEscapeScript { script: PathBuf },
2319    #[error(
2320        ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2321         wasm-engine instantiator reads every migration script as tatara-lisp source through \
2322         `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2323         peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2324         other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2325         parser error far from the source caixa.lisp, with no field naming the offending \
2326         `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2327         terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2328         `\"lib/migrations/v01-to-v02.lisp\"`).",
2329        script.display()
2330    )]
2331    NonLispExtensionScript { script: PathBuf },
2332    #[error(
2333        ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2334         one matching block per running version (`release_handler:install_release/1` dispatches \
2335         on the loaded `:from` against the currently-running release), so two entries with the \
2336         same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2337         pick either set non-deterministically). Author one path per prior version; if two \
2338         distinct instruction sequences are needed, fold them into one ordered list under the \
2339         single matching `(:from {from:?} :instructions (…))` block."
2340    )]
2341    DuplicateFrom { from: String },
2342    #[error(
2343        ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2344         `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2345         greater than or equal to the caixa's own version is structurally unreachable \
2346         (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2347         the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2348         is never reached because the operator never runs a version greater than or equal to \
2349         the current one that it could then upgrade *to* the current one). Bump the caixa's \
2350         `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2351         *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2352         reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2353         version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2354         than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2355         values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2356         here as a self-upgrade no-op."
2357    )]
2358    FromNotBeforeVersao { from: String, versao: String },
2359    #[error(
2360        ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2361         exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2362         `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2363         instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2364         `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2365         component-model world incompatibility, irreversible state shape change), and the \
2366         fallback is terminal by construction (the operator restarts the pod and the new \
2367         version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2368         in both directions: if the typed instructions would succeed, `(:restart)` is \
2369         unreached; if they wouldn't, the typed instructions are dead because the operator \
2370         restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2371         (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2372         never repeated. If two distinct upgrade strategies are needed for the same prior \
2373         version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2374         dispatch picks exactly one block per running version) — keep the typed sequence; \
2375         the fallback restart is what the operator does on any typed-sequence failure \
2376         already."
2377    )]
2378    RestartNotExclusive {
2379        from: String,
2380        restart_count: usize,
2381        other_kinds: Vec<&'static str>,
2382    },
2383    #[error(
2384        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2385         `(:load-module …)` in its :instructions list — a state migration is the \
2386         gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2387         code, but the operator executes instructions in declared order, so this migration \
2388         runs while the only resident version is still the prior one (which expects the \
2389         pre-migration state shape). Load the new module first: author the canonical \
2390         `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2391         resident before its state migration runs.",
2392        script.display(),
2393        script.display()
2394    )]
2395    StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2396    #[error(
2397        ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2398         `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2399         code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2400         resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2401         then `code:soft_purge/1`), but the operator executes instructions in declared \
2402         order, so this cleanup runs while the only resident version is still the same \
2403         old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2404         mid-request), leaving no replacement to route in-flight or future requests \
2405         to. Load the new module first: author the canonical `(:load-module …) \
2406         (:state-change …) ({kind} {module:?})` order so the new code is resident \
2407         before the old code is drained or discarded."
2408    )]
2409    PurgeWithoutPriorLoad {
2410        from: String,
2411        kind: &'static str,
2412        module: String,
2413    },
2414    #[error(
2415        ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2416         more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2417         code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2418         wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2419         if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2420         them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2421         both, never repeated. systools-generated `.relup` files emit at most one purge per \
2422         module for this reason. A second cleanup on the same module is at best redundant (the \
2423         module is already gone after the first cleanup, so the second is a no-op or undefined \
2424         depending on the operator's handling of a non-resident-module purge request) and at \
2425         worst incoherent (mixing drain and discard semantics on one module suggests the author \
2426         wanted a fallback, but the operator runs declared instructions unconditionally — \
2427         fallback on cleanup failure is the operator's job, not authored into the entry). \
2428         Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2429         callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2430         can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2431         cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2432    )]
2433    DuplicateCleanup {
2434        from: String,
2435        module: String,
2436        kinds: Vec<&'static str>,
2437    },
2438    #[error(
2439        ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2440         once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2441         `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2442         'old'.\"), and the instruction binds the named wasm component once: the operator's \
2443         dispatch table reads the module name and brings up the corresponding component \
2444         alongside the running version. systools-generated `.relup` files emit at most one \
2445         `load_module` per module per upgrade step for this reason. A second `(:load-module \
2446         {module:?})` instruction has no observable semantic relative to the first (the \
2447         component is already resident) — either dead code (copy-pasted load line) or a typo \
2448         masking a distinct module the author intended to load alongside (renamed both to \
2449         {module:?} by mistake), leaving the second module silently absent from the entry. \
2450         Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2451         versions need loading alongside the running one, name them distinctly (e.g. \
2452         `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2453    )]
2454    DuplicateLoadModule { from: String, module: String },
2455    #[error(
2456        ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2457         once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2458         \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2459         state shape into the current-version shape: a one-shot transition, not a step that \
2460         composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2461         per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2462         callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2463         the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2464         author intended two distinct migration scripts) and at worst silent state corruption \
2465         (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2466         counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2467         Author one `(:state-change {})` per migration script per entry; if two distinct state \
2468         transitions are needed (e.g. one module's schema *and* another module's projection), \
2469         name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2470        script.display(),
2471        script.display(),
2472        script.display(),
2473        script.display()
2474    )]
2475    DuplicateStateChange { from: String, script: PathBuf },
2476    #[error(
2477        ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2478         {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2479         gen_server:code_change/3 analog and folds the prior-version state shape into the \
2480         current shape, but the prior version's state only exists while the prior code is \
2481         still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2482         analogs and drain or discard that prior code. The operator executes instructions in \
2483         declared order, so a cleanup ahead of a state-change has already drained the prior \
2484         module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2485         the migration script runs, leaving the script either no-op (no prior-version state \
2486         left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2487         canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2488         `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2489         {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2490         strictly between load and cleanup. Author the canonical `(:load-module …) \
2491         (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2492         migration runs against the prior-version state before the cleanup drains it.",
2493        script.display(),
2494        script.display()
2495    )]
2496    StateChangeAfterCleanup {
2497        from: String,
2498        script: PathBuf,
2499        prior_cleanup_kind: &'static str,
2500        prior_cleanup_module: String,
2501    },
2502    #[error(
2503        ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2504         declare `:behavior :on-state-change` — the per-version migration script is the \
2505         gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2506         hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2507         realizes the composition by invoking the running gen_server's code_change/3 callback \
2508         during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2509         composition into two typed slots, the per-version migration logic in this \
2510         `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2511         `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2512         verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2513         migration during hot upgrades\"). The missing callback leaves the per-version script \
2514         with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2515         callback at the migration step, finds it absent, and either fails the upgrade \
2516         mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2517         current version stays load-bearing\") or silently skips the migration leaving the \
2518         new code running against unmigrated prior-version state. Add the callback: \
2519         `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2520         path) alongside the existing `(:state-change {})` instruction (the per-version \
2521         script). If the upgrade truly carries no state migration, drop the `(:state-change \
2522         …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2523         migration — is the canonical shape).",
2524        script.display(),
2525        script.display()
2526    )]
2527    StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2528}
2529
2530// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2531// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2532// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2533// two-slot struct-variant wire-up sites at
2534// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2535// / `script` from `instr.declared_path()`),
2536// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2537// (`self.prior_versao()` / `script.as_path()` from
2538// `instr.declared_path()`), and
2539// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2540// / `script` from `instr.declared_path()`) onto one substrate primitive
2541// per typed variant — the paired `{ from: String, script: PathBuf }`
2542// two-slot sibling on [`UpgradeError`] of the peer
2543// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2544// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2545// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2546// (8580068, 4 variants on `{ de, para }`),
2547// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2548// `{ de, para, wit, expected }`),
2549// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2550// variants on `{ <field>: String, reason: String }`), and
2551// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2552// variants on `{ de, para, <field>: String, reason: String }`) on the
2553// sibling `AplicacaoError` envelopes, and the peer
2554// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2555// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2556// (0419438, 4 variants on `{ caixa, kind, slots }`),
2557// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2558// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2559// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2560// `LayoutError` envelopes, plus the peer
2561// [`crate::limits::limits_codec_value_only_ctors!`] /
2562// [`crate::limits::limits_codec_value_byte_ctors!`] /
2563// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2564// wire-ups) on the sibling `LimitsError` envelopes.
2565//
2566// Each of the three wire-up sites on this shape opens the identical
2567// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2568// script: <script>.to_path_buf() }` struct-literal against a local
2569// `prior_versao()` and `declared_path()` accessor pair — the exact
2570// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2571// names as a bug, on the same altitude the peer `SupervisorError` /
2572// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2573// closed on their sibling envelopes. The three variants share one
2574// `{ from: String, script: PathBuf }` shape, so the fold routes each
2575// wire-up site through one dispatch per typed variant.
2576//
2577// The macro below generates one `#[must_use]` inherent constructor per
2578// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2579// Self`, so every wire-up site collapses onto one dispatch:
2580// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2581// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2582// uniform two-field construction (`from.to_string()` /
2583// `script.to_path_buf()`) is spelled once — inside the macro — rather
2584// than at every wire-up site. The `&Path` parameter accepts both
2585// `&Path` (from `script.as_path()` at the uniqueness gate) and
2586// `&PathBuf` (from `instr.declared_path()` at the ordering /
2587// callback-declaration gates, via Deref coercion), so every existing
2588// wire-up threads through the ctor without a pre-conversion.
2589//
2590// Every future consumer that wants to construct one of these three
2591// variants outside the three in-crate `UpgradeFromEntry` /
2592// `validate_state_change_on_state_change_callback` gates (a deferred
2593// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2594// re-checker at hot-upgrade dispatch time, a future
2595// `feira validate --upgrade-from` per-caixa admission verb re-checking
2596// the three axes, a per-`Caixa` overlay resolver rejecting an
2597// ordering / uniqueness / callback-declaration invariant against a
2598// cluster-local snapshot) now reaches each variant through one call
2599// rather than re-inlining the three-line struct-literal in lockstep
2600// with the three in-crate wire-up sites.
2601macro_rules! upgrade_from_script_ctors {
2602    ($($ctor:ident => $variant:ident),* $(,)?) => {
2603        impl UpgradeError {
2604            $(
2605                #[doc = concat!(
2606                    "Construct an [`UpgradeError::",
2607                    stringify!($variant),
2608                    "`] naming the offending `(:from <prior-versao>)` and ",
2609                    "`(:state-change <script>)` pair. Folds the uniform ",
2610                    "`Self::",
2611                    stringify!($variant),
2612                    " { from: from.to_string(), script: script.to_path_buf() }` ",
2613                    "two-field struct-literal onto one substrate primitive so ",
2614                    "every wire-up on this variant reads through one dispatch ",
2615                    "rather than the pre-lift three-line open-coded block. The ",
2616                    "`from` string threads verbatim from ",
2617                    "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2618                    "from [`UpgradeInstruction::declared_path`] at the call site."
2619                )]
2620                #[must_use]
2621                pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2622                    Self::$variant {
2623                        from: from.to_string(),
2624                        script: script.to_path_buf(),
2625                    }
2626                }
2627            )*
2628        }
2629    };
2630}
2631
2632upgrade_from_script_ctors! {
2633    state_change_without_prior_load => StateChangeWithoutPriorLoad,
2634    duplicate_state_change => DuplicateStateChange,
2635    state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2636}
2637
2638// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2639// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2640// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2641// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2642// onto one substrate primitive per typed variant — the paired
2643// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2644// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2645// `{ from: String, script: PathBuf }`) two-slot family on the same
2646// envelope, and of the peer
2647// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2648// variants on `{ caixa: String }`) and
2649// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2650// `{ nome: String }`) single-slot families on the sibling
2651// `SupervisorError` / `DepError` envelopes, and of the peer
2652// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2653// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2654// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2655// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2656// variants on `{ <field>: String, reason: String }`), and
2657// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2658// variants on `{ de, para, <field>: String, reason: String }`) on the
2659// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2660// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2661// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2662// [`crate::LayoutError::missing_entry`] 1b09f9d;
2663// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2664// `LimitsError` codec families (81c856c), and the sibling
2665// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2666// `{ nome, caminho }`) two-slot family.
2667//
2668// The three wire-up sites this fold closes are the three closures
2669// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2670// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2671// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2672// passed to [`crate::render::require_sandboxed_lisp_path`] at
2673// [`UpgradeInstruction::validate`] — each opens the identical
2674// `UpgradeError::<Variant> { script: script.clone() }` three-line
2675// struct-literal against the same `script: &PathBuf` local threaded
2676// from [`UpgradeInstruction::declared_path`], the exact "same block
2677// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2678// bug. The three variants share one `{ script: PathBuf }` shape, so
2679// the fold routes each closure through one dispatch per typed variant.
2680// The sibling `EmptyScript` unit-variant on the same envelope stays on
2681// its pre-lift open-coded shape — it carries no `script` field (the
2682// offending `:script` value *is* the empty path this variant catches),
2683// so the uniform `fn(script: &Path) -> Self` signature this macro
2684// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2685// closure is already a one-liner. This is the second fold family on
2686// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2687// two-slot family established in 8e67041, which explicitly named this
2688// `{ script: PathBuf }` single-slot family as the next fold to land
2689// on the envelope; per that commit's coverage roster, both of the two
2690// most-populated shapes on `UpgradeError` — the two-slot
2691// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2692//
2693// The macro below generates one `#[must_use]` inherent constructor per
2694// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2695// every closure collapses onto one dispatch:
2696// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2697// struct-literal on the same `&Path` fixture. The uniform one-field
2698// construction (`script.to_path_buf()`) is spelled once — inside the
2699// macro — rather than at every wire-up site. The `&Path` parameter
2700// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2701// `instr.declared_path()` at the three closures, via Deref coercion),
2702// so every existing closure threads through the ctor without a
2703// pre-conversion.
2704//
2705// Every future consumer that wants to construct one of these three
2706// variants outside the three in-crate closures (a deferred
2707// wasm-operator's `install_release/1` per-instruction script-shape
2708// re-checker at hot-upgrade dispatch time, a future
2709// `feira validate --upgrade-from` per-caixa admission verb re-checking
2710// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2711// an author-supplied `:state-change :script` against a cluster-local
2712// snapshot) now reaches each variant through one call rather than
2713// re-inlining the three-line struct-literal in lockstep with the three
2714// in-crate closure sites.
2715macro_rules! upgrade_script_only_ctors {
2716    ($($ctor:ident => $variant:ident),* $(,)?) => {
2717        impl UpgradeError {
2718            $(
2719                #[doc = concat!(
2720                    "Construct an [`UpgradeError::",
2721                    stringify!($variant),
2722                    "`] naming the offending `(:state-change <script>)`. ",
2723                    "Folds the uniform `Self::",
2724                    stringify!($variant),
2725                    " { script: script.to_path_buf() }` one-field ",
2726                    "struct-literal onto one substrate primitive so every ",
2727                    "closure passed to ",
2728                    "[`crate::render::require_sandboxed_lisp_path`] at ",
2729                    "[`UpgradeInstruction::validate`] on this variant reads ",
2730                    "through one dispatch rather than the pre-lift three-line ",
2731                    "open-coded block. The `script` path threads verbatim ",
2732                    "from [`UpgradeInstruction::declared_path`] at the call ",
2733                    "site."
2734                )]
2735                #[must_use]
2736                pub fn $ctor(script: &std::path::Path) -> Self {
2737                    Self::$variant {
2738                        script: script.to_path_buf(),
2739                    }
2740                }
2741            )*
2742        }
2743    };
2744}
2745
2746upgrade_script_only_ctors! {
2747    absolute_script => AbsoluteScript,
2748    parent_escape_script => ParentEscapeScript,
2749    non_lisp_extension_script => NonLispExtensionScript,
2750}
2751
2752// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2753// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2754// <value>.to_string() }` two-slot struct-variant wire-up sites at
2755// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2756// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2757// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2758// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2759// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2760// self.prior_versao().to_string(), module: module.to_string() });`),
2761// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2762// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2763// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2764// });`) onto one substrate-primitive family per typed variant — the
2765// missing paired two-slot rung on the `UpgradeError`-side four-family
2766// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2767// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2768// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2769// script: PathBuf }`), and mirror-symmetric sibling of the peer
2770// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2771// String, <axis>: String }` fold on the `DepError` envelope — same
2772// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2773// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2774// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2775// carries the offending prior-version `:from` verbatim so the author
2776// can grep their caixa.lisp for the offending `(:from "<value>")` /
2777// `(:load-module …)` / `:versao` block in one edit). The three
2778// variants share the same `{ from: String, <axis>: String }` two-slot
2779// shape: the `from` field names the offending per-`:upgrade-from` block's
2780// prior-version tag the diagnostic points the author back at, and the
2781// middle `<axis>: String` field carries the offending per-envelope axis
2782// value verbatim (`reason` on `FromInvalid` carries the wrapped
2783// `semver::Version::parse` error message that pinpoints why the tag
2784// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2785// own current-`:versao` the entry's `:from` failed to precede; `module`
2786// on `DuplicateLoadModule` carries the caixa name the second
2787// `(:load-module …)` instruction re-loaded within the same entry).
2788// The middle axis-field name differs across variants (`reason` /
2789// `versao` / `module`) so the ctor family below takes the axis field
2790// name as a macro parameter (`$axis:ident`) alongside the ctor +
2791// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2792// -> Self` inherent constructor per typed variant that spells the
2793// uniform two-field construction (`from.to_string()` /
2794// `<axis>.to_string()`) exactly once.
2795//
2796// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2797// variants on `{ from: String, script: PathBuf }`) two-slot family on
2798// the same envelope — both key off the same `from: String` axis at the
2799// same per-`:upgrade-from :from`-owned altitude; this family carries the
2800// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2801// carrier) where the script-slot family carries the owned-`PathBuf`
2802// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2803// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2804// same envelope, of the sibling
2805// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2806// variants on `{ caixa: String }`) and
2807// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2808// `{ nome: String }`) single-slot families on the sibling
2809// `SupervisorError` / `DepError` envelopes, and of the peer
2810// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2811// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2812// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2813// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2814// variants on `{ <field>: String, reason: String }`),
2815// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2816// variants on `{ caixa: String }`),
2817// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2818// on `{ path: String }`), and
2819// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2820// variants on `{ de, para, <field>: String, reason: String }`) on the
2821// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2822// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2823// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2824// [`crate::LayoutError::missing_entry`] 1b09f9d;
2825// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2826// `LimitsError` codec families (81c856c), the sibling
2827// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2828// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2829// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2830// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2831// `{ nome, list: &'static str }`), and
2832// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2833// `{ nome, <axis>: String, reason: String }`) families.
2834//
2835// Each of the three wire-up sites on this shape opens the identical
2836// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2837// <value>.to_string() }` four-line struct-literal against a local
2838// `(prior_versao(), <axis-value>)` pair threaded from
2839// [`UpgradeFromEntry::prior_versao`] (or, at the
2840// [`validate_upgrade_from_against_versao`] site, directly from the
2841// caller-supplied `versao: &str` argument) — the exact "same block
2842// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2843// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2844// / `upgrade_script_only_ctors!` families closed on the sibling
2845// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2846// axis-field discriminators are the only things that vary between them;
2847// the rest of the struct-literal is a byte-for-byte re-inline.
2848//
2849// The macro below generates one `#[must_use]` inherent constructor per
2850// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2851// every wire-up site collapses onto one dispatch:
2852// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2853// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2854// parameters accept `&str` literals and `&String` (via Deref coercion)
2855// so every existing wire-up threads through the ctor without a
2856// pre-conversion.
2857//
2858// Every future consumer that wants to construct one of these three
2859// variants outside the three in-crate `UpgradeFromEntry::validate` /
2860// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2861// gates (a deferred wasm-operator's `install_release/1` per-entry
2862// `:from`-parse / per-`:load-module` singularity / per-entry
2863// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2864// `feira validate --upgrade-from` per-caixa admission verb re-checking
2865// the three axes, a per-`Caixa` overlay resolver rejecting a
2866// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2867// invariant against a cluster-local snapshot) now reaches each variant
2868// through one call rather than re-inlining the four-line struct-literal
2869// in lockstep with the three in-crate wire-up sites.
2870macro_rules! upgrade_from_axis_ctors {
2871    ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2872        impl UpgradeError {
2873            $(
2874                #[doc = concat!(
2875                    "Construct an [`UpgradeError::",
2876                    stringify!($variant),
2877                    "`] naming the offending `(:from <prior-versao>)` and ",
2878                    "the offending `:", stringify!($axis), "` axis value. ",
2879                    "Folds the uniform `Self::",
2880                    stringify!($variant),
2881                    " { from: from.to_string(), ",
2882                    stringify!($axis),
2883                    ": ",
2884                    stringify!($axis),
2885                    ".to_string() }` two-field struct-literal onto one ",
2886                    "substrate primitive so every in-crate wire-up on ",
2887                    "this variant reads through one dispatch rather than ",
2888                    "the pre-lift four-line open-coded block. Both `from: ",
2889                    "&str` and `",
2890                    stringify!($axis),
2891                    ": &str` parameters accept `&str` literals and ",
2892                    "`&String` (via Deref coercion) so every existing ",
2893                    "wire-up threads through the ctor without a pre-",
2894                    "conversion."
2895                )]
2896                #[must_use]
2897                pub fn $ctor(from: &str, $axis: &str) -> Self {
2898                    Self::$variant {
2899                        from: from.to_string(),
2900                        $axis: $axis.to_string(),
2901                    }
2902                }
2903            )*
2904        }
2905    };
2906}
2907
2908upgrade_from_axis_ctors! {
2909    from_invalid => FromInvalid { reason },
2910    from_not_before_versao => FromNotBeforeVersao { versao },
2911    duplicate_load_module => DuplicateLoadModule { module },
2912}
2913
2914// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2915// entry.prior_versao().to_string() }` one-slot struct-literal inside
2916// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2917// one substrate primitive on the [`UpgradeError`] envelope, projecting
2918// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2919// on the substrate primitive. The `DuplicateFrom` variant is the last
2920// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2921// every peer envelope shape (`{ script: PathBuf }` one-slot via
2922// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2923// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2924// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2925// 8e67041) already reads through one substrate-primitive dispatch, so
2926// this fold closes the last one-off single-slot on the envelope.
2927//
2928// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2929// (b30edfe) on the paired [`WitContract`] projection — same
2930// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2931// through the substrate primitive's own scalar accessor rather than
2932// re-inlining the `.to_string()` at the call site. Extended here onto
2933// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2934// M2 companion of the M3 mesh-slot accessors (see
2935// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2936// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2937// 4a32abf, and the [`crate::WitContract::{source, destination,
2938// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2939// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2940//
2941// The one wire-up site this fold closes opens the identical
2942// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2943// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2944// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2945// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2946// names as a bug, on the same altitude the peer `contrato_self_loop`
2947// closed on the sibling `{ caixa: String, wit: String }` two-slot
2948// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2949// parameter accepts the borrowed entry verbatim so the wire-up site
2950// threads through the ctor without a pre-projection — the ctor body
2951// spells the paired `prior_versao().to_string()` projection once.
2952//
2953// Every future consumer that wants to construct this variant outside
2954// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2955// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2956// re-checker at hot-upgrade dispatch time rejecting a second entry
2957// with the same prior-versao tag, a future `feira validate --upgrade-
2958// from` per-caixa admission verb re-running the cross-entry duplicate
2959// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2960// supplied duplicate `(:from "<value>")` against a cluster-local
2961// snapshot — now reaches the variant through one call rather than
2962// re-inlining the three-line struct-literal in lockstep with the one
2963// in-crate wire-up site.
2964impl UpgradeError {
2965    /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2966    /// duplicate `(:from <prior-versao>)` entry, projecting through the
2967    /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2968    /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2969    /// from: entry.prior_versao().to_string() }` one-field struct-literal
2970    /// onto one substrate primitive so every wire-up on this variant
2971    /// reads through one dispatch, matching the sibling
2972    /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2973    /// substrate-primitive-projection ctor's shape on the peer
2974    /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2975    /// parameter accepts the borrowed entry verbatim so the paired
2976    /// `prior_versao().to_string()` projection is spelled once — inside
2977    /// the ctor body — rather than at every wire-up site.
2978    #[must_use]
2979    pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2980        Self::DuplicateFrom {
2981            from: entry.prior_versao().to_string(),
2982        }
2983    }
2984
2985    /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2986    /// offending `(:from <prior-versao>)` entry, the offending cleanup
2987    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2988    /// its `:module` target. Folds the uniform
2989    /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2990    /// module: module.to_string() }` three-field struct-literal onto one
2991    /// substrate primitive so every wire-up on this sole-variant
2992    /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2993    /// through one dispatch rather than the pre-lift seven-line
2994    /// open-coded block.
2995    ///
2996    /// The `from: &str` parameter accepts `&str` literals and `&String`
2997    /// via Deref coercion so the sole in-crate wire-up site threads
2998    /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2999    /// pre-conversion. The `kind: &'static str` parameter accepts the
3000    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3001    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3002    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3003    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3004    /// re-projection at the ctor path. The `module: &str` parameter
3005    /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
3006    /// via `.expect("is_cleanup() implies declared_module() is Some")`
3007    /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
3008    /// `Some` composition pin at
3009    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3010    /// makes the `.expect(…)` structurally infallible at build time.
3011    ///
3012    /// Peer of the sibling one-off standalone-ctor
3013    /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
3014    /// String }` envelope on the same `UpgradeError` envelope, and of
3015    /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
3016    /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
3017    /// variant standalone ctor on the peer `AplicacaoError` envelope.
3018    /// Closes the last unlifted `{ from: String, kind: &'static str,
3019    /// module: String }` three-slot open-coded struct-literal wire-up
3020    /// on the OTP-appup load-before-cleanup ordering axis, sibling of
3021    /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
3022    /// (41d08db, three variants on `{ from: String, <axis>: String }`)
3023    /// on the paired ordering / uniqueness / callback-declaration axes,
3024    /// and of the peer standalone [`UpgradeError::duplicate_from`]
3025    /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
3026    /// `:from` gate. Every future consumer that raises this refusal
3027    /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
3028    /// wasm-operator's `install_release/1` per-entry load-before-cleanup
3029    /// re-checker at hot-upgrade dispatch time, a future
3030    /// `feira validate --upgrade-from` per-caixa admission verb
3031    /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
3032    /// overlay resolver rejecting a cluster-local `:soft-purge` /
3033    /// `:purge` overlay lacking a preceding `:load-module` — reaches
3034    /// the variant through one call rather than re-inlining the
3035    /// seven-line struct-literal in lockstep with the sole in-crate
3036    /// wire-up site.
3037    #[must_use]
3038    pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
3039        Self::PurgeWithoutPriorLoad {
3040            from: from.to_string(),
3041            kind,
3042            module: module.to_string(),
3043        }
3044    }
3045
3046    /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
3047    /// offending `(:from <prior-versao>)` entry, the offending
3048    /// `(:state-change …)` `:script` path, and the prior cleanup
3049    /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
3050    /// `:module` target. Folds the uniform
3051    /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
3052    /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
3053    /// prior_cleanup_module.to_string() }` four-field struct-literal
3054    /// onto one substrate primitive so every wire-up on this sole-
3055    /// variant migrate-after-cleanup ordering-refusal envelope reads
3056    /// through one dispatch rather than the pre-lift seven-line open-
3057    /// coded block. Closes the last unlifted `{ from: String, script:
3058    /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
3059    /// String }` four-slot open-coded struct-literal wire-up on the
3060    /// OTP-appup migrate-before-cleanup ordering axis, filling the
3061    /// missing four-slot rung on the `UpgradeError`-side ctor-family
3062    /// ladder alongside the sibling one-slot
3063    /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
3064    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3065    /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
3066    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
3067    /// families, and the one-slot [`upgrade_script_only_ctors!`]
3068    /// (7468ca9) family. Sole in-crate wire-up site is inside
3069    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
3070    /// migrate-family sticky-latch dispatch — the third of three
3071    /// within-entry cross-instruction OTP-appup ordering gates the
3072    /// module doc pins (`validate_state_change_ordering` on the load →
3073    /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
3074    /// `state_change_without_prior_load`; `validate_purge_ordering` on
3075    /// the load → cleanup boundary via `purge_without_prior_load`;
3076    /// `validate_state_change_before_cleanup` on the migrate → cleanup
3077    /// boundary via this ctor — now).
3078    ///
3079    /// The `from: &str` parameter accepts `&str` literals and `&String`
3080    /// via Deref coercion so the sole in-crate wire-up site threads
3081    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3082    /// without a pre-conversion. The `script: &std::path::Path`
3083    /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
3084    /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
3085    /// via Deref coercion) so the wire-up threads the sticky-latch
3086    /// script projection through the ctor without a pre-conversion; the
3087    /// uniform `script.to_path_buf()` one-field construction is spelled
3088    /// once — inside the ctor body — rather than at every wire-up site.
3089    /// The `prior_cleanup_kind: &'static str` parameter accepts the
3090    /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3091    /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3092    /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3093    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3094    /// re-projection at the ctor path. The `prior_cleanup_module: &str`
3095    /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
3096    /// returns via `.expect("is_cleanup() implies declared_module() is
3097    /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
3098    /// is-`Some` composition pin at
3099    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3100    /// makes the `.expect(…)` structurally infallible at build time.
3101    ///
3102    /// Every future consumer that raises this refusal outside
3103    /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
3104    /// deferred wasm-operator's `install_release/1` per-entry
3105    /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
3106    /// a future `feira validate --upgrade-from` per-caixa admission verb
3107    /// re-running the migrate-before-cleanup gate on demand, a
3108    /// per-`Caixa` overlay resolver rejecting a cluster-local
3109    /// `:state-change` overlay authored after a `:soft-purge` /
3110    /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
3111    /// webhook re-checking a per-`:upgrade-from`-patched candidate
3112    /// before the migrate-before-cleanup gate re-fires — reaches the
3113    /// variant through one call rather than re-inlining the seven-line
3114    /// struct-literal in lockstep with the sole in-crate wire-up site.
3115    #[must_use]
3116    pub fn state_change_after_cleanup(
3117        from: &str,
3118        script: &std::path::Path,
3119        prior_cleanup_kind: &'static str,
3120        prior_cleanup_module: &str,
3121    ) -> Self {
3122        Self::StateChangeAfterCleanup {
3123            from: from.to_string(),
3124            script: script.to_path_buf(),
3125            prior_cleanup_kind,
3126            prior_cleanup_module: prior_cleanup_module.to_string(),
3127        }
3128    }
3129
3130    /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
3131    /// offending `(:from <prior-versao>)` entry, the colliding `:module`
3132    /// target, and the ordered pair of colliding cleanup `:kind` lisp-
3133    /// forms (`:soft-purge` / `:purge`). Folds the uniform
3134    /// `Self::DuplicateCleanup { from: from.to_string(), module:
3135    /// module.to_string(), kinds }` three-field struct-literal onto one
3136    /// substrate primitive so every wire-up on this sole-variant within-
3137    /// entry per-module cleanup-singularity refusal envelope reads
3138    /// through one dispatch rather than the pre-lift five-line open-coded
3139    /// block. Closes the last unlifted `{ from: String, module: String,
3140    /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
3141    /// wire-up on the OTP-appup per-module cleanup-singularity axis,
3142    /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
3143    /// family ladder alongside the sibling three-slot
3144    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3145    /// ctor on the paired within-entry load → cleanup ordering axis, the
3146    /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
3147    /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
3148    /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
3149    /// standalone ctor on the migrate → cleanup boundary, the two-slot
3150    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3151    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3152    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3153    /// Sole in-crate wire-up site is inside
3154    /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
3155    /// cleanup-family dedup arm.
3156    ///
3157    /// The `from: &str` parameter accepts `&str` literals and `&String`
3158    /// via Deref coercion so the sole in-crate wire-up threads
3159    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3160    /// without a pre-conversion. The `module: &str` parameter takes the
3161    /// `&str` [`UpgradeInstruction::declared_module`] returns via
3162    /// `.expect("is_cleanup() implies declared_module() is Some")` at the
3163    /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
3164    /// composition pin at
3165    /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3166    /// makes the `.expect(…)` structurally infallible at build time. The
3167    /// `kinds: Vec<&'static str>` parameter takes the ordered pair
3168    /// `vec![prior_kind, kind]` built at the caller from the two
3169    /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
3170    /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3171    /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
3172    /// primitive `&'static str` projection the paired three-slot
3173    /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
3174    /// sibling load → cleanup ordering axis.
3175    ///
3176    /// Every future consumer that raises this refusal outside
3177    /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
3178    /// wasm-operator's `install_release/1` per-entry per-module
3179    /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
3180    /// future `feira validate --upgrade-from` per-caixa admission verb
3181    /// re-running the singularity pass on demand, a per-`Caixa` overlay
3182    /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
3183    /// overlay that collides with a base-entry cleanup on the same
3184    /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3185    /// re-checking a per-`:upgrade-from`-patched candidate before the
3186    /// singularity gate re-fires — reaches the variant through one call
3187    /// rather than re-inlining the five-line struct-literal in lockstep
3188    /// with the sole in-crate wire-up site.
3189    #[must_use]
3190    pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
3191        Self::DuplicateCleanup {
3192            from: from.to_string(),
3193            module: module.to_string(),
3194            kinds,
3195        }
3196    }
3197
3198    /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
3199    /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
3200    /// instruction count, and the ordered list of non-`:restart`
3201    /// instruction lisp-forms the entry mixed with the terminal fallback.
3202    /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
3203    /// restart_count, other_kinds }` three-field struct-literal onto one
3204    /// substrate primitive so every wire-up on this sole-variant within-
3205    /// entry `(:restart)`-exclusivity refusal envelope reads through one
3206    /// dispatch rather than the pre-lift five-line open-coded block. Closes
3207    /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
3208    /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
3209    /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
3210    /// the last-remaining open-coded emission site the sibling
3211    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
3212    /// the natural next lift on the `UpgradeError` envelope. Fills a peer
3213    /// three-slot rung on the `UpgradeError`-side ctor-family ladder
3214    /// alongside the sibling three-slot
3215    /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3216    /// ctor on the paired within-entry load → cleanup ordering axis and
3217    /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
3218    /// the per-module cleanup-singularity axis, the one-slot
3219    /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
3220    /// cross-entry duplicate-`:from` gate, the four-slot
3221    /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
3222    /// ctor on the migrate → cleanup boundary, the two-slot
3223    /// [`upgrade_from_axis_ctors!`] (41d08db) /
3224    /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3225    /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3226    /// Sole in-crate wire-up site is inside
3227    /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
3228    /// arm.
3229    ///
3230    /// The `from: &str` parameter accepts `&str` literals and `&String`
3231    /// via Deref coercion so the sole in-crate wire-up threads
3232    /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3233    /// without a pre-conversion. The `restart_count: usize` parameter
3234    /// takes the observed `(:restart)` occurrence count built at the
3235    /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
3236    /// — the same `IsVariant`-derived arm-discriminator dispatch the
3237    /// paired `other_kinds` projection routes through — so the diagnostic
3238    /// surfaces the duplication mode unambiguously even when `other_kinds`
3239    /// is empty (the `((:restart) (:restart))` shape the sibling
3240    /// `validate_rejects_restart_duplicated` test pins with
3241    /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
3242    /// Vec<&'static str>` parameter takes the ordered list of non-
3243    /// `:restart` instruction lisp-forms built at the caller from
3244    /// `instructions.iter().filter(|i| !i.is_restart()).map(
3245    /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
3246    /// primitive `&'static str` projection the peer three-slot
3247    /// [`UpgradeError::purge_without_prior_load`] /
3248    /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
3249    /// within-entry cleanup axes.
3250    ///
3251    /// Every future consumer that raises this refusal outside
3252    /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
3253    /// wasm-operator's `install_release/1` per-entry `(:restart)`-
3254    /// exclusivity re-checker at hot-upgrade dispatch time, a future
3255    /// `feira validate --upgrade-from` per-caixa admission verb re-running
3256    /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
3257    /// rejecting a cluster-local `(:restart)` overlay that mixes with a
3258    /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
3259    /// CR admission webhook re-checking a per-`:upgrade-from`-patched
3260    /// candidate before the exclusivity gate re-fires — reaches the
3261    /// variant through one call rather than re-inlining the five-line
3262    /// struct-literal in lockstep with the sole in-crate wire-up site.
3263    #[must_use]
3264    pub fn restart_not_exclusive(
3265        from: &str,
3266        restart_count: usize,
3267        other_kinds: Vec<&'static str>,
3268    ) -> Self {
3269        Self::RestartNotExclusive {
3270            from: from.to_string(),
3271            restart_count,
3272            other_kinds,
3273        }
3274    }
3275
3276    /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
3277    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3278    /// `:purge`), the malformed `:module` value, and the parser-shaped
3279    /// `reason` from
3280    /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3281    /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3282    /// three-field struct-literal onto one substrate primitive so every
3283    /// wire-up on this variant reads through one dispatch rather than the
3284    /// pre-lift open-coded closure block inside [`validate_module`]'s
3285    /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3286    ///
3287    /// The `kind: &'static str` parameter accepts the lisp-form
3288    /// [`UpgradeInstruction::lisp_form`] returns for the three
3289    /// [`UpgradeInstruction::declared_module`]-bearing arms —
3290    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3291    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3292    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3293    /// without a per-arm re-projection at the ctor path. The `module: &str`
3294    /// parameter threads the offending author-supplied `:module` value
3295    /// verbatim from [`UpgradeInstruction::declared_module`]. The
3296    /// `reason: impl Into<String>` bound accepts both `&str` literals and
3297    /// the `String` [`crate::render::is_dns_1123_label`] returns via
3298    /// `.into()`, matching the peer
3299    /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3300    /// [`crate::SupervisorError::child_caixa_invalid`] /
3301    /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3302    /// three-slot invalid-arm ctor discipline on the sibling
3303    /// DNS-1123-label per-envelope shape.
3304    ///
3305    /// Peer of the sibling standalone-ctor
3306    /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3307    /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3308    /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3309    /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3310    /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3311    /// [`validate_module`]'s cascade now reads through one substrate
3312    /// primitive on the invalid-arm rather than an open-coded four-line
3313    /// struct-literal in lockstep with the sole in-crate wire-up site.
3314    ///
3315    /// Every future consumer that raises this refusal outside
3316    /// [`validate_module`] — a deferred wasm-operator's
3317    /// `install_release/1` per-instruction `:module` re-validator at
3318    /// hot-upgrade dispatch time re-running the same DNS-1123-label
3319    /// floor against a candidate module reference, a future
3320    /// `feira validate --upgrade-from` per-caixa admission verb
3321    /// re-running the module-shape gate on demand, an M4
3322    /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3323    /// per-`:upgrade-from`-patched candidate before the module-shape
3324    /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3325    /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3326    /// overlay against a cluster-local snapshot — now reaches this
3327    /// variant through one call rather than re-inlining the four-line
3328    /// struct-literal in lockstep with the [`validate_module`]
3329    /// closure-form wire-up.
3330    #[must_use]
3331    pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3332        Self::ModuleInvalid {
3333            kind,
3334            module: module.to_string(),
3335            reason: reason.into(),
3336        }
3337    }
3338
3339    /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3340    /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3341    /// `:purge`) at which the appup module reference is the empty
3342    /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3343    /// struct-literal onto one substrate primitive so the sole in-crate
3344    /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3345    /// at [`validate_module`] on this variant reads through one dispatch
3346    /// rather than the pre-lift open-coded block. The `kind` label
3347    /// threads verbatim from the caller-side
3348    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3349    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3350    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3351    /// roster the wire-up feeds through [`validate_module`]'s
3352    /// `kind: &'static str` parameter.
3353    ///
3354    /// Sibling of the paired three-slot [`Self::module_invalid`]
3355    /// (3d0d64a) substrate primitive on the same
3356    /// [`crate::render::require_valid_dns_1123_label`] two-closure
3357    /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3358    /// now both reach the `UpgradeError` envelope through one substrate
3359    /// primitive per typed variant, closing the pair on the OTP-appup
3360    /// per-instruction `:module` caixa-reference axis. Same shape
3361    /// discipline as the peer
3362    /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3363    /// one-slot `{ slot: &'static str }` sibling that closed the peer
3364    /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3365    /// cascade at the `:contratos <slot>` per-edge axis
3366    /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3367    /// "one substrate primitive per typed arm on both sides of a
3368    /// `require_valid_dns_1123_label` two-closure cascade, projecting
3369    /// through the caller-supplied axis-tag" discipline now extended
3370    /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3371    /// of the pair the M3 (`:contratos <slot>`) side already carries.
3372    ///
3373    /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3374    /// :instructions <kind>` tag comes from the
3375    /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3376    /// carrying program-lifetime storage, matching the enum-field type
3377    /// and the [`validate_module`] wire-up's per-arm dispatch. A
3378    /// runtime-borrowed `&str` would silently downgrade the label
3379    /// lifetime and let a caller stash a non-`'static` borrow into the
3380    /// returned error. `#[must_use]` fires a compile warning at any
3381    /// wire-up that mistakenly discards the constructed error rather
3382    /// than routing it through `return Err(…)` / `.map_err(…)` / a
3383    /// closure return. `pub const fn` matches the peer per-envelope
3384    /// one-slot `Copy`-scalar ctor family discipline
3385    /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3386    /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3387    /// ctor is usable in `const` position at every wire-up site.
3388    ///
3389    /// Every future consumer that constructs `ModuleEmpty` outside
3390    /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3391    /// closure — a deferred wasm-operator's `install_release/1`
3392    /// per-instruction `:module` re-validator at hot-upgrade dispatch
3393    /// time re-running the same empty-arm floor against a candidate
3394    /// module reference, a future `feira validate --upgrade-from`
3395    /// per-caixa admission verb re-running the empty-module gate on
3396    /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3397    /// re-checking a per-`:upgrade-from`-patched candidate before the
3398    /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3399    /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3400    /// overlay against a cluster-local snapshot — now reaches this
3401    /// variant through one call rather than re-inlining the one-line
3402    /// struct-literal in lockstep with the sole in-crate wire-up site.
3403    #[must_use]
3404    pub const fn module_empty(kind: &'static str) -> Self {
3405        Self::ModuleEmpty { kind }
3406    }
3407}
3408
3409#[cfg(test)]
3410mod tests {
3411    use std::path::Path;
3412
3413    use super::*;
3414
3415    fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3416        UpgradeFromEntry {
3417            from: from.into(),
3418            instructions: instrs,
3419        }
3420    }
3421
3422    #[test]
3423    fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3424        // Fail-before-pass-after pin on
3425        // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3426        // posture. The accessor projects the per-`:upgrade-from :from`
3427        // [`String`] storage through the `pub const fn`
3428        // [`String::as_str`] (const-stable since Rust 1.87, well within
3429        // the workspace MSRV) — any future accidental downgrade to
3430        // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3431        // build time with E0015 (`cannot call non-const method`),
3432        // strictly stronger than a runtime `assert!`. Sibling of the
3433        // peer M2/M3 slot family pins on the sibling `const`-eval-
3434        // surface passes ([`crate::Caixa::nome`] /
3435        // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3436        // [`crate::aplicacao::Membro::nome`] /
3437        // [`crate::aplicacao::Membro::versao_requirement`],
3438        // [`crate::aplicacao::Entrada::hostname`] /
3439        // [`crate::aplicacao::Entrada::destination`],
3440        // [`crate::supervisor::ChildSpec::nome`] /
3441        // [`crate::supervisor::ChildSpec::versao_requirement`],
3442        // [`crate::dep::Dep::nome`] /
3443        // [`crate::dep::Dep::versao_requirement`], and the
3444        // per-`:contratos`
3445        // [`crate::aplicacao::WitContract::source`] /
3446        // [`crate::aplicacao::WitContract::destination`] /
3447        // [`crate::aplicacao::WitContract::world_ref`] trio the
3448        // sibling pin at 279823b already anchors).
3449        const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3450            e.prior_versao()
3451        }
3452        for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3453            let e = entry(from, vec![]);
3454            assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3455            assert_eq!(e.prior_versao(), from);
3456        }
3457    }
3458
3459    #[test]
3460    fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3461        // Fail-before-pass-after pin on
3462        // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3463        // posture. The accessor destructures the per-`:upgrade-from
3464        // :instructions` `Vec<UpgradeInstruction>` storage through the
3465        // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3466        // 1.66, well within the workspace MSRV) — any future
3467        // accidental downgrade to non-`const` fails
3468        // `instructions_via_const_fn` at caixa-core build time with
3469        // E0015 (`cannot call non-const method`), strictly stronger
3470        // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3471        // slot `Vec → &[T]` slice-return accessor family pin
3472        // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3473        // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3474        // per-`:membros` / per-`:contratos` slice-return axes, and of
3475        // the peer M2 supervisor-tree axis pin
3476        // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3477        // on the per-`:children` slice-return axis.
3478        const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3479            e.instructions()
3480        }
3481        // Sweep both the empty-instructions arm (author-declared
3482        // per-`:from` entry with no migration steps — the degenerate
3483        // shape the appup `restart`-only path folds through) and the
3484        // populated-instructions arm (the canonical OTP-appup shape
3485        // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3486        // chain) so the accessor carries a const-dispatch pin on
3487        // both arms.
3488        let e_empty = entry("0.1.0", vec![]);
3489        assert!(instructions_via_const_fn(&e_empty).is_empty());
3490        assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3491        let e_full = entry(
3492            "0.1.0",
3493            vec![
3494                UpgradeInstruction::LoadModule {
3495                    module: "hello-rio".into(),
3496                },
3497                UpgradeInstruction::StateChange {
3498                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3499                },
3500                UpgradeInstruction::SoftPurge {
3501                    module: "hello-rio-old".into(),
3502                },
3503            ],
3504        );
3505        assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3506        assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3507    }
3508
3509    #[test]
3510    fn round_trip_load_module() {
3511        let i = UpgradeInstruction::LoadModule {
3512            module: "hello-rio".into(),
3513        };
3514        let json = serde_json::to_string(&i).unwrap();
3515        assert!(json.contains("\"kind\":\"load-module\""));
3516        let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3517        assert_eq!(i, back);
3518    }
3519
3520    #[test]
3521    fn round_trip_all_variants() {
3522        let cases = vec![
3523            UpgradeInstruction::LoadModule { module: "x".into() },
3524            UpgradeInstruction::StateChange {
3525                script: PathBuf::from("lib/migrations.lisp"),
3526            },
3527            UpgradeInstruction::SoftPurge {
3528                module: "x-old".into(),
3529            },
3530            UpgradeInstruction::Purge {
3531                module: "x-old".into(),
3532            },
3533            UpgradeInstruction::Restart,
3534        ];
3535        for c in cases {
3536            let json = serde_json::to_string(&c).unwrap();
3537            let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3538            assert_eq!(c, back);
3539        }
3540    }
3541
3542    #[test]
3543    fn validate_accepts_well_formed() {
3544        let e = entry(
3545            "0.1.0",
3546            vec![
3547                UpgradeInstruction::LoadModule {
3548                    module: "hello-rio".into(),
3549                },
3550                UpgradeInstruction::StateChange {
3551                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3552                },
3553                UpgradeInstruction::SoftPurge {
3554                    module: "hello-rio-old".into(),
3555                },
3556            ],
3557        );
3558        e.validate().unwrap();
3559    }
3560
3561    #[test]
3562    fn validate_rejects_non_semver_from() {
3563        let e = entry("not-a-semver", vec![]);
3564        let err = e.validate().unwrap_err();
3565        assert!(
3566            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3567        );
3568    }
3569
3570    #[test]
3571    fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3572        // Diagnostic-shape pin: the error names the offending
3573        // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3574        // reason, so a `feira lint` run can render the diagnostic
3575        // without re-parsing — the author can grep their caixa.lisp for
3576        // `:from "<value>"` and fix it in one edit. Mirrors the peer
3577        // `versao_invalid_diagnostic_carries_offending_versao` pin on
3578        // the sibling SemVer-2 axis (the top-level `:versao`), the
3579        // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3580        // pin on `:membros :versao`, and the peer
3581        // `deps_invalid_diagnostic_carries_offending_value` pin on
3582        // `:deps :versao` — every SemVer-2-parsing slot's invalid
3583        // diagnostic is now structurally equivalent.
3584        let e = entry("v0.1.0", vec![]);
3585        let err = e.validate().unwrap_err();
3586        let UpgradeError::FromInvalid { from, reason } = err else {
3587            panic!("expected FromInvalid variant, got {err:?}");
3588        };
3589        assert_eq!(from, "v0.1.0");
3590        assert!(
3591            !reason.is_empty(),
3592            "FromInvalid `reason` must carry the parser's wording verbatim"
3593        );
3594    }
3595
3596    #[test]
3597    fn prior_versao_returns_from_byte_equal_across_permutations() {
3598        // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3599        // accessor across the SemVer-2 shape lattice every consumer
3600        // reaches through it — the numeric-triad canonical shape, a
3601        // pre-release build with a dotted identifier chain, a full-
3602        // metadata build, a large-magnitude triad, and the empty
3603        // string (which reaches this accessor unchanged before any
3604        // validate gate rejects it). Sibling to the peer
3605        // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3606        // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3607        // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3608        // family — extended here onto the first M2 slot scalar-value
3609        // axis. Any silent detour on the accessor (a `.to_string()`
3610        // + retained ownership shape, a canonicalization pass, a
3611        // trim-whitespace on the return path) surfaces as a byte-
3612        // inequality failure here rather than as a downstream error-
3613        // diagnostic drift.
3614        let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3615        for from in cases {
3616            let e = entry(from, vec![]);
3617            assert_eq!(
3618                e.prior_versao(),
3619                from,
3620                "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3621            );
3622            assert_eq!(
3623                e.prior_versao().len(),
3624                from.len(),
3625                "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3626            );
3627        }
3628    }
3629
3630    #[test]
3631    fn prior_versao_borrows_from_from_storage() {
3632        // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3633        // a borrow into `self.from`'s heap allocation, never a fresh
3634        // owned copy. Guards against a future silent detour where
3635        // the accessor materializes a `Cow<'_, str>` / `String` /
3636        // `Rc<str>` intermediate — the return path stays zero-cost
3637        // even under a refactor that reshapes the storage. Sibling
3638        // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3639        // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3640        // (4a32abf) pins — extended onto the M2 slot's first
3641        // scalar-value axis.
3642        let e = entry("0.1.0", vec![]);
3643        assert!(
3644            std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3645            "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3646        );
3647    }
3648
3649    #[test]
3650    fn validate_parses_prior_versao_through_lifted_accessor() {
3651        // Coherence pin between the accessor and the SemVer-2 parse
3652        // gate: every `:upgrade-from :from` value the validator
3653        // accepts (resp. rejects) must be identical to what
3654        // `Version::parse(entry.prior_versao())` accepts (resp.
3655        // rejects) — the two must remain in lockstep across the
3656        // shape lattice so `validate_upgrade_from`'s
3657        // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3658        // assertion holds by construction. If a future extension of
3659        // `prior_versao` reshapes the return (a canonicalization
3660        // pass, a leading/trailing whitespace trim, an empty-to-
3661        // "0.0.0" fallback) it would either loosen the validator
3662        // (silently accepting shapes the parser rejects) or
3663        // tighten the parser's re-parse (silently panicking on
3664        // shapes the validator accepts) — this pin catches either
3665        // shift at caixa-core build time.
3666        let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3667        for from in accepted {
3668            let e = entry(from, vec![]);
3669            e.validate().unwrap_or_else(|err| {
3670                panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3671            });
3672            semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3673                panic!(
3674                    "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3675                     got {err:?}",
3676                );
3677            });
3678        }
3679        let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3680        for from in rejected {
3681            let e = entry(from, vec![]);
3682            assert!(
3683                matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3684                "validate() must reject {from:?} that Version::parse rejects",
3685            );
3686            assert!(
3687                semver::Version::parse(e.prior_versao()).is_err(),
3688                "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3689            );
3690        }
3691    }
3692
3693    #[test]
3694    fn validate_rejects_empty_module() {
3695        // Per-arm coverage: every Module-bearing variant surfaces the
3696        // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3697        // so the author can grep their caixa.lisp for `(:load-module
3698        // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3699        // edit — same self-locating shape `BehaviorError::EmptyPath`
3700        // (b0c8389) carries on the peer M2 typed slot.
3701        let cases: &[(UpgradeInstruction, &'static str)] = &[
3702            (
3703                UpgradeInstruction::LoadModule {
3704                    module: String::new(),
3705                },
3706                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3707            ),
3708            (
3709                UpgradeInstruction::SoftPurge {
3710                    module: String::new(),
3711                },
3712                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3713            ),
3714            (
3715                UpgradeInstruction::Purge {
3716                    module: String::new(),
3717                },
3718                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3719            ),
3720        ];
3721        for (instr, expected_kind) in cases {
3722            assert_eq!(
3723                instr.validate().unwrap_err(),
3724                UpgradeError::ModuleEmpty {
3725                    kind: expected_kind
3726                },
3727                "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3728            );
3729        }
3730    }
3731
3732    #[test]
3733    fn validate_rejects_non_dns_1123_module() {
3734        // Every appup `:module` reference is a caixa name (the
3735        // wasm-engine resolves it through the same ComputeUnit
3736        // registry the operator manages), so the value-shape gate
3737        // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3738        // the canonical authoring footguns — uppercase letters, `_`
3739        // separator, embedded `.`, leading/trailing `-`, an embedded
3740        // whitespace byte, the >63-byte UUID-shaped slug — across
3741        // every Module-bearing variant; each must surface as
3742        // `ModuleInvalid { kind, module, reason }` carrying the
3743        // offending value verbatim and the parser-shaped reason.
3744        type Build = fn(String) -> UpgradeInstruction;
3745        let footguns: &[&str] = &[
3746            "Hello-Rio",
3747            "hello_rio",
3748            "hello.rio",
3749            "-hello",
3750            "hello-",
3751            "hello rio",
3752            &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3753        ];
3754        let variants: &[(Build, &'static str)] = &[
3755            (
3756                |m| UpgradeInstruction::LoadModule { module: m },
3757                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3758            ),
3759            (
3760                |m| UpgradeInstruction::SoftPurge { module: m },
3761                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3762            ),
3763            (
3764                |m| UpgradeInstruction::Purge { module: m },
3765                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3766            ),
3767        ];
3768        for (build, expected_kind) in variants {
3769            for module in footguns {
3770                let instr = build((*module).to_string());
3771                let err = instr.validate().unwrap_err();
3772                match err {
3773                    UpgradeError::ModuleInvalid {
3774                        kind,
3775                        module: m,
3776                        reason,
3777                    } => {
3778                        assert_eq!(
3779                            kind, *expected_kind,
3780                            ":module footgun on {instr:?} must tag the lisp-form"
3781                        );
3782                        assert_eq!(
3783                            m, *module,
3784                            "ModuleInvalid must carry the offending value verbatim"
3785                        );
3786                        assert!(
3787                            !reason.is_empty(),
3788                            "ModuleInvalid reason must name the specific violation \
3789                             (the predicate's parser-shaped wording from \
3790                             `is_dns_1123_label`), got empty"
3791                        );
3792                    }
3793                    other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3794                }
3795            }
3796        }
3797    }
3798
3799    #[test]
3800    fn validate_accepts_canonical_module_names() {
3801        // Positive control: every documented authoring shape — bare
3802        // identifier, with hyphens, with digits, the
3803        // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3804        // references — passes the gate. Drift here = a future
3805        // tighten that rejects any of these surfaces as a
3806        // test-failure at the predicate boundary, not piecemeal
3807        // across per-instruction call sites.
3808        let canonical: &[&str] = &[
3809            "hello-rio",
3810            "hello-rio-old",
3811            "cache",
3812            "cache-v2",
3813            "x",
3814            "a1",
3815            "0a",
3816            "abc-123-def",
3817        ];
3818        for module in canonical {
3819            UpgradeInstruction::LoadModule {
3820                module: (*module).to_string(),
3821            }
3822            .validate()
3823            .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3824            UpgradeInstruction::SoftPurge {
3825                module: (*module).to_string(),
3826            }
3827            .validate()
3828            .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3829            UpgradeInstruction::Purge {
3830                module: (*module).to_string(),
3831            }
3832            .validate()
3833            .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3834        }
3835    }
3836
3837    #[test]
3838    fn validate_empty_takes_precedence_over_invalid() {
3839        // Empty input is rejected via the narrower `ModuleEmpty`
3840        // diagnostic before the DNS-1123 predicate is consulted, so
3841        // a future tighten that adds another stage between the two
3842        // doesn't accidentally reorder the diagnostic precedence.
3843        // Mirrors the empty-first cascade on every peer DNS-1123
3844        // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3845        // `SupervisorSpec::validate`'s child-name arm).
3846        let err = UpgradeInstruction::LoadModule {
3847            module: String::new(),
3848        }
3849        .validate()
3850        .unwrap_err();
3851        assert_eq!(
3852            err,
3853            UpgradeError::ModuleEmpty {
3854                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3855            }
3856        );
3857    }
3858
3859    #[test]
3860    fn validate_rejects_empty_script() {
3861        let i = UpgradeInstruction::StateChange {
3862            script: PathBuf::new(),
3863        };
3864        assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3865    }
3866
3867    #[test]
3868    fn validate_rejects_absolute_script() {
3869        let i = UpgradeInstruction::StateChange {
3870            script: PathBuf::from("/etc/migrations.lisp"),
3871        };
3872        assert!(matches!(
3873            i.validate().unwrap_err(),
3874            UpgradeError::AbsoluteScript { .. }
3875        ));
3876    }
3877
3878    #[test]
3879    fn validate_rejects_parent_escape_script() {
3880        let i = UpgradeInstruction::StateChange {
3881            script: PathBuf::from("../sibling/migrations.lisp"),
3882        };
3883        assert!(matches!(
3884            i.validate().unwrap_err(),
3885            UpgradeError::ParentEscapeScript { .. }
3886        ));
3887        // mid-path `..` is also caught
3888        let i2 = UpgradeInstruction::StateChange {
3889            script: PathBuf::from("lib/../../escaped.lisp"),
3890        };
3891        assert!(matches!(
3892            i2.validate().unwrap_err(),
3893            UpgradeError::ParentEscapeScript { .. }
3894        ));
3895    }
3896
3897    // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3898    // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3899    // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3900    // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3901    // consumer; the file-type contract is identical, so the per-axis
3902    // test grid is mirrored leg-for-leg.
3903
3904    #[test]
3905    fn validate_rejects_no_extension_script() {
3906        // Fail-before-pass-after: the canonical "I declared the
3907        // migration script but forgot the `.lisp` extension"
3908        // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3909        // The wasm-engine's `tatara_lisp::read` consumer needs a
3910        // file-type contract beyond the structural-shape gate; a
3911        // no-extension path past `is_sandboxed_relative_path` would
3912        // surface a parser-shaped diagnostic at hot-upgrade migration
3913        // time far from the source caixa.lisp.
3914        for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3915            let i = UpgradeInstruction::StateChange {
3916                script: PathBuf::from(relpath),
3917            };
3918            let err = i.validate().unwrap_err();
3919            assert!(
3920                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3921                         if s == Path::new(relpath)),
3922                "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3923                 carrying the offending path verbatim, got {err:?}"
3924            );
3925        }
3926    }
3927
3928    #[test]
3929    fn validate_rejects_non_lisp_extension_script() {
3930        // Wrong-extension sweep across common authoring footguns: the
3931        // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3932        // drag in from the workspace tree, the `.rs` shape that an
3933        // IDE auto-complete might propose, the `.lisp.bak` shape an
3934        // editor might leave behind, and the `.lispx` near-miss that
3935        // a typo would produce. Each must surface as
3936        // `NonLispExtensionScript` carrying the offending path
3937        // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3938        // rejects all of these at hot-upgrade migration time, and
3939        // the gate lifts that contract to validate time. Mirrors the
3940        // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3941        // the `:behavior :on-*` axis leg-for-leg — same downstream
3942        // consumer, same accepted set, same per-axis test grid.
3943        let footguns: &[&str] = &[
3944            "lib/migrations.rs",
3945            "lib/migrations.txt",
3946            "lib/migrations.md",
3947            "lib/migrations.json",
3948            "lib/migrations.yaml",
3949            "lib/migrations.toml",
3950            "lib/migrations.lisp.bak",
3951            "lib/migrations.lispx",
3952            "lib/migrations.lis",
3953        ];
3954        for relpath in footguns {
3955            let i = UpgradeInstruction::StateChange {
3956                script: PathBuf::from(relpath),
3957            };
3958            let err = i.validate().unwrap_err();
3959            assert!(
3960                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3961                         if s == Path::new(relpath)),
3962                "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3963                 carrying the offending path verbatim, got {err:?}"
3964            );
3965        }
3966    }
3967
3968    #[test]
3969    fn validate_rejects_uppercase_lisp_extension_script() {
3970        // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3971        // case-folded shapes a case-insensitive volume's existence
3972        // check would match the on-disk file — but the
3973        // canonical-form codec emits lowercase `.lisp` verbatim, so
3974        // a case-folded shape mismatches the round-trip-stable
3975        // canonical form (THEORY.md §V.2.7 render-determinism).
3976        // Same case-sensitive discipline the byte-size / duration
3977        // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3978        // and every other shape-gate predicate in `render.rs` (label
3979        // / scheme / unit boundaries). Mirrors the peer
3980        // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3981        for relpath in [
3982            "lib/migrations.LISP",
3983            "lib/migrations.Lisp",
3984            "lib/migrations.LiSp",
3985            "lib/migrations.lISP",
3986        ] {
3987            let i = UpgradeInstruction::StateChange {
3988                script: PathBuf::from(relpath),
3989            };
3990            let err = i.validate().unwrap_err();
3991            assert!(
3992                matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3993                         if s == Path::new(relpath)),
3994                "case-folded `.lisp` extension {relpath:?} must surface as \
3995                 NonLispExtensionScript (strict lowercase, canonical-form \
3996                 round-trip pin), got {err:?}"
3997            );
3998        }
3999    }
4000
4001    #[test]
4002    fn validate_accepts_canonical_lisp_extension_scripts() {
4003        // Positive-control sweep across every canonical in-tree
4004        // authoring shape: bare filename, standard `lib/`
4005        // subdirectory, deeply-nested migrations subdirectory,
4006        // explicit current-dir-relative prefix, mid-path `./`
4007        // segment, multi-dot stem (the version-suffix shape
4008        // `lib/migrations/v.0.1.lisp` an author might use to encode
4009        // the migration's `:from` version into the filename). Drift
4010        // here = a future tightening that rejects any of these
4011        // surfaces as a test-failure at the per-axis validator
4012        // boundary, not piecemeal across renderer / layout-checker
4013        // call sites. Mirrors the peer `BehaviorSpec` positive-set
4014        // sweep (c97815a).
4015        let canonical: &[&str] = &[
4016            "lib/migrations.lisp",
4017            "lib/migrations/v01-to-v02.lisp",
4018            "migrations.lisp",
4019            "a.lisp",
4020            "./lib/migrations.lisp",
4021            "lib/./migrations.lisp",
4022            "lib/migrations/v.0.1.lisp",
4023        ];
4024        for relpath in canonical {
4025            UpgradeInstruction::StateChange {
4026                script: PathBuf::from(relpath),
4027            }
4028            .validate()
4029            .unwrap_or_else(|e| {
4030                panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
4031            });
4032        }
4033    }
4034
4035    #[test]
4036    fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
4037        // Cross-arm precedence pin: a script that is *both*
4038        // sandbox-escaping (Empty / Absolute / ParentEscape) and
4039        // non-`.lisp` must surface the more-fundamental
4040        // sandbox-shape diagnostic first — the canonical fix
4041        // collapses both into "pin a relative `.lisp` path under the
4042        // caixa root", and the `.lisp` remediation would be
4043        // misleading when the offending path can never resolve under
4044        // the caixa root anyway. Mirrors the peer
4045        // `BehaviorError` cross-arm precedence (c97815a) and the
4046        // sibling `LimitsError`
4047        // (`MemoryZero` → `MemoryBelowWasm32Page` →
4048        // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
4049        // smallest-scope-arm-fires-last posture.
4050        let i_empty = UpgradeInstruction::StateChange {
4051            script: PathBuf::new(),
4052        };
4053        assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
4054        let i_abs = UpgradeInstruction::StateChange {
4055            script: PathBuf::from("/etc/migrations.txt"),
4056        };
4057        assert!(
4058            matches!(
4059                i_abs.validate().unwrap_err(),
4060                UpgradeError::AbsoluteScript { .. }
4061            ),
4062            "absolute + non-`.lisp` must surface AbsoluteScript first"
4063        );
4064        let i_esc = UpgradeInstruction::StateChange {
4065            script: PathBuf::from("../sibling/migrations.rs"),
4066        };
4067        assert!(
4068            matches!(
4069                i_esc.validate().unwrap_err(),
4070                UpgradeError::ParentEscapeScript { .. }
4071            ),
4072            "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
4073        );
4074    }
4075
4076    #[test]
4077    fn non_lisp_extension_script_diagnostic_carries_offending_path() {
4078        // Diagnostic-shape pin: the surfaced error message names the
4079        // offending path verbatim (so the author can grep their
4080        // caixa.lisp for the literal value), the `.lisp` extension
4081        // is named in the remediation, and the downstream consumer
4082        // (`tatara_lisp::read` at hot-upgrade migration time) is
4083        // named so the author can trace the contract back to its
4084        // source. Same self-locating shape every per-axis variant
4085        // carries (`BehaviorError::NonLispExtension`, c97815a;
4086        // `LimitsError::MemoryNotPageMultiple`, ec266d8).
4087        let bad = PathBuf::from("lib/migrations.txt");
4088        let err = UpgradeInstruction::StateChange {
4089            script: bad.clone(),
4090        }
4091        .validate()
4092        .unwrap_err();
4093        let msg = err.to_string();
4094        assert!(
4095            msg.contains("lib/migrations.txt"),
4096            "diagnostic must name the offending path verbatim, got {msg:?}"
4097        );
4098        assert!(
4099            msg.contains(".lisp"),
4100            "diagnostic must name the expected `.lisp` extension, got {msg:?}"
4101        );
4102        assert!(
4103            msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
4104            "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
4105        );
4106        match err {
4107            UpgradeError::NonLispExtensionScript { script } => {
4108                assert_eq!(
4109                    script, bad,
4110                    "variant must carry the offending path verbatim"
4111                );
4112            }
4113            other => panic!("expected NonLispExtensionScript, got {other:?}"),
4114        }
4115    }
4116
4117    #[test]
4118    fn declared_path_only_for_state_change() {
4119        let load = UpgradeInstruction::LoadModule { module: "x".into() };
4120        assert!(load.declared_path().is_none());
4121        let mig = UpgradeInstruction::StateChange {
4122            script: PathBuf::from("lib/m.lisp"),
4123        };
4124        assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
4125    }
4126
4127    #[test]
4128    fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
4129        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4130        // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
4131        // predicate: [`UpgradeInstruction::Restart`] is the only variant
4132        // that satisfies `.is_restart()`; every module-bearing arm
4133        // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
4134        // `StateChange` arm all return `false`. This pin makes the
4135        // partition invariant load-bearing at caixa-core test time so a
4136        // future derive regression (a hole that returns `false` for
4137        // `Restart` too, or a byte-collision that flips a second variant
4138        // to `true`) trips here rather than laundering the arm at
4139        // [`Self::validate_restart_exclusive`]'s paired positive /
4140        // negated filter sites (a hole flips restart-count to 0 →
4141        // vacuous OK; a collision flips restart-count > 1 → false
4142        // `RestartNotExclusive` on an entry the author declared without
4143        // any `(:restart)`). Peer of the sibling
4144        // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
4145        // pin on the M0 `CaixaKind` axis.
4146        let cases: &[(UpgradeInstruction, bool)] = &[
4147            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4148            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4149            (UpgradeInstruction::Purge { module: "c".into() }, false),
4150            (
4151                UpgradeInstruction::StateChange {
4152                    script: PathBuf::from("lib/m.lisp"),
4153                },
4154                false,
4155            ),
4156            (UpgradeInstruction::Restart, true),
4157        ];
4158        for (variant, expected) in cases {
4159            assert_eq!(
4160                variant.is_restart(),
4161                *expected,
4162                "UpgradeInstruction::{variant:?}.is_restart() must \
4163                 return {expected} (partition invariant on the \
4164                 IsVariant-derived arm-discriminator predicate)"
4165            );
4166        }
4167    }
4168
4169    #[test]
4170    fn validate_restart_exclusive_routes_through_is_restart_predicate() {
4171        // Byte-identity pin on the paired positive / negated
4172        // `.is_restart()` filters at
4173        // [`Self::validate_restart_exclusive`] against the pre-lift
4174        // `matches!(i, UpgradeInstruction::Restart)` /
4175        // `!matches!(i, UpgradeInstruction::Restart)` predicates every
4176        // consumer of the gate previously coupled to inline. Asserts
4177        // the two projections agree byte-for-byte on every arm of the
4178        // enum, so a future derive regression that flipped either
4179        // predicate's arm-set would surface here at caixa-core test
4180        // time rather than at
4181        // [`Self::validate_restart_exclusive`]'s per-entry restart-
4182        // count / other-kinds tabulation far from the derive site.
4183        // Same peer-shape pin every sibling
4184        // `IsVariant`-derive-routed gate carries on the substrate's
4185        // closed-set typed-enum surface.
4186        let cases: Vec<UpgradeInstruction> = vec![
4187            UpgradeInstruction::LoadModule { module: "a".into() },
4188            UpgradeInstruction::SoftPurge { module: "b".into() },
4189            UpgradeInstruction::Purge { module: "c".into() },
4190            UpgradeInstruction::StateChange {
4191                script: PathBuf::from("lib/m.lisp"),
4192            },
4193            UpgradeInstruction::Restart,
4194        ];
4195        for instr in &cases {
4196            let via_predicate = instr.is_restart();
4197            let via_matches = matches!(instr, UpgradeInstruction::Restart);
4198            assert_eq!(
4199                via_predicate, via_matches,
4200                "UpgradeInstruction::{instr:?}: is_restart() must \
4201                 byte-equal matches!(_, UpgradeInstruction::Restart) — \
4202                 the pre-lift open-coded pattern and the \
4203                 IsVariant-derived predicate are the same axis, \
4204                 one typed dispatch"
4205            );
4206        }
4207    }
4208
4209    #[test]
4210    fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
4211        // The fail-before-pass-after pin on the lifted
4212        // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
4213        // arm-discriminator predicate:
4214        // [`UpgradeInstruction::SoftPurge`] and
4215        // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
4216        // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
4217        // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
4218        // on the paired two-phase-load half,
4219        // [`UpgradeInstruction::StateChange`] on the
4220        // `gen_server:code_change/3`-analog migration axis,
4221        // [`UpgradeInstruction::Restart`] on the OTP terminal-
4222        // fallback shape) returns `false`. This pin makes the
4223        // partition invariant load-bearing at caixa-core test time
4224        // so a future accessor regression (a hole that returns
4225        // `false` for `SoftPurge` or `Purge`, or a byte-collision
4226        // that flips `LoadModule` / `StateChange` / `Restart` to
4227        // `true`) trips here rather than laundering the arm at the
4228        // three within-entry cross-instruction cleanup-facing gates
4229        // ([`UpgradeFromEntry::validate_purge_ordering`],
4230        // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
4231        // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
4232        // hole would silently accept a cleanup-shaped entry the
4233        // three gates should refuse; a collision would fire a
4234        // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
4235        // `DuplicateCleanup` refusal on a well-shaped
4236        // [`UpgradeInstruction::LoadModule`] / `StateChange` /
4237        // `Restart` arm the three gates should pass through. Peer
4238        // of the sibling
4239        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4240        // pin on the single-arm terminal-fallback partition —
4241        // extended here from the single-arm case onto the two-arm
4242        // cleanup-family union case.
4243        let cases: &[(UpgradeInstruction, bool)] = &[
4244            (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4245            (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
4246            (UpgradeInstruction::Purge { module: "c".into() }, true),
4247            (
4248                UpgradeInstruction::StateChange {
4249                    script: PathBuf::from("lib/m.lisp"),
4250                },
4251                false,
4252            ),
4253            (UpgradeInstruction::Restart, false),
4254        ];
4255        for (variant, expected) in cases {
4256            assert_eq!(
4257                variant.is_cleanup(),
4258                *expected,
4259                "UpgradeInstruction::{variant:?}.is_cleanup() must \
4260                 return {expected} (partition invariant on the \
4261                 lifted OTP-appup two-arm cleanup-family arm-\
4262                 discriminator predicate)"
4263            );
4264        }
4265    }
4266
4267    #[test]
4268    fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
4269        // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
4270        // composition against the two [`gen_platform::IsVariant`]-
4271        // derive-generated per-variant classifiers it routes through
4272        // — the accessor's one body must byte-equal
4273        // `self.is_soft_purge() || self.is_purge()` across every arm
4274        // of the closed-set enum, so a future silent detour that
4275        // reintroduced a raw `matches!` pattern or that stopped
4276        // composing through the derive-generated per-variant
4277        // predicates (an accidental `self.is_soft_purge()` on its
4278        // own — silently dropping the `Purge` arm; an accidental
4279        // `self.is_purge() || self.is_state_change()` — silently
4280        // folding the migration arm into the cleanup family; a
4281        // typo `&&` for the union `||` — silently classifying no
4282        // arm as cleanup) trips here at caixa-core test time
4283        // rather than laundering the arm at the three within-entry
4284        // cross-instruction cleanup-facing gates. Same peer-shape
4285        // pin the sibling
4286        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4287        // carries on the paired terminal-fallback axis.
4288        let cases: Vec<UpgradeInstruction> = vec![
4289            UpgradeInstruction::LoadModule { module: "a".into() },
4290            UpgradeInstruction::SoftPurge { module: "b".into() },
4291            UpgradeInstruction::Purge { module: "c".into() },
4292            UpgradeInstruction::StateChange {
4293                script: PathBuf::from("lib/m.lisp"),
4294            },
4295            UpgradeInstruction::Restart,
4296        ];
4297        for instr in &cases {
4298            let via_predicate = instr.is_cleanup();
4299            let via_composition = instr.is_soft_purge() || instr.is_purge();
4300            assert_eq!(
4301                via_predicate, via_composition,
4302                "UpgradeInstruction::{instr:?}: is_cleanup() must \
4303                 byte-equal is_soft_purge() || is_purge() — the \
4304                 lifted union predicate and its per-variant \
4305                 composition are the same axis, one typed dispatch"
4306            );
4307        }
4308    }
4309
4310    #[test]
4311    fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4312        // Composition-pin the load-bearing invariant every consumer
4313        // that routes through `is_cleanup()` + `declared_module()`
4314        // relies on: any [`UpgradeInstruction`] value whose
4315        // `.is_cleanup()` returns `true` must have a `Some(_)`
4316        // `.declared_module()`. This makes the three within-entry
4317        // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4318        // implies declared_module() is Some")` structurally
4319        // infallible at build time — a future refactor that added
4320        // a cleanup-shaped variant carrying no `:module` would trip
4321        // here rather than panic at
4322        // [`UpgradeFromEntry::validate_purge_ordering`] /
4323        // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4324        // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4325        // runtime on the offending author's caixa.lisp.
4326        let cases: Vec<UpgradeInstruction> = vec![
4327            UpgradeInstruction::LoadModule { module: "a".into() },
4328            UpgradeInstruction::SoftPurge { module: "b".into() },
4329            UpgradeInstruction::Purge { module: "c".into() },
4330            UpgradeInstruction::StateChange {
4331                script: PathBuf::from("lib/m.lisp"),
4332            },
4333            UpgradeInstruction::Restart,
4334        ];
4335        for instr in &cases {
4336            if instr.is_cleanup() {
4337                assert!(
4338                    instr.declared_module().is_some(),
4339                    "UpgradeInstruction::{instr:?}: is_cleanup() \
4340                     must imply declared_module().is_some() — the \
4341                     three within-entry cross-instruction cleanup-\
4342                     facing gates rely on this invariant to route \
4343                     the cleanup-target :module scalar through the \
4344                     sibling declared_module accessor without a \
4345                     pattern-bound `module` binding"
4346                );
4347            }
4348        }
4349    }
4350
4351    #[test]
4352    fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4353        // Composition-pin the load-bearing invariant
4354        // [`UpgradeFromEntry::validate_load_singularity`] relies on
4355        // when routing the per-instruction load-family arm-discriminator
4356        // through the sibling
4357        // [`UpgradeInstruction::is_load_module`] +
4358        // [`UpgradeInstruction::declared_module`] accessor pair: any
4359        // [`UpgradeInstruction`] value whose `.is_load_module()`
4360        // returns `true` must have a `Some(_)` `.declared_module()`.
4361        // This makes the gate's `.expect("is_load_module() implies
4362        // declared_module() is Some")` structurally infallible at
4363        // build time — a future refactor that added a load-shaped
4364        // variant carrying no `:module` would trip here rather than
4365        // panic at [`UpgradeFromEntry::validate_load_singularity`]
4366        // at runtime on the offending author's caixa.lisp. Sibling
4367        // of the peer
4368        // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4369        // composition pin on the two-arm cleanup-family axis — same
4370        // "predicate implies accessor" discipline extended onto the
4371        // single-arm load-family axis, closes the load-vs-cleanup
4372        // pair on the substrate primitive's typed dispatch discipline.
4373        let cases: Vec<UpgradeInstruction> = vec![
4374            UpgradeInstruction::LoadModule { module: "a".into() },
4375            UpgradeInstruction::SoftPurge { module: "b".into() },
4376            UpgradeInstruction::Purge { module: "c".into() },
4377            UpgradeInstruction::StateChange {
4378                script: PathBuf::from("lib/m.lisp"),
4379            },
4380            UpgradeInstruction::Restart,
4381        ];
4382        for instr in &cases {
4383            if instr.is_load_module() {
4384                assert!(
4385                    instr.declared_module().is_some(),
4386                    "UpgradeInstruction::{instr:?}: is_load_module() \
4387                     must imply declared_module().is_some() — the \
4388                     within-entry load-singularity gate relies on this \
4389                     invariant to route the load-target :module scalar \
4390                     through the sibling declared_module accessor \
4391                     without a pattern-bound `module` binding"
4392                );
4393            }
4394        }
4395    }
4396
4397    #[test]
4398    fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4399     {
4400        // Byte-identity pin on the
4401        // [`UpgradeFromEntry::validate_load_singularity`] load-family
4402        // dispatch against the pre-lift
4403        // `match instr { UpgradeInstruction::LoadModule { module } =>
4404        // module.as_str(), _ => continue }` open-coded pattern-match
4405        // the site previously carried. Asserts the two projections
4406        // agree byte-for-byte on every arm of the enum — the
4407        // arm-discriminator via `is_load_module()` and the `:module`
4408        // scalar via `declared_module()` — so a future derive
4409        // regression that flipped the predicate's arm-set (a hole
4410        // returning `false` for [`UpgradeInstruction::LoadModule`], a
4411        // byte-collision flipping a second variant to `true`) or an
4412        // accessor extension that promoted an additional variant onto
4413        // the `String`-carrying axis would trip here at caixa-core
4414        // test time rather than laundering the arm at the gate's
4415        // per-entry load-singularity scan far from the derive site.
4416        // Peer of the sibling
4417        // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4418        // byte-identity pin on the paired ordering-side load-family
4419        // sticky-latch dispatch (both consumers now agree on one
4420        // typed dispatch for the load-family axis) and the peer
4421        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4422        // pin on the migration-family script-projection axis — the
4423        // three within-entry per-instruction-class singularity gates
4424        // now share one byte-identity pin apiece against their
4425        // respective substrate-primitive typed dispatches.
4426        //
4427        // Three-arm projective coverage:
4428        //   (a) `LoadModule` modules project through
4429        //       `declared_module()` byte-equal to the raw
4430        //       `module.as_str()` field access;
4431        //   (b) a duplicate-`LoadModule` input trips the gate on the
4432        //       second occurrence with `DuplicateLoadModule` carrying
4433        //       the offending module verbatim;
4434        //   (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4435        //       `StateChange` / `Restart`) leaves the gate vacuous
4436        //       with `Ok(())` — the `!instr.is_load_module()`
4437        //       `continue` fall-through pins.
4438        //
4439        // Fail-before-pass-after verified locally: swapping the
4440        // production `if !instr.is_load_module() { continue; } let
4441        // module = instr.declared_module().expect(…);` back to `let
4442        // module = match instr { UpgradeInstruction::LoadModule
4443        // { module } => module.as_str(), _ => continue, };` keeps
4444        // arms (a)-(c) passing but silently detaches the gate from
4445        // the accessor's typed dispatch — any future
4446        // `is_load_module` / `declared_module` extension (a hole in
4447        // either predicate, a promotion of an additional variant
4448        // onto the `String`-carrying axis, an operator-side
4449        // pre-parsed caixa-name cache the accessor materializes)
4450        // would then silently disagree between this gate's raw
4451        // pattern-match and the peer per-`UpgradeInstruction`
4452        // consumers that route through the accessor pair.
4453
4454        // (a) LoadModule projection byte-equal via
4455        //     is_load_module() + declared_module().
4456        let lm = UpgradeInstruction::LoadModule {
4457            module: "hello-rio".into(),
4458        };
4459        assert!(
4460            lm.is_load_module(),
4461            "LoadModule must satisfy is_load_module() — the gate's \
4462             load-family arm-discriminator relies on this partition"
4463        );
4464        assert_eq!(
4465            lm.declared_module(),
4466            Some("hello-rio"),
4467            "declared_module() must project the LoadModule :module \
4468             byte-equal to the raw field access — accessor divergence \
4469             would silently detach the gate from the projection every \
4470             peer per-`UpgradeInstruction` consumer routes through"
4471        );
4472
4473        // (b) Duplicate-LoadModule input trips the gate.
4474        let dup = entry(
4475            "0.1.0",
4476            vec![
4477                UpgradeInstruction::LoadModule { module: "x".into() },
4478                UpgradeInstruction::LoadModule { module: "x".into() },
4479            ],
4480        );
4481        assert_eq!(
4482            dup.validate_load_singularity(),
4483            Err(UpgradeError::DuplicateLoadModule {
4484                from: "0.1.0".into(),
4485                module: "x".into(),
4486            }),
4487            "duplicate LoadModule modules within one entry must fire \
4488             DuplicateLoadModule byte-identical to the pre-lift \
4489             pattern-match shape"
4490        );
4491
4492        // (c) Non-LoadModule-only input leaves the gate vacuous.
4493        let no_load = entry(
4494            "0.1.0",
4495            vec![
4496                UpgradeInstruction::StateChange {
4497                    script: PathBuf::from("lib/m.lisp"),
4498                },
4499                UpgradeInstruction::Restart,
4500            ],
4501        );
4502        assert_eq!(
4503            no_load.validate_load_singularity(),
4504            Ok(()),
4505            "non-LoadModule-only entries must leave the load-\
4506             singularity gate vacuous — the `!is_load_module()` \
4507             continue fall-through pins"
4508        );
4509    }
4510
4511    #[test]
4512    fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4513        // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4514        // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4515        // predicate: [`UpgradeInstruction::LoadModule`] is the only
4516        // variant that satisfies `.is_load_module()`; every cleanup arm
4517        // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4518        // and the terminal-fallback arm (`Restart`) all return `false`.
4519        // This pin makes the partition invariant load-bearing at
4520        // caixa-core test time so a future derive regression (a hole
4521        // that returns `false` for `LoadModule` too, or a byte-collision
4522        // that flips a second variant to `true`) trips here rather than
4523        // laundering the arm at
4524        // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4525        // dispatch — a hole would silently keep `loaded = false` through
4526        // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4527        // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4528        // a collision would flip `loaded = true` on a well-shaped
4529        // cleanup-only entry and silently swallow the load-less
4530        // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4531        // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4532        // and
4533        // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4534        // pins on the paired terminal-fallback and cleanup-family
4535        // arm-discriminator axes — closes the last unlifted `matches!`-
4536        // based arm-discriminator axis on the OTP-appup closed-set
4537        // typed enum.
4538        let cases: &[(UpgradeInstruction, bool)] = &[
4539            (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4540            (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4541            (UpgradeInstruction::Purge { module: "c".into() }, false),
4542            (
4543                UpgradeInstruction::StateChange {
4544                    script: PathBuf::from("lib/m.lisp"),
4545                },
4546                false,
4547            ),
4548            (UpgradeInstruction::Restart, false),
4549        ];
4550        for (variant, expected) in cases {
4551            assert_eq!(
4552                variant.is_load_module(),
4553                *expected,
4554                "UpgradeInstruction::{variant:?}.is_load_module() must \
4555                 return {expected} (partition invariant on the \
4556                 IsVariant-derived arm-discriminator predicate)"
4557            );
4558        }
4559    }
4560
4561    #[test]
4562    fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4563        // Byte-identity pin on the [`Self::validate_purge_ordering`]
4564        // load-family sticky-latch dispatch against the pre-lift
4565        // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4566        // predicate the site previously open-coded. Asserts the two
4567        // projections agree byte-for-byte on every arm of the enum, so
4568        // a future derive regression that flipped the predicate's
4569        // arm-set would surface here at caixa-core test time rather
4570        // than at [`Self::validate_purge_ordering`]'s per-entry
4571        // load-before-cleanup ordering scan far from the derive site.
4572        // Same peer-shape pin the sibling
4573        // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4574        // carries on the paired terminal-fallback axis and the
4575        // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4576        // carries on the two-arm cleanup-family axis — the third and
4577        // final byte-identity pin closes the substrate primitive's
4578        // arm-discriminator dispatch discipline on the OTP-appup
4579        // closed-set typed enum.
4580        let cases: Vec<UpgradeInstruction> = vec![
4581            UpgradeInstruction::LoadModule { module: "a".into() },
4582            UpgradeInstruction::SoftPurge { module: "b".into() },
4583            UpgradeInstruction::Purge { module: "c".into() },
4584            UpgradeInstruction::StateChange {
4585                script: PathBuf::from("lib/m.lisp"),
4586            },
4587            UpgradeInstruction::Restart,
4588        ];
4589        for instr in &cases {
4590            let via_predicate = instr.is_load_module();
4591            let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4592            assert_eq!(
4593                via_predicate, via_matches,
4594                "UpgradeInstruction::{instr:?}: is_load_module() must \
4595                 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4596                 {{ .. }}) — the pre-lift open-coded pattern and the \
4597                 IsVariant-derived predicate are the same axis, one \
4598                 typed dispatch"
4599            );
4600        }
4601    }
4602
4603    #[test]
4604    fn declared_module_only_for_module_bearing_variants() {
4605        // Pinned partition of the `UpgradeInstruction` closed-set
4606        // variant space against the sibling of the peer
4607        // `declared_path` accessor: every OTP-appup module-bearing
4608        // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4609        // `:module` string byte-for-byte through the lifted
4610        // `declared_module` accessor; every non-module-bearing variant
4611        // (`StateChange` on the peer `:script`-carrying axis;
4612        // `Restart` on the OTP terminal-fallback data-less axis)
4613        // returns `None`. Mirrors the peer
4614        // `declared_path_only_for_state_change` pin — the pair now
4615        // closes both scalar-carrying axes on the enum on one lifted
4616        // `Option<&…>` accessor apiece.
4617        let load = UpgradeInstruction::LoadModule {
4618            module: "hello-rio".into(),
4619        };
4620        assert_eq!(load.declared_module(), Some("hello-rio"));
4621        let soft = UpgradeInstruction::SoftPurge {
4622            module: "hello-rio-old".into(),
4623        };
4624        assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4625        let hard = UpgradeInstruction::Purge {
4626            module: "hello-rio-ancient".into(),
4627        };
4628        assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4629        let mig = UpgradeInstruction::StateChange {
4630            script: PathBuf::from("lib/m.lisp"),
4631        };
4632        assert!(mig.declared_module().is_none());
4633        assert!(UpgradeInstruction::Restart.declared_module().is_none());
4634    }
4635
4636    #[test]
4637    fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4638        // Byte-identity pin on the two-accessor partition: every
4639        // `UpgradeInstruction` variant returns `Some` from *exactly
4640        // one* of {`declared_module`, `declared_path`} (the two
4641        // module-bearing / script-carrying axes) or from *neither*
4642        // (the OTP terminal-fallback `Restart` shape). No variant
4643        // returns `Some` from both — the two axes are disjoint by
4644        // construction, and this pin closes the disjointness at the
4645        // test surface so a future variant that leaks a scalar across
4646        // both axes fails at build time. Mirrors the peer
4647        // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4648        // discipline on the `BehaviorSpec` per-slot family.
4649        let cases: Vec<UpgradeInstruction> = vec![
4650            UpgradeInstruction::LoadModule { module: "a".into() },
4651            UpgradeInstruction::SoftPurge { module: "b".into() },
4652            UpgradeInstruction::Purge { module: "c".into() },
4653            UpgradeInstruction::StateChange {
4654                script: PathBuf::from("lib/m.lisp"),
4655            },
4656            UpgradeInstruction::Restart,
4657        ];
4658        for instr in &cases {
4659            let has_module = instr.declared_module().is_some();
4660            let has_path = instr.declared_path().is_some();
4661            assert!(
4662                !(has_module && has_path),
4663                "no variant may declare both a module and a path — offending: {instr:?}"
4664            );
4665            match instr {
4666                UpgradeInstruction::LoadModule { .. }
4667                | UpgradeInstruction::SoftPurge { .. }
4668                | UpgradeInstruction::Purge { .. } => {
4669                    assert!(has_module && !has_path, "module axis: {instr:?}");
4670                }
4671                UpgradeInstruction::StateChange { .. } => {
4672                    assert!(!has_module && has_path, "script axis: {instr:?}");
4673                }
4674                UpgradeInstruction::Restart => {
4675                    assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4676                }
4677            }
4678        }
4679    }
4680
4681    #[test]
4682    fn entry_with_chain_of_versions() {
4683        // Middle entry pairs a `:load-module` with the trailing
4684        // `:soft-purge` so it satisfies the within-entry purge-ordering
4685        // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4686        // preceding `:load-module`, mirroring the state-change-ordering
4687        // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4688        // test is *cross-entry* `:from` values; the within-entry shape
4689        // is incidental — keeping it canonical (`:load-module` before
4690        // `:soft-purge`) leaves the chain assertion load-bearing.
4691        let entries = vec![
4692            entry(
4693                "0.1.0",
4694                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4695            ),
4696            entry(
4697                "0.1.5",
4698                vec![
4699                    UpgradeInstruction::LoadModule { module: "x".into() },
4700                    UpgradeInstruction::SoftPurge {
4701                        module: "x-old".into(),
4702                    },
4703                ],
4704            ),
4705            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4706        ];
4707        for e in &entries {
4708            e.validate().unwrap();
4709        }
4710        let json = serde_json::to_string(&entries).unwrap();
4711        let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4712        assert_eq!(entries, back);
4713    }
4714
4715    #[test]
4716    fn empty_instructions_list_is_valid() {
4717        let e = entry("0.1.0", vec![]);
4718        e.validate().unwrap();
4719    }
4720
4721    #[test]
4722    fn json_uses_kebab_case_kind_tags() {
4723        let i = UpgradeInstruction::SoftPurge {
4724            module: "x-old".into(),
4725        };
4726        let json = serde_json::to_string(&i).unwrap();
4727        assert!(json.contains("\"kind\":\"soft-purge\""));
4728        let i2 = UpgradeInstruction::StateChange {
4729            script: PathBuf::from("m.lisp"),
4730        };
4731        let json2 = serde_json::to_string(&i2).unwrap();
4732        assert!(json2.contains("\"kind\":\"state-change\""));
4733    }
4734
4735    // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4736
4737    #[test]
4738    fn validate_upgrade_from_accepts_disjoint_versions() {
4739        // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4740        // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4741        // (and `entry_with_chain_of_versions` above) passes the cross-
4742        // entry gate. Different `:from` per entry is the intended
4743        // shape; the gate must not regress this baseline. Middle entry
4744        // pairs `:load-module` with `:soft-purge` to satisfy the
4745        // within-entry purge-ordering gate (see
4746        // `entry_with_chain_of_versions` for the same shape).
4747        let entries = vec![
4748            entry(
4749                "0.1.0",
4750                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4751            ),
4752            entry(
4753                "0.1.5",
4754                vec![
4755                    UpgradeInstruction::LoadModule { module: "x".into() },
4756                    UpgradeInstruction::SoftPurge {
4757                        module: "x-old".into(),
4758                    },
4759                ],
4760            ),
4761            entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4762        ];
4763        validate_upgrade_from(&entries).unwrap();
4764    }
4765
4766    #[test]
4767    fn validate_upgrade_from_accepts_empty_list() {
4768        // Absent `:upgrade-from` (the bare `feira init` shape) — the
4769        // gate must trivially pass an empty list. Mirrors the per-axis
4770        // "empty list passes" positive control on every peer typed-
4771        // graph gate (`validate_membros` empty list, `validate_placement`
4772        // requires non-empty clusters but only after a `Placement`
4773        // exists, etc.).
4774        validate_upgrade_from(&[]).unwrap();
4775    }
4776
4777    #[test]
4778    fn validate_upgrade_from_rejects_duplicate_from() {
4779        // Fail-before-pass-after pin: two entries with the same parsed-
4780        // semver `:from` are an ambiguous edge in the typed upgrade
4781        // graph (OTP appup picks at most one matching block per running
4782        // version; with two matching blocks the operator picks either
4783        // set non-deterministically — author intent is one path per
4784        // prior version). Same set-not-multiset discipline as
4785        // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4786        // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4787        // `:entrada :paths` (eb3456d) — now extended onto the fifth
4788        // typed-graph axis.
4789        let entries = vec![
4790            entry(
4791                "0.1.0",
4792                vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4793            ),
4794            entry(
4795                "0.1.0",
4796                vec![
4797                    UpgradeInstruction::LoadModule { module: "x".into() },
4798                    UpgradeInstruction::SoftPurge {
4799                        module: "x-old".into(),
4800                    },
4801                ],
4802            ),
4803        ];
4804        let err = validate_upgrade_from(&entries).unwrap_err();
4805        assert_eq!(
4806            err,
4807            UpgradeError::DuplicateFrom {
4808                from: "0.1.0".into()
4809            },
4810            "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4811             offending value verbatim"
4812        );
4813    }
4814
4815    #[test]
4816    fn validate_upgrade_from_treats_pre_release_as_distinct() {
4817        // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4818        // equal under semver (pre-release version is part of the
4819        // identity), so they're distinct upgrade paths and must not
4820        // collide. A future tightening that collapses pre-release into
4821        // the release version surfaces here.
4822        let entries = vec![
4823            entry("1.0.0", vec![UpgradeInstruction::Restart]),
4824            entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4825        ];
4826        validate_upgrade_from(&entries).unwrap();
4827    }
4828
4829    #[test]
4830    fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4831        // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4832        // compares build metadata (it derives equality across all
4833        // fields including `pre` + `build`), so `1.0.0+build1` and
4834        // `1.0.0+build2` are *not* duplicates from the gate's
4835        // perspective — the operator may treat the build-metadata
4836        // suffix as a tiebreaker even though the semver spec says
4837        // build metadata is ignored for precedence
4838        // (https://semver.org/#spec-item-10). Pin the conservative
4839        // behavior here so a future switch to a build-metadata-
4840        // stripping comparator surfaces as a test failure first; that
4841        // change would require coordinating with the wasm-operator's
4842        // `:from`-match dispatch step, which is the load-bearing
4843        // semantic we'd be mirroring.
4844        let entries = vec![
4845            entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4846            entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4847        ];
4848        validate_upgrade_from(&entries).unwrap();
4849    }
4850
4851    #[test]
4852    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4853        // Order pin: a malformed `:from` on the second entry surfaces
4854        // its `FromInvalid` diagnostic, not a (less-useful)
4855        // `DuplicateFrom`. The per-entry shape pass runs *inline*
4856        // before the duplicate-key insert — parallel to
4857        // `child_versao_invalid_fires_before_duplicate_check`
4858        // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4859        // (9888b13). Without this pin a future shortcut that runs the
4860        // cross-entry gate first would surface a duplicate diagnostic
4861        // on a string that isn't even parsable as a version.
4862        let entries = vec![
4863            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4864            entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4865        ];
4866        let err = validate_upgrade_from(&entries).unwrap_err();
4867        assert!(
4868            matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4869            "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4870        );
4871    }
4872
4873    #[test]
4874    fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4875        // Symmetric arm: a malformed shape on the *first* entry of a
4876        // duplicate pair surfaces its per-entry diagnostic too (not
4877        // the duplicate diagnostic that would otherwise fire on the
4878        // second entry). Pinned separately so a future shortcut that
4879        // walks the duplicate-check ahead of the per-entry pass for the
4880        // first entry only — easy regression to introduce — surfaces
4881        // here.
4882        let entries = vec![
4883            entry(
4884                "0.1.0",
4885                vec![UpgradeInstruction::LoadModule {
4886                    module: String::new(),
4887                }],
4888            ),
4889            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4890        ];
4891        let err = validate_upgrade_from(&entries).unwrap_err();
4892        assert_eq!(
4893            err,
4894            UpgradeError::ModuleEmpty {
4895                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4896            },
4897            "malformed instruction on the first entry of a duplicate pair must surface its \
4898             per-entry diagnostic before the duplicate gate fires, got {err:?}"
4899        );
4900    }
4901
4902    #[test]
4903    fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4904        // Diagnostic-shape pin: when three entries carry the same
4905        // `:from`, the gate reports the *first* collision (the second
4906        // entry) and stops — the third entry's duplicate is masked by
4907        // the first surfaced one. Mirrors
4908        // `validate_duplicate_child_diagnostic_names_first_collision`
4909        // (dbf50a9) on the supervisor axis.
4910        let entries = vec![
4911            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4912            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4913            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4914        ];
4915        let err = validate_upgrade_from(&entries).unwrap_err();
4916        assert_eq!(
4917            err,
4918            UpgradeError::DuplicateFrom {
4919                from: "0.1.0".into()
4920            }
4921        );
4922    }
4923
4924    #[test]
4925    fn validate_upgrade_from_single_entry_never_duplicates() {
4926        // Boundary control: a list of one entry can never produce a
4927        // duplicate, regardless of `:from` value (any single-element
4928        // set is trivially without duplicates). Pin this so a future
4929        // off-by-one in the seen-set insert doesn't accidentally flag
4930        // a single entry as duplicating itself.
4931        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4932        validate_upgrade_from(&entries).unwrap();
4933    }
4934
4935    // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4936
4937    #[test]
4938    fn versao_gate_accepts_strict_upgrade() {
4939        // Positive control: the canonical "chain prior versions →
4940        // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4941        // `:from` strictly less than the current `:versao` under
4942        // SemVer-2 precedence. The gate must not regress this baseline.
4943        let entries = vec![
4944            entry("0.1.0", vec![UpgradeInstruction::Restart]),
4945            entry("0.1.5", vec![UpgradeInstruction::Restart]),
4946            entry("0.1.9", vec![UpgradeInstruction::Restart]),
4947        ];
4948        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4949    }
4950
4951    #[test]
4952    fn versao_gate_accepts_empty_entries() {
4953        // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4954        // the gate is a no-op when the entries list is empty. Mirrors
4955        // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4956        validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4957    }
4958
4959    #[test]
4960    fn versao_gate_rejects_equal_from() {
4961        // Self-upgrade no-op: declaring `:from "0.2.0"` while
4962        // `:versao "0.2.0"` means "upgrade from myself to myself" —
4963        // the operator's dispatch either skips silently or
4964        // trivially "succeeds" with no observable state change.
4965        // Reject as the canonical "I forgot to bump :versao when
4966        // adding this entry" footgun.
4967        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4968        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4969        assert_eq!(
4970            err,
4971            UpgradeError::FromNotBeforeVersao {
4972                from: "0.2.0".into(),
4973                versao: "0.2.0".into(),
4974            },
4975            ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4976             values verbatim, got {err:?}"
4977        );
4978    }
4979
4980    #[test]
4981    fn versao_gate_rejects_downgrade_from() {
4982        // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4983        // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4984        // the operator's `:from`-match dispatch can never reach (it
4985        // never runs a version >= the current one). Reject as the
4986        // canonical "I copy-pasted from the next minor version and
4987        // forgot to bump :versao" footgun.
4988        let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4989        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4990        assert_eq!(
4991            err,
4992            UpgradeError::FromNotBeforeVersao {
4993                from: "0.3.0".into(),
4994                versao: "0.2.0".into(),
4995            }
4996        );
4997    }
4998
4999    #[test]
5000    fn versao_gate_accepts_prerelease_before_release() {
5001        // SemVer §11 precedence: pre-release versions are *less than*
5002        // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
5003        // FROM an RC TO the GA release is the canonical authoring
5004        // shape — must pass. A regression that collapses pre-release
5005        // into the release version (treating them as equal) surfaces
5006        // here as a false-positive rejection.
5007        let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
5008        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5009    }
5010
5011    #[test]
5012    fn versao_gate_rejects_release_after_prerelease() {
5013        // Symmetric arm: with `:versao "0.2.0-rc.1"` and
5014        // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
5015        // the typical "I'm on an RC of a release that already
5016        // shipped" footgun. The gate names both values verbatim
5017        // so the author can grep for either side and fix in one
5018        // edit.
5019        let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
5020        let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
5021        assert_eq!(
5022            err,
5023            UpgradeError::FromNotBeforeVersao {
5024                from: "0.2.0".into(),
5025                versao: "0.2.0-rc.1".into(),
5026            }
5027        );
5028    }
5029
5030    #[test]
5031    fn versao_gate_rejects_build_metadata_only_difference() {
5032        // SemVer §11 explicitly excludes build metadata from
5033        // precedence comparison: `0.2.0+build.1` and `0.2.0` are
5034        // *equal* under [`semver::Version::cmp`]. From the
5035        // operator's `:from`-match dispatch perspective this is a
5036        // self-upgrade no-op (no semantic transition between the
5037        // two), so the gate rejects it — *unlike* the peer
5038        // duplicate-`:from` gate which uses derived `PartialEq` and
5039        // treats build-metadata variants as distinct dispatch keys.
5040        // The two gates' different equality notions are deliberate:
5041        // duplicate-check is conservative (preserves operator-side
5042        // tiebreaking surface), precedence-check is permissive
5043        // (matches operator-side dispatch semantic).
5044        let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
5045        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5046        assert_eq!(
5047            err,
5048            UpgradeError::FromNotBeforeVersao {
5049                from: "0.2.0+build.1".into(),
5050                versao: "0.2.0".into(),
5051            }
5052        );
5053    }
5054
5055    #[test]
5056    fn versao_gate_silently_passes_on_unparseable_versao() {
5057        // Defensive arm: a malformed `:versao` (gated by the
5058        // narrower `ManifestError::VersaoInvalid` surface at the
5059        // load-bearing call site) must not regress into a
5060        // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
5061        // the precedence error over an unparseable `:versao` would
5062        // mask the more actionable root cause (the author meant to
5063        // type `"0.2.0"`, not `"v0.2.0"`).
5064        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
5065        validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
5066    }
5067
5068    #[test]
5069    fn versao_gate_silently_passes_on_unparseable_from() {
5070        // Symmetric defensive arm: a malformed `:from` is gated by
5071        // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
5072        // upstream at the LayoutInvariants call site. Surfacing the
5073        // precedence error over an unparseable `:from` from this
5074        // gate alone would mask the narrower `FromInvalid`
5075        // diagnostic that's expected to lead — same fall-through
5076        // posture as the unparseable-`:versao` arm above. The
5077        // wiring in `LayoutInvariants::verify` runs
5078        // `validate_upgrade_from` *before* this gate, so in practice
5079        // an unparseable `:from` surfaces as `FromInvalid` first
5080        // and this gate is never reached on that input.
5081        let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
5082        validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5083    }
5084
5085    #[test]
5086    fn versao_gate_reports_first_offending_entry() {
5087        // Determinism pin: with multiple offending entries the gate
5088        // surfaces the *first* one in declaration order — same
5089        // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5090        // on the peer gate. Walks the entries in order; first
5091        // failing `:from >= :versao` short-circuits.
5092        let entries = vec![
5093            entry("0.1.0", vec![UpgradeInstruction::Restart]),
5094            entry("0.3.0", vec![UpgradeInstruction::Restart]),
5095            entry("0.4.0", vec![UpgradeInstruction::Restart]),
5096        ];
5097        let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5098        assert_eq!(
5099            err,
5100            UpgradeError::FromNotBeforeVersao {
5101                from: "0.3.0".into(),
5102                versao: "0.2.0".into(),
5103            },
5104            "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
5105        );
5106    }
5107
5108    // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
5109
5110    #[test]
5111    fn validate_rejects_restart_mixed_with_load_module() {
5112        // The "I'll try the typed path *then* restart anyway" footgun:
5113        // an instructions list with `(:restart)` plus `(:load-module …)`
5114        // is dead code in both directions (succeed → restart discards
5115        // the work that just succeeded, defeating the typed sequence's
5116        // whole point; fail → restart never reached because the entry
5117        // already failed). The gate names the offending entry's `:from`
5118        // verbatim plus the kebab-case lisp-form of every non-`:restart`
5119        // peer so the author can grep their caixa.lisp for either side
5120        // and fix in one edit.
5121        let e = entry(
5122            "0.1.0",
5123            vec![
5124                UpgradeInstruction::LoadModule {
5125                    module: "hello-rio".into(),
5126                },
5127                UpgradeInstruction::Restart,
5128            ],
5129        );
5130        let err = e.validate().unwrap_err();
5131        assert_eq!(
5132            err,
5133            UpgradeError::RestartNotExclusive {
5134                from: "0.1.0".into(),
5135                restart_count: 1,
5136                other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
5137            },
5138            "restart + load-module mix must surface as RestartNotExclusive naming the \
5139             offending `:from` + the non-:restart kinds verbatim, got {err:?}"
5140        );
5141    }
5142
5143    #[test]
5144    fn validate_rejects_restart_mixed_with_full_typed_sequence() {
5145        // Sweep the typed-sequence universe — every non-`:restart`
5146        // variant alongside `:restart` — and assert every typed
5147        // instruction's lisp-form appears in `other_kinds` in
5148        // declaration order. The author should be able to grep for
5149        // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
5150        // `:purge`) and resolve in one pass. Drift in the `lisp_form`
5151        // mapping surfaces here.
5152        let e = entry(
5153            "0.1.0",
5154            vec![
5155                UpgradeInstruction::LoadModule {
5156                    module: "hello-rio".into(),
5157                },
5158                UpgradeInstruction::StateChange {
5159                    script: PathBuf::from("lib/m.lisp"),
5160                },
5161                UpgradeInstruction::SoftPurge {
5162                    module: "hello-rio-old".into(),
5163                },
5164                UpgradeInstruction::Purge {
5165                    module: "hello-rio-old".into(),
5166                },
5167                UpgradeInstruction::Restart,
5168            ],
5169        );
5170        let err = e.validate().unwrap_err();
5171        assert_eq!(
5172            err,
5173            UpgradeError::RestartNotExclusive {
5174                from: "0.1.0".into(),
5175                restart_count: 1,
5176                other_kinds: vec![
5177                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5178                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
5179                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5180                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5181                ],
5182            },
5183        );
5184    }
5185
5186    #[test]
5187    fn validate_rejects_restart_duplicated() {
5188        // `((:restart) (:restart))` — multiple Restart variants in one
5189        // entry. The fallback is a single semantic (restart the pod;
5190        // the new version comes up fresh); repeating it is at best
5191        // redundant, at worst suggests the author thought the second
5192        // would re-trigger after the first. The gate reports
5193        // `restart_count: 2` so the diagnostic surfaces the duplication
5194        // mode unambiguously even when `other_kinds` is empty.
5195        let e = entry(
5196            "0.1.0",
5197            vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5198        );
5199        let err = e.validate().unwrap_err();
5200        assert_eq!(
5201            err,
5202            UpgradeError::RestartNotExclusive {
5203                from: "0.1.0".into(),
5204                restart_count: 2,
5205                other_kinds: vec![],
5206            },
5207        );
5208    }
5209
5210    #[test]
5211    fn validate_accepts_sole_restart() {
5212        // Positive control: the canonical "this prior version's typed
5213        // upgrade is impossible — restart" authoring shape from the
5214        // UpgradeInstruction::Restart doc comment. `((:restart))` alone
5215        // is the entry's whole instructions list and the only valid
5216        // Restart-bearing shape.
5217        let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
5218        e.validate().unwrap();
5219    }
5220
5221    #[test]
5222    fn validate_accepts_typed_sequence_without_restart() {
5223        // Positive control: the canonical typed hot-upgrade authoring
5224        // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
5225        // `:state-change` → `:soft-purge`. Absent `:restart` is the
5226        // only shape that lets the sequence run to completion under
5227        // the wasm-operator's `:from`-match dispatch. Drift here =
5228        // a future tighten that rejects any canonical typed-only shape
5229        // surfaces as a regression at this gate.
5230        let e = entry(
5231            "0.1.0",
5232            vec![
5233                UpgradeInstruction::LoadModule {
5234                    module: "hello-rio".into(),
5235                },
5236                UpgradeInstruction::StateChange {
5237                    script: PathBuf::from("lib/m.lisp"),
5238                },
5239                UpgradeInstruction::SoftPurge {
5240                    module: "hello-rio-old".into(),
5241                },
5242            ],
5243        );
5244        e.validate().unwrap();
5245    }
5246
5247    // ── within-entry state-change-ordering invariant ───────────────────
5248
5249    #[test]
5250    fn validate_rejects_state_change_without_load() {
5251        // Fail-before-pass-after pin: a `:state-change` migrates state
5252        // into the newly-loaded code (gen_server:code_change/3 analog),
5253        // so an entry that runs it with no preceding `:load-module`
5254        // migrates state into code that was never loaded. The operator
5255        // runs instructions in declared order, so this is a build error,
5256        // not a runtime surprise (CAIXA-SDLC §III).
5257        let e = entry(
5258            "0.1.0",
5259            vec![UpgradeInstruction::StateChange {
5260                script: PathBuf::from("lib/m.lisp"),
5261            }],
5262        );
5263        let err = e.validate().unwrap_err();
5264        assert_eq!(
5265            err,
5266            UpgradeError::StateChangeWithoutPriorLoad {
5267                from: "0.1.0".into(),
5268                script: PathBuf::from("lib/m.lisp"),
5269            },
5270            "a `:state-change` with no preceding `:load-module` must surface as \
5271             StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
5272        );
5273    }
5274
5275    #[test]
5276    fn validate_rejects_state_change_before_load() {
5277        // Right-instructions-wrong-order: the load is present but runs
5278        // *after* the migration. Because the operator executes in
5279        // declared order, the migration runs before the new code is
5280        // resident — the same incoherence as the missing-load case.
5281        let e = entry(
5282            "0.1.0",
5283            vec![
5284                UpgradeInstruction::StateChange {
5285                    script: PathBuf::from("lib/m.lisp"),
5286                },
5287                UpgradeInstruction::LoadModule {
5288                    module: "hello-rio".into(),
5289                },
5290            ],
5291        );
5292        let err = e.validate().unwrap_err();
5293        assert!(
5294            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5295            "a `:state-change` ahead of its `:load-module` must surface as \
5296             StateChangeWithoutPriorLoad, got {err:?}"
5297        );
5298    }
5299
5300    #[test]
5301    fn validate_accepts_state_change_after_load() {
5302        // Positive control: the canonical `(:load-module …)
5303        // (:state-change …)` order validates. The load need not name
5304        // the same module the migration targets (StateChange carries a
5305        // script, not a module ref), so any preceding `:load-module`
5306        // satisfies "new code is resident before its migration runs".
5307        let e = entry(
5308            "0.1.0",
5309            vec![
5310                UpgradeInstruction::LoadModule {
5311                    module: "hello-rio".into(),
5312                },
5313                UpgradeInstruction::StateChange {
5314                    script: PathBuf::from("lib/m.lisp"),
5315                },
5316            ],
5317        );
5318        e.validate().unwrap();
5319    }
5320
5321    #[test]
5322    fn validate_accepts_multiple_state_changes_after_one_load() {
5323        // A single leading `:load-module` covers every subsequent
5324        // `:state-change` — the `loaded` latch stays set once the new
5325        // code is resident.
5326        let e = entry(
5327            "0.1.0",
5328            vec![
5329                UpgradeInstruction::LoadModule {
5330                    module: "hello-rio".into(),
5331                },
5332                UpgradeInstruction::StateChange {
5333                    script: PathBuf::from("lib/m1.lisp"),
5334                },
5335                UpgradeInstruction::StateChange {
5336                    script: PathBuf::from("lib/m2.lisp"),
5337                },
5338            ],
5339        );
5340        e.validate().unwrap();
5341    }
5342
5343    #[test]
5344    fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5345     {
5346        // Byte-identity pin on the
5347        // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5348        // migrate ordering dispatch against the pre-lift
5349        // `match instr { UpgradeInstruction::LoadModule { .. } =>
5350        // loaded = true, UpgradeInstruction::StateChange { script } if
5351        // !loaded => …, _ => {} }` open-coded pattern-match the site
5352        // previously carried. Asserts the two projections agree
5353        // byte-for-byte on every arm of the enum — the load-family
5354        // arm-discriminator via `is_load_module()` and the migration-
5355        // family `:script` scalar via `declared_path()` — so a future
5356        // derive regression that flipped the predicate's arm-set (a
5357        // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5358        // a byte-collision flipping a second variant to `true`) or an
5359        // accessor extension that promoted an additional variant onto
5360        // the `PathBuf`-carrying axis would trip here at caixa-core
5361        // test time rather than laundering the arm at the gate's
5362        // per-entry ordering scan far from the derive site.
5363        //
5364        // Peer of the sibling
5365        // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5366        // (c9ce91d) pin on the peer within-entry per-instruction-class
5367        // singularity gate's load-family + `String`-carrying dispatch,
5368        // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5369        // (580d0f1) pin on the paired load → cleanup ordering gate's
5370        // load-family sticky-latch dispatch, and the
5371        // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5372        // pin on the peer within-entry per-instruction-class singularity
5373        // gate's migration-family script-projection dispatch — closes
5374        // the last unlifted `match`-shaped per-arm-hand-rolled load-
5375        // family arm-discriminator + migration-family script-projection
5376        // pair inside `impl UpgradeFromEntry`. The four within-entry
5377        // ordering / singularity gates now share one byte-identity pin
5378        // apiece against their respective substrate-primitive typed
5379        // dispatches on the OTP-appup closed-set enum.
5380        //
5381        // Three-arm projective coverage:
5382        //   (a) `LoadModule` satisfies `is_load_module()`, so the
5383        //       sticky-latch advances byte-equal to the pre-lift
5384        //       `UpgradeInstruction::LoadModule { .. }` arm; every
5385        //       other variant leaves the latch untouched;
5386        //   (b) a `((:state-change …))`-only entry (no preceding load)
5387        //       trips the gate on the first `StateChange` with
5388        //       `StateChangeWithoutPriorLoad` carrying the offending
5389        //       script verbatim — the migration-family script surfaces
5390        //       through `declared_path()` byte-equal to the raw
5391        //       `StateChange { script }` pattern-bound field;
5392        //   (c) a `((:load-module …) (:state-change …))` entry leaves
5393        //       the gate vacuous with `Ok(())` — the `loaded = true`
5394        //       latch on the first arm satisfies the `!loaded` guard
5395        //       negation on the second, so the `declared_path()`
5396        //       `Some(script)` fall-through does not fire — and a
5397        //       non-`StateChange`-non-`LoadModule` sequence
5398        //       (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5399        //       the gate vacuous because `declared_path()` is `None`
5400        //       on all three of those arms.
5401        //
5402        // Fail-before-pass-after verified locally: swapping the
5403        // production `if instr.is_load_module() { loaded = true; }
5404        // else if !loaded && let Some(script) = instr.declared_path()
5405        // { … }` back to `match instr { UpgradeInstruction::LoadModule
5406        // { .. } => loaded = true, UpgradeInstruction::StateChange
5407        // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5408        // passing but silently detaches the gate from the accessor's
5409        // typed dispatch — any future `is_load_module` / `declared_path`
5410        // extension (a hole in either predicate, a promotion of an
5411        // additional variant onto either axis, an operator-side
5412        // pre-resolved-path cache the accessor materializes) would
5413        // then silently disagree between this gate's raw pattern-match
5414        // and the peer per-`UpgradeInstruction` consumers that route
5415        // through the accessor pair.
5416
5417        // (a) is_load_module() partitions the arm-set byte-equal to
5418        //     the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5419        //     { .. })` and declared_path() surfaces the StateChange
5420        //     `:script` byte-equal to the raw field access.
5421        let lm = UpgradeInstruction::LoadModule {
5422            module: "hello-rio".into(),
5423        };
5424        assert!(
5425            lm.is_load_module(),
5426            "LoadModule must satisfy is_load_module() — the gate's \
5427             load-family sticky-latch relies on this partition"
5428        );
5429        assert!(
5430            lm.declared_path().is_none(),
5431            "LoadModule must not carry a declared_path — the gate's \
5432             else-if migration-family arm must not fire on load arms"
5433        );
5434        let sc = UpgradeInstruction::StateChange {
5435            script: PathBuf::from("lib/m.lisp"),
5436        };
5437        assert!(
5438            !sc.is_load_module(),
5439            "StateChange must not satisfy is_load_module() — the gate's \
5440             sticky-latch must not advance on migration arms"
5441        );
5442        assert_eq!(
5443            sc.declared_path().map(std::path::PathBuf::as_path),
5444            Some(PathBuf::from("lib/m.lisp").as_path()),
5445            "declared_path() must project the StateChange :script \
5446             byte-equal to the raw field access — accessor divergence \
5447             would silently detach the gate from the projection every \
5448             peer per-`UpgradeInstruction` consumer routes through"
5449        );
5450
5451        // (b) A `((:state-change …))`-only entry trips
5452        //     StateChangeWithoutPriorLoad byte-identical to the
5453        //     pre-lift match-pattern shape.
5454        let no_prior_load = entry(
5455            "0.1.0",
5456            vec![UpgradeInstruction::StateChange {
5457                script: PathBuf::from("lib/m.lisp"),
5458            }],
5459        );
5460        assert_eq!(
5461            no_prior_load.validate_state_change_ordering(),
5462            Err(UpgradeError::StateChangeWithoutPriorLoad {
5463                from: "0.1.0".into(),
5464                script: PathBuf::from("lib/m.lisp"),
5465            }),
5466            "a `:state-change` with no preceding `:load-module` must fire \
5467             StateChangeWithoutPriorLoad carrying the offending script \
5468             verbatim through the declared_path() accessor"
5469        );
5470
5471        // (c) `((:load-module …) (:state-change …))` leaves the gate
5472        //     vacuous; so does a non-StateChange-non-LoadModule
5473        //     sequence (SoftPurge / Purge / Restart alone).
5474        let load_before_migrate = entry(
5475            "0.1.0",
5476            vec![
5477                UpgradeInstruction::LoadModule {
5478                    module: "hello-rio".into(),
5479                },
5480                UpgradeInstruction::StateChange {
5481                    script: PathBuf::from("lib/m.lisp"),
5482                },
5483            ],
5484        );
5485        assert_eq!(
5486            load_before_migrate.validate_state_change_ordering(),
5487            Ok(()),
5488            "load-before-migrate entries must leave the ordering gate \
5489             vacuous — the `loaded = true` sticky-latch on the first arm \
5490             satisfies the `!loaded` guard negation on the else-if arm"
5491        );
5492        for instr in [
5493            UpgradeInstruction::SoftPurge {
5494                module: "x-old".into(),
5495            },
5496            UpgradeInstruction::Purge {
5497                module: "x-old".into(),
5498            },
5499            UpgradeInstruction::Restart,
5500        ] {
5501            let e = entry("0.1.0", vec![instr.clone()]);
5502            assert_eq!(
5503                e.validate_state_change_ordering(),
5504                Ok(()),
5505                "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5506                 leave the ordering gate vacuous — declared_path() is None \
5507                 on every non-StateChange arm, so the else-if migration-\
5508                 family arm never fires"
5509            );
5510        }
5511    }
5512
5513    #[test]
5514    fn validate_state_change_ordering_fires_after_restart_exclusive() {
5515        // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5516        // shape is *both* state-change-without-load and restart-mixed.
5517        // The more-fundamental `RestartNotExclusive` must win (a valid
5518        // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5519        // entry should reach the ordering gate). Guards the call order
5520        // in `validate` against silent reordering.
5521        let e = entry(
5522            "0.1.0",
5523            vec![
5524                UpgradeInstruction::StateChange {
5525                    script: PathBuf::from("lib/m.lisp"),
5526                },
5527                UpgradeInstruction::Restart,
5528            ],
5529        );
5530        let err = e.validate().unwrap_err();
5531        assert!(
5532            matches!(err, UpgradeError::RestartNotExclusive { .. }),
5533            "restart-mixed must surface before the ordering gate, got {err:?}"
5534        );
5535    }
5536
5537    // ── within-entry purge-ordering invariant ──────────────────────────
5538
5539    #[test]
5540    fn validate_rejects_soft_purge_without_load() {
5541        // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5542        // module after the new one is resident (OTP's two-phase code
5543        // load — code:load_module/1 then code:soft_purge/1), so an
5544        // entry that runs it with no preceding `:load-module` drains
5545        // the live module with no replacement. The operator runs
5546        // instructions in declared order, so this is a build error,
5547        // not a runtime surprise (CAIXA-SDLC §III).
5548        let e = entry(
5549            "0.1.0",
5550            vec![UpgradeInstruction::SoftPurge {
5551                module: "x-old".into(),
5552            }],
5553        );
5554        let err = e.validate().unwrap_err();
5555        assert_eq!(
5556            err,
5557            UpgradeError::PurgeWithoutPriorLoad {
5558                from: "0.1.0".into(),
5559                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5560                module: "x-old".into(),
5561            },
5562            "a `:soft-purge` with no preceding `:load-module` must surface as \
5563             PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5564        );
5565    }
5566
5567    #[test]
5568    fn validate_rejects_purge_without_load() {
5569        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5570        // the more catastrophic peer of `:soft-purge`; same gate, same
5571        // shape, kind-tag differs so the author can grep their
5572        // caixa.lisp for the offending `(:purge …)` form.
5573        let e = entry(
5574            "0.1.0",
5575            vec![UpgradeInstruction::Purge {
5576                module: "x-old".into(),
5577            }],
5578        );
5579        let err = e.validate().unwrap_err();
5580        assert_eq!(
5581            err,
5582            UpgradeError::PurgeWithoutPriorLoad {
5583                from: "0.1.0".into(),
5584                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5585                module: "x-old".into(),
5586            },
5587        );
5588    }
5589
5590    #[test]
5591    fn validate_rejects_soft_purge_before_load() {
5592        // Right-instructions-wrong-order: the load is present but runs
5593        // *after* the purge. Because the operator executes in declared
5594        // order, the cleanup drains the old code before the new code
5595        // is resident — same incoherence as the missing-load case,
5596        // leaving a window during which neither version is available.
5597        let e = entry(
5598            "0.1.0",
5599            vec![
5600                UpgradeInstruction::SoftPurge {
5601                    module: "x-old".into(),
5602                },
5603                UpgradeInstruction::LoadModule { module: "x".into() },
5604            ],
5605        );
5606        let err = e.validate().unwrap_err();
5607        assert!(
5608            matches!(
5609                err,
5610                UpgradeError::PurgeWithoutPriorLoad {
5611                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5612                    ..
5613                }
5614            ),
5615            "a `:soft-purge` ahead of its `:load-module` must surface as \
5616             PurgeWithoutPriorLoad, got {err:?}"
5617        );
5618    }
5619
5620    #[test]
5621    fn validate_rejects_purge_before_load() {
5622        // Symmetric arm on the `:purge` variant — the kind tag
5623        // distinguishes the diagnostic so the author lands on the
5624        // offending form directly.
5625        let e = entry(
5626            "0.1.0",
5627            vec![
5628                UpgradeInstruction::Purge {
5629                    module: "x-old".into(),
5630                },
5631                UpgradeInstruction::LoadModule { module: "x".into() },
5632            ],
5633        );
5634        let err = e.validate().unwrap_err();
5635        assert!(
5636            matches!(
5637                err,
5638                UpgradeError::PurgeWithoutPriorLoad {
5639                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5640                    ..
5641                }
5642            ),
5643            "a `:purge` ahead of its `:load-module` must surface as \
5644             PurgeWithoutPriorLoad, got {err:?}"
5645        );
5646    }
5647
5648    #[test]
5649    fn validate_accepts_soft_purge_after_load() {
5650        // Positive control: the canonical `(:load-module …)
5651        // (:soft-purge …)` order validates. The load need not name the
5652        // same module the purge targets — the cleanup typically targets
5653        // the *old* module name (e.g. `"x-old"`) and the load brings up
5654        // the *new* one (`"x"`); the gate only requires that *some*
5655        // `:load-module` precedes the purge, so the new code is resident
5656        // before the old one is drained.
5657        let e = entry(
5658            "0.1.0",
5659            vec![
5660                UpgradeInstruction::LoadModule { module: "x".into() },
5661                UpgradeInstruction::SoftPurge {
5662                    module: "x-old".into(),
5663                },
5664            ],
5665        );
5666        e.validate().unwrap();
5667    }
5668
5669    #[test]
5670    fn validate_accepts_multiple_purges_after_one_load() {
5671        // A single leading `:load-module` covers every subsequent
5672        // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5673        // the new code is resident. Same shape as
5674        // `validate_accepts_multiple_state_changes_after_one_load` on
5675        // the peer ordering gate.
5676        let e = entry(
5677            "0.1.0",
5678            vec![
5679                UpgradeInstruction::LoadModule { module: "x".into() },
5680                UpgradeInstruction::SoftPurge {
5681                    module: "x-old".into(),
5682                },
5683                UpgradeInstruction::Purge {
5684                    module: "x-oldest".into(),
5685                },
5686            ],
5687        );
5688        e.validate().unwrap();
5689    }
5690
5691    #[test]
5692    fn validate_purge_ordering_fires_after_state_change_ordering() {
5693        // Diagnostic-precedence pin: an entry like `((:state-change …)
5694        // (:soft-purge …))` is *both* state-change-without-load and
5695        // purge-without-load. The state-change gate must win — it's
5696        // the load-bearing semantic on this ordering contract, and
5697        // surfacing the purge diagnostic first would mask the more-
5698        // fundamental migration-against-stale-code defect. Guards the
5699        // call order in `validate` against silent reordering.
5700        let e = entry(
5701            "0.1.0",
5702            vec![
5703                UpgradeInstruction::StateChange {
5704                    script: PathBuf::from("lib/m.lisp"),
5705                },
5706                UpgradeInstruction::SoftPurge {
5707                    module: "x-old".into(),
5708                },
5709            ],
5710        );
5711        let err = e.validate().unwrap_err();
5712        assert!(
5713            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5714            "state-change-without-load must surface before purge-without-load, got {err:?}"
5715        );
5716    }
5717
5718    #[test]
5719    fn validate_purge_ordering_fires_after_per_instr_shape() {
5720        // Order pin: a malformed `:module` value on a `:soft-purge` (an
5721        // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5722        // diagnostic *before* the within-entry purge-ordering gate fires.
5723        // The per-instruction shape pass walks the list inline before
5724        // the ordering checks, so the narrower self-locating diagnostic
5725        // surfaces first — mirrors the empty-first cascade on every peer
5726        // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5727        // per_instr_shape` pin on the sibling ordering gate.
5728        let e = entry(
5729            "0.1.0",
5730            vec![UpgradeInstruction::SoftPurge {
5731                module: String::new(),
5732            }],
5733        );
5734        let err = e.validate().unwrap_err();
5735        assert_eq!(
5736            err,
5737            UpgradeError::ModuleEmpty {
5738                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5739            },
5740            "malformed instruction must surface its kind-tagged diagnostic before the \
5741             purge-ordering gate fires, got {err:?}"
5742        );
5743    }
5744
5745    #[test]
5746    fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5747        // The whole-list entry-point surfaces the per-entry ordering
5748        // error (mirrors
5749        // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5750        // the gate is reachable from the LayoutInvariants call site, not
5751        // only from a direct `entry.validate()`.
5752        let entries = vec![entry(
5753            "0.1.0",
5754            vec![UpgradeInstruction::Purge {
5755                module: "x-old".into(),
5756            }],
5757        )];
5758        let err = validate_upgrade_from(&entries).unwrap_err();
5759        assert!(
5760            matches!(
5761                err,
5762                UpgradeError::PurgeWithoutPriorLoad {
5763                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5764                    ..
5765                }
5766            ),
5767            "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5768        );
5769    }
5770
5771    #[test]
5772    fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5773        // The whole-list entry-point surfaces the per-entry ordering
5774        // error (mirrors `validate_restart_exclusive_threads_through_…`):
5775        // the gate is reachable from the LayoutInvariants call site, not
5776        // only from a direct `entry.validate()`.
5777        let entries = vec![entry(
5778            "0.1.0",
5779            vec![UpgradeInstruction::StateChange {
5780                script: PathBuf::from("lib/m.lisp"),
5781            }],
5782        )];
5783        let err = validate_upgrade_from(&entries).unwrap_err();
5784        assert!(
5785            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5786            "validate_upgrade_from must thread the ordering error, got {err:?}"
5787        );
5788    }
5789
5790    // ── within-entry cleanup-singularity invariant ─────────────────────
5791
5792    #[test]
5793    fn validate_rejects_duplicate_soft_purge_for_same_module() {
5794        // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5795        // its target module (code:soft_purge/1 analog); after the
5796        // first the module is gone, so a second `:soft-purge` of the
5797        // same module is at best a no-op and at worst undefined
5798        // (depending on the operator's handling of a non-resident-
5799        // module purge). Author one cleanup per module.
5800        let e = entry(
5801            "0.1.0",
5802            vec![
5803                UpgradeInstruction::LoadModule { module: "x".into() },
5804                UpgradeInstruction::SoftPurge {
5805                    module: "x-old".into(),
5806                },
5807                UpgradeInstruction::SoftPurge {
5808                    module: "x-old".into(),
5809                },
5810            ],
5811        );
5812        let err = e.validate().unwrap_err();
5813        assert_eq!(
5814            err,
5815            UpgradeError::DuplicateCleanup {
5816                from: "0.1.0".into(),
5817                module: "x-old".into(),
5818                kinds: vec![
5819                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5820                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5821                ],
5822            },
5823            "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5824             module + both kinds in declaration order, got {err:?}"
5825        );
5826    }
5827
5828    #[test]
5829    fn validate_rejects_duplicate_purge_for_same_module() {
5830        // Per-arm coverage: `:purge` (immediate discard, no drain) is
5831        // the more catastrophic peer of `:soft-purge`; same gate, same
5832        // shape, kind-tag distinguishes so the author can grep their
5833        // caixa.lisp for the offending `(:purge …)` form.
5834        let e = entry(
5835            "0.1.0",
5836            vec![
5837                UpgradeInstruction::LoadModule { module: "x".into() },
5838                UpgradeInstruction::Purge {
5839                    module: "x-old".into(),
5840                },
5841                UpgradeInstruction::Purge {
5842                    module: "x-old".into(),
5843                },
5844            ],
5845        );
5846        let err = e.validate().unwrap_err();
5847        assert_eq!(
5848            err,
5849            UpgradeError::DuplicateCleanup {
5850                from: "0.1.0".into(),
5851                module: "x-old".into(),
5852                kinds: vec![
5853                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5854                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5855                ],
5856            },
5857        );
5858    }
5859
5860    #[test]
5861    fn validate_rejects_soft_purge_then_purge_for_same_module() {
5862        // Soft-then-hard footgun: the author wrote "drain, and if
5863        // drain doesn't clean up, force-discard", but the operator
5864        // runs declared instructions unconditionally — the `:purge`
5865        // fires whether the `:soft-purge` already discarded the
5866        // module or not, so the imagined fallback semantic is
5867        // missing. Fallback on cleanup failure is the operator's
5868        // job, not authored into the entry. Both kinds carry in
5869        // declaration order so the author can grep for either side
5870        // and pick one.
5871        let e = entry(
5872            "0.1.0",
5873            vec![
5874                UpgradeInstruction::LoadModule { module: "x".into() },
5875                UpgradeInstruction::SoftPurge {
5876                    module: "x-old".into(),
5877                },
5878                UpgradeInstruction::Purge {
5879                    module: "x-old".into(),
5880                },
5881            ],
5882        );
5883        let err = e.validate().unwrap_err();
5884        assert_eq!(
5885            err,
5886            UpgradeError::DuplicateCleanup {
5887                from: "0.1.0".into(),
5888                module: "x-old".into(),
5889                kinds: vec![
5890                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5891                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5892                ],
5893            },
5894        );
5895    }
5896
5897    #[test]
5898    fn validate_rejects_purge_then_soft_purge_for_same_module() {
5899        // Reversed-ordering arm: `:purge` discards immediately; the
5900        // trailing `:soft-purge` has no module to drain. The kinds
5901        // list reflects declaration order so the diagnostic locates
5902        // both forms in the source.
5903        let e = entry(
5904            "0.1.0",
5905            vec![
5906                UpgradeInstruction::LoadModule { module: "x".into() },
5907                UpgradeInstruction::Purge {
5908                    module: "x-old".into(),
5909                },
5910                UpgradeInstruction::SoftPurge {
5911                    module: "x-old".into(),
5912                },
5913            ],
5914        );
5915        let err = e.validate().unwrap_err();
5916        assert_eq!(
5917            err,
5918            UpgradeError::DuplicateCleanup {
5919                from: "0.1.0".into(),
5920                module: "x-old".into(),
5921                kinds: vec![
5922                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5923                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5924                ],
5925            },
5926        );
5927    }
5928
5929    #[test]
5930    fn validate_accepts_distinct_cleanup_modules() {
5931        // Positive control: `:soft-purge` and `:purge` on *different*
5932        // modules pass the gate. Mirrors
5933        // `validate_accepts_multiple_purges_after_one_load` — the
5934        // cleanup-singularity gate is keyed on (module), not on
5935        // (kind, module) pair, so distinct old-version names render
5936        // distinct cleanup targets and don't collide. Sweep both
5937        // same-class (two `:soft-purge` distinct modules) and cross-
5938        // class (`:soft-purge` then `:purge` distinct modules) so a
5939        // future tighten to a kind-only key (which would over-fire on
5940        // distinct modules) surfaces here.
5941        let two_soft = entry(
5942            "0.1.0",
5943            vec![
5944                UpgradeInstruction::LoadModule { module: "x".into() },
5945                UpgradeInstruction::SoftPurge {
5946                    module: "x-old".into(),
5947                },
5948                UpgradeInstruction::SoftPurge {
5949                    module: "x-older".into(),
5950                },
5951            ],
5952        );
5953        two_soft.validate().unwrap();
5954        let mixed = entry(
5955            "0.1.0",
5956            vec![
5957                UpgradeInstruction::LoadModule { module: "x".into() },
5958                UpgradeInstruction::SoftPurge {
5959                    module: "x-old".into(),
5960                },
5961                UpgradeInstruction::Purge {
5962                    module: "x-oldest".into(),
5963                },
5964            ],
5965        );
5966        mixed.validate().unwrap();
5967    }
5968
5969    #[test]
5970    fn validate_accepts_single_cleanup_per_module() {
5971        // Boundary control: a list with exactly one `:soft-purge` and
5972        // one `:purge` (distinct modules, the canonical "drain one,
5973        // hard-discard the other" shape) is the gate's identity
5974        // element. Pin so a future off-by-one in the duplicate-detection
5975        // scan doesn't accidentally flag a single occurrence as
5976        // duplicating itself — mirrors
5977        // `validate_upgrade_from_single_entry_never_duplicates` on
5978        // the peer cross-entry duplicate axis.
5979        let e = entry(
5980            "0.1.0",
5981            vec![
5982                UpgradeInstruction::LoadModule { module: "x".into() },
5983                UpgradeInstruction::SoftPurge {
5984                    module: "x-old".into(),
5985                },
5986                UpgradeInstruction::Purge {
5987                    module: "y-old".into(),
5988                },
5989            ],
5990        );
5991        e.validate().unwrap();
5992    }
5993
5994    #[test]
5995    fn validate_cleanup_singularity_fires_after_purge_ordering() {
5996        // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5997        // (:soft-purge "x"))` is *both* purge-without-load and
5998        // duplicate-cleanup. The more-fundamental ordering gate must
5999        // win — the missing-load defect is load-bearing (the canonical
6000        // OTP shape requires the new code be resident before any
6001        // cleanup runs), and surfacing the duplicate diagnostic first
6002        // would mask the no-replacement-window defect the ordering
6003        // gate exists to close. Guards the call order in `validate`
6004        // against silent reordering. Same posture as
6005        // `validate_purge_ordering_fires_after_state_change_ordering`
6006        // on the sibling ordering gate.
6007        let e = entry(
6008            "0.1.0",
6009            vec![
6010                UpgradeInstruction::SoftPurge {
6011                    module: "x-old".into(),
6012                },
6013                UpgradeInstruction::SoftPurge {
6014                    module: "x-old".into(),
6015                },
6016            ],
6017        );
6018        let err = e.validate().unwrap_err();
6019        assert!(
6020            matches!(
6021                err,
6022                UpgradeError::PurgeWithoutPriorLoad {
6023                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6024                    ..
6025                }
6026            ),
6027            "purge-without-load must surface before duplicate-cleanup, got {err:?}"
6028        );
6029    }
6030
6031    #[test]
6032    fn validate_cleanup_singularity_fires_after_per_instr_shape() {
6033        // Order pin: a malformed `:module` value on a `:soft-purge`
6034        // (an empty string) surfaces its narrower kind-tagged
6035        // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
6036        // singularity gate fires. The per-instruction shape pass walks
6037        // the list inline before the singularity check, so the
6038        // narrower self-locating diagnostic surfaces first — mirrors
6039        // the empty-first cascade on every peer DNS-1123 gate and the
6040        // `validate_purge_ordering_fires_after_per_instr_shape` pin on
6041        // the sibling ordering gate.
6042        //
6043        // Two empty-string `:soft-purge` would *otherwise* duplicate
6044        // (both modules are the same empty string), so this pin
6045        // double-locks the precedence: the per-instr shape gate must
6046        // win on the first malformed instruction before the duplicate
6047        // scan even reaches the second.
6048        let e = entry(
6049            "0.1.0",
6050            vec![
6051                UpgradeInstruction::LoadModule { module: "x".into() },
6052                UpgradeInstruction::SoftPurge {
6053                    module: String::new(),
6054                },
6055                UpgradeInstruction::SoftPurge {
6056                    module: String::new(),
6057                },
6058            ],
6059        );
6060        let err = e.validate().unwrap_err();
6061        assert_eq!(
6062            err,
6063            UpgradeError::ModuleEmpty {
6064                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
6065            },
6066            "malformed instruction must surface its kind-tagged diagnostic before the \
6067             cleanup-singularity gate fires, got {err:?}"
6068        );
6069    }
6070
6071    #[test]
6072    fn validate_cleanup_singularity_reports_first_collision() {
6073        // Determinism pin: with three cleanups of the same module the
6074        // gate reports the *first* collision (the second occurrence)
6075        // and stops — the third's duplicate is masked by the first
6076        // surfaced one. Mirrors
6077        // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
6078        // on the peer cross-entry duplicate axis.
6079        let e = entry(
6080            "0.1.0",
6081            vec![
6082                UpgradeInstruction::LoadModule { module: "x".into() },
6083                UpgradeInstruction::SoftPurge {
6084                    module: "x-old".into(),
6085                },
6086                UpgradeInstruction::SoftPurge {
6087                    module: "x-old".into(),
6088                },
6089                UpgradeInstruction::Purge {
6090                    module: "x-old".into(),
6091                },
6092            ],
6093        );
6094        let err = e.validate().unwrap_err();
6095        assert_eq!(
6096            err,
6097            UpgradeError::DuplicateCleanup {
6098                from: "0.1.0".into(),
6099                module: "x-old".into(),
6100                kinds: vec![
6101                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6102                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6103                ],
6104            },
6105            "the first colliding pair must surface, not the later `:purge` collision"
6106        );
6107    }
6108
6109    #[test]
6110    fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
6111        // The whole-list entry-point surfaces the per-entry singularity
6112        // error (mirrors
6113        // `validate_purge_ordering_threads_through_validate_upgrade_from`):
6114        // the gate is reachable from the LayoutInvariants call site,
6115        // not only from a direct `entry.validate()`.
6116        let entries = vec![entry(
6117            "0.1.0",
6118            vec![
6119                UpgradeInstruction::LoadModule { module: "x".into() },
6120                UpgradeInstruction::SoftPurge {
6121                    module: "x-old".into(),
6122                },
6123                UpgradeInstruction::Purge {
6124                    module: "x-old".into(),
6125                },
6126            ],
6127        )];
6128        let err = validate_upgrade_from(&entries).unwrap_err();
6129        assert!(
6130            matches!(err, UpgradeError::DuplicateCleanup { .. }),
6131            "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
6132        );
6133    }
6134
6135    #[test]
6136    fn validate_rejects_duplicate_load_module_for_same_module() {
6137        // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
6138        // §II.4): each module is loaded exactly once per upgrade entry,
6139        // the operator's dispatch table reads the module name to bind
6140        // the wasm component, and a second `(:load-module "x")` re-reads
6141        // the same module name and re-binds the same component — a
6142        // no-op the second time. systools-generated `.relup` files emit
6143        // at most one `load_module` per module per upgrade step for
6144        // this reason. Author one `(:load-module "x")` per old module.
6145        let e = entry(
6146            "0.1.0",
6147            vec![
6148                UpgradeInstruction::LoadModule { module: "x".into() },
6149                UpgradeInstruction::LoadModule { module: "x".into() },
6150            ],
6151        );
6152        let err = e.validate().unwrap_err();
6153        assert_eq!(
6154            err,
6155            UpgradeError::DuplicateLoadModule {
6156                from: "0.1.0".into(),
6157                module: "x".into(),
6158            },
6159            "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
6160             the module, got {err:?}"
6161        );
6162    }
6163
6164    #[test]
6165    fn validate_accepts_distinct_load_modules() {
6166        // Positive control: `:load-module` instructions on *different*
6167        // modules pass the gate. Mirrors
6168        // `validate_accepts_distinct_cleanup_modules` on the sibling
6169        // singularity axis — the load-singularity gate is keyed on
6170        // (module), so distinct module names render distinct load
6171        // targets and don't collide. Sweep both the bare two-load shape
6172        // and the canonical load-pair-with-cleanup shape so a future
6173        // tighten that over-fires on distinct loads surfaces here.
6174        let two_loads = entry(
6175            "0.1.0",
6176            vec![
6177                UpgradeInstruction::LoadModule { module: "x".into() },
6178                UpgradeInstruction::LoadModule { module: "y".into() },
6179            ],
6180        );
6181        two_loads.validate().unwrap();
6182        let with_cleanup = entry(
6183            "0.1.0",
6184            vec![
6185                UpgradeInstruction::LoadModule { module: "x".into() },
6186                UpgradeInstruction::LoadModule { module: "y".into() },
6187                UpgradeInstruction::SoftPurge {
6188                    module: "x-old".into(),
6189                },
6190                UpgradeInstruction::SoftPurge {
6191                    module: "y-old".into(),
6192                },
6193            ],
6194        );
6195        with_cleanup.validate().unwrap();
6196    }
6197
6198    #[test]
6199    fn validate_accepts_single_load_per_module() {
6200        // Boundary control: a list with exactly one `:load-module`
6201        // followed by the canonical `:state-change` + `:soft-purge`
6202        // sequence (the module-doc OTP shape) is the gate's identity
6203        // element. Pin so a future off-by-one in the duplicate-
6204        // detection scan doesn't accidentally flag a single occurrence
6205        // as duplicating itself — mirrors
6206        // `validate_accepts_single_cleanup_per_module` on the sibling
6207        // singularity axis.
6208        let e = entry(
6209            "0.1.0",
6210            vec![
6211                UpgradeInstruction::LoadModule { module: "x".into() },
6212                UpgradeInstruction::StateChange {
6213                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6214                },
6215                UpgradeInstruction::SoftPurge {
6216                    module: "x-old".into(),
6217                },
6218            ],
6219        );
6220        e.validate().unwrap();
6221    }
6222
6223    #[test]
6224    fn validate_load_singularity_fires_after_state_change_ordering() {
6225        // Diagnostic-precedence pin: an entry like `((:state-change
6226        // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
6227        // state-change-without-load and duplicate-load. The more-
6228        // fundamental ordering gate must win — the missing-load defect
6229        // is load-bearing (the migration runs against unloaded code),
6230        // and surfacing the duplicate diagnostic first would mask the
6231        // migrate-into-unloaded-code defect the ordering gate exists
6232        // to close. Guards the call order in `validate` against silent
6233        // reordering. Same posture as
6234        // `validate_cleanup_singularity_fires_after_purge_ordering`
6235        // on the sibling singularity gate.
6236        let e = entry(
6237            "0.1.0",
6238            vec![
6239                UpgradeInstruction::StateChange {
6240                    script: PathBuf::from("lib/m.lisp"),
6241                },
6242                UpgradeInstruction::LoadModule { module: "x".into() },
6243                UpgradeInstruction::LoadModule { module: "x".into() },
6244            ],
6245        );
6246        let err = e.validate().unwrap_err();
6247        assert!(
6248            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6249            "state-change-without-load must surface before duplicate-load, got {err:?}"
6250        );
6251    }
6252
6253    #[test]
6254    fn validate_load_singularity_fires_after_purge_ordering() {
6255        // Diagnostic-precedence pin: an entry like `((:soft-purge
6256        // "x-old") (:load-module "x") (:load-module "x"))` is *both*
6257        // purge-without-load and duplicate-load. The more-fundamental
6258        // ordering gate must win — the missing-load defect is load-
6259        // bearing (the cleanup runs against no-replacement-window),
6260        // and surfacing the duplicate diagnostic first would mask the
6261        // drain-to-nothing defect the ordering gate exists to close.
6262        // Sibling of
6263        // `validate_cleanup_singularity_fires_after_purge_ordering` on
6264        // the load-singularity axis.
6265        let e = entry(
6266            "0.1.0",
6267            vec![
6268                UpgradeInstruction::SoftPurge {
6269                    module: "x-old".into(),
6270                },
6271                UpgradeInstruction::LoadModule { module: "x".into() },
6272                UpgradeInstruction::LoadModule { module: "x".into() },
6273            ],
6274        );
6275        let err = e.validate().unwrap_err();
6276        assert!(
6277            matches!(
6278                err,
6279                UpgradeError::PurgeWithoutPriorLoad {
6280                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6281                    ..
6282                }
6283            ),
6284            "purge-without-load must surface before duplicate-load, got {err:?}"
6285        );
6286    }
6287
6288    #[test]
6289    fn validate_load_singularity_fires_after_per_instr_shape() {
6290        // Order pin: a malformed `:module` value on a `:load-module`
6291        // (an empty string) surfaces its narrower kind-tagged
6292        // `ModuleEmpty` diagnostic *before* the within-entry load-
6293        // singularity gate fires. The per-instruction shape pass walks
6294        // the list inline before the singularity check, so the
6295        // narrower self-locating diagnostic surfaces first — mirrors
6296        // the empty-first cascade on every peer DNS-1123 gate and the
6297        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6298        // pin on the sibling singularity gate.
6299        //
6300        // Two empty-string `:load-module` would *otherwise* duplicate
6301        // (both modules are the same empty string), so this pin
6302        // double-locks the precedence: the per-instr shape gate must
6303        // win on the first malformed instruction before the duplicate
6304        // scan even reaches the second.
6305        let e = entry(
6306            "0.1.0",
6307            vec![
6308                UpgradeInstruction::LoadModule {
6309                    module: String::new(),
6310                },
6311                UpgradeInstruction::LoadModule {
6312                    module: String::new(),
6313                },
6314            ],
6315        );
6316        let err = e.validate().unwrap_err();
6317        assert_eq!(
6318            err,
6319            UpgradeError::ModuleEmpty {
6320                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6321            },
6322            "malformed instruction must surface its kind-tagged diagnostic before the \
6323             load-singularity gate fires, got {err:?}"
6324        );
6325    }
6326
6327    #[test]
6328    fn validate_load_singularity_fires_before_cleanup_singularity() {
6329        // Diagnostic-precedence pin: an entry that violates *both*
6330        // singularities — duplicate load on "x" *and* duplicate cleanup
6331        // on "y-old" — must surface the load-side diagnostic first.
6332        // The load axis precedes the cleanup axis in the canonical OTP
6333        // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6334        // in [`UpgradeInstruction`] declaration order (LoadModule
6335        // before SoftPurge/Purge), so the load-side singularity is the
6336        // load-bearing diagnostic when both fire — the cleanup-side
6337        // duplicate is meaningless either way without a coherent load.
6338        // Guards the call order in `validate`: `validate_load_singularity`
6339        // runs before `validate_cleanup_singularity`.
6340        let e = entry(
6341            "0.1.0",
6342            vec![
6343                UpgradeInstruction::LoadModule { module: "x".into() },
6344                UpgradeInstruction::LoadModule { module: "x".into() },
6345                UpgradeInstruction::SoftPurge {
6346                    module: "y-old".into(),
6347                },
6348                UpgradeInstruction::SoftPurge {
6349                    module: "y-old".into(),
6350                },
6351            ],
6352        );
6353        let err = e.validate().unwrap_err();
6354        assert_eq!(
6355            err,
6356            UpgradeError::DuplicateLoadModule {
6357                from: "0.1.0".into(),
6358                module: "x".into(),
6359            },
6360            "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6361        );
6362    }
6363
6364    #[test]
6365    fn validate_load_singularity_reports_first_collision() {
6366        // Determinism pin: with three loads of the same module the gate
6367        // reports the *first* collision (the second occurrence) and
6368        // stops — the third's duplicate is masked by the first surfaced
6369        // one. Mirrors
6370        // `validate_cleanup_singularity_reports_first_collision` on the
6371        // sibling singularity axis and every peer duplicate gate's
6372        // first-collision discipline.
6373        let e = entry(
6374            "0.1.0",
6375            vec![
6376                UpgradeInstruction::LoadModule { module: "x".into() },
6377                UpgradeInstruction::LoadModule { module: "x".into() },
6378                UpgradeInstruction::LoadModule { module: "x".into() },
6379            ],
6380        );
6381        let err = e.validate().unwrap_err();
6382        assert_eq!(
6383            err,
6384            UpgradeError::DuplicateLoadModule {
6385                from: "0.1.0".into(),
6386                module: "x".into(),
6387            },
6388            "the first colliding occurrence must surface, not the later third-load collision"
6389        );
6390    }
6391
6392    #[test]
6393    fn validate_load_singularity_threads_through_validate_upgrade_from() {
6394        // The whole-list entry-point surfaces the per-entry singularity
6395        // error (mirrors
6396        // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6397        // the gate is reachable from the LayoutInvariants call site,
6398        // not only from a direct `entry.validate()`.
6399        let entries = vec![entry(
6400            "0.1.0",
6401            vec![
6402                UpgradeInstruction::LoadModule { module: "x".into() },
6403                UpgradeInstruction::LoadModule { module: "x".into() },
6404            ],
6405        )];
6406        let err = validate_upgrade_from(&entries).unwrap_err();
6407        assert!(
6408            matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6409            "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6410        );
6411    }
6412
6413    // ── within-entry state-change-singularity invariant ────────────────
6414
6415    #[test]
6416    fn validate_rejects_duplicate_state_change_for_same_script() {
6417        // `StateChange` is the `gen_server:code_change/3` analog
6418        // (INSPIRATIONS §II.4): the script folds the prior-version
6419        // state shape into the current-version shape — a one-shot
6420        // transition, not a step that composes with itself. OTP's
6421        // release_handler invokes `code_change/3` exactly once per
6422        // upgrade per gen_server; systools-generated `.relup` files
6423        // emit at most one `code_change` per gen_server per upgrade
6424        // step for this reason. A second `(:state-change "m.lisp")`
6425        // re-runs the same fold on the already-migrated state — at
6426        // best a no-op and at worst silent state corruption from
6427        // double-applied non-idempotent transforms (`add column`,
6428        // `increment counter`, `rename field`). Author one
6429        // `(:state-change "m.lisp")` per migration script per entry.
6430        let e = entry(
6431            "0.1.0",
6432            vec![
6433                UpgradeInstruction::LoadModule { module: "x".into() },
6434                UpgradeInstruction::StateChange {
6435                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6436                },
6437                UpgradeInstruction::StateChange {
6438                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6439                },
6440            ],
6441        );
6442        let err = e.validate().unwrap_err();
6443        assert_eq!(
6444            err,
6445            UpgradeError::DuplicateStateChange {
6446                from: "0.1.0".into(),
6447                script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6448            },
6449            "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6450             the script, got {err:?}"
6451        );
6452    }
6453
6454    #[test]
6455    fn validate_accepts_distinct_state_change_scripts() {
6456        // Positive control: `:state-change` instructions on *different*
6457        // scripts pass the gate. Mirrors
6458        // `validate_accepts_distinct_cleanup_modules` /
6459        // `validate_accepts_distinct_load_modules` on the sibling
6460        // singularity axes — the state-change-singularity gate is keyed
6461        // on the script PathBuf, so distinct scripts render distinct
6462        // migration targets and don't collide. Sweep both the bare two-
6463        // migration shape and the canonical load-pair-with-cleanup shape
6464        // so a future tighten that over-fires on distinct scripts
6465        // surfaces here. This positive control is the gate-level peer of
6466        // `validate_accepts_multiple_state_changes_after_one_load` (the
6467        // ordering-gate positive control on distinct scripts), pinned
6468        // here independently so a future refactor that decouples the
6469        // gates can't accidentally drop coverage on either.
6470        let two_migrations = entry(
6471            "0.1.0",
6472            vec![
6473                UpgradeInstruction::LoadModule { module: "x".into() },
6474                UpgradeInstruction::StateChange {
6475                    script: PathBuf::from("lib/m1.lisp"),
6476                },
6477                UpgradeInstruction::StateChange {
6478                    script: PathBuf::from("lib/m2.lisp"),
6479                },
6480            ],
6481        );
6482        two_migrations.validate().unwrap();
6483        let with_cleanup = entry(
6484            "0.1.0",
6485            vec![
6486                UpgradeInstruction::LoadModule { module: "x".into() },
6487                UpgradeInstruction::StateChange {
6488                    script: PathBuf::from("lib/m1.lisp"),
6489                },
6490                UpgradeInstruction::StateChange {
6491                    script: PathBuf::from("lib/m2.lisp"),
6492                },
6493                UpgradeInstruction::SoftPurge {
6494                    module: "x-old".into(),
6495                },
6496            ],
6497        );
6498        with_cleanup.validate().unwrap();
6499    }
6500
6501    #[test]
6502    fn validate_accepts_single_state_change_per_script() {
6503        // Boundary control: a list with exactly one `:state-change`
6504        // wrapped by the canonical `:load-module` + `:soft-purge`
6505        // sequence (the module-doc OTP shape) is the gate's identity
6506        // element. Pin so a future off-by-one in the duplicate-
6507        // detection scan doesn't accidentally flag a single occurrence
6508        // as duplicating itself — mirrors
6509        // `validate_accepts_single_load_per_module` /
6510        // `validate_accepts_single_cleanup_per_module` on the sibling
6511        // singularity axes.
6512        let e = entry(
6513            "0.1.0",
6514            vec![
6515                UpgradeInstruction::LoadModule { module: "x".into() },
6516                UpgradeInstruction::StateChange {
6517                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6518                },
6519                UpgradeInstruction::SoftPurge {
6520                    module: "x-old".into(),
6521                },
6522            ],
6523        );
6524        e.validate().unwrap();
6525    }
6526
6527    #[test]
6528    fn validate_state_change_singularity_fires_after_state_change_ordering() {
6529        // Diagnostic-precedence pin: an entry like `((:state-change
6530        // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6531        // without-load and duplicate-state-change. The more-fundamental
6532        // ordering gate must win — the missing-load defect is load-
6533        // bearing (the migration runs against unloaded code), and
6534        // surfacing the duplicate diagnostic first would mask the
6535        // migrate-into-unloaded-code defect the ordering gate exists to
6536        // close. Guards the call order in `validate` against silent
6537        // reordering. Same posture as
6538        // `validate_load_singularity_fires_after_state_change_ordering`
6539        // on the sibling singularity gate.
6540        //
6541        // Two same-script `:state-change` would *otherwise* duplicate
6542        // (both scripts collide on the very first `:state-change`-
6543        // without-load encountered), so this pin double-locks the
6544        // precedence: the ordering gate must win on the first un-loaded
6545        // `:state-change` before the singularity scan even reaches the
6546        // second.
6547        let e = entry(
6548            "0.1.0",
6549            vec![
6550                UpgradeInstruction::StateChange {
6551                    script: PathBuf::from("lib/m.lisp"),
6552                },
6553                UpgradeInstruction::StateChange {
6554                    script: PathBuf::from("lib/m.lisp"),
6555                },
6556            ],
6557        );
6558        let err = e.validate().unwrap_err();
6559        assert!(
6560            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6561            "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6562        );
6563    }
6564
6565    #[test]
6566    fn validate_state_change_singularity_fires_after_purge_ordering() {
6567        // Diagnostic-precedence pin: an entry like `((:soft-purge
6568        // "x-old") (:load-module "x") (:state-change "m.lisp")
6569        // (:state-change "m.lisp"))` is *both* purge-without-load and
6570        // duplicate-state-change. The more-fundamental ordering gate
6571        // must win — the missing-load defect (a cleanup that drains the
6572        // only resident version to nothing) is load-bearing, and
6573        // surfacing the duplicate diagnostic first would mask the
6574        // drain-to-nothing defect the ordering gate exists to close.
6575        // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6576        // on the state-change-singularity axis.
6577        let e = entry(
6578            "0.1.0",
6579            vec![
6580                UpgradeInstruction::SoftPurge {
6581                    module: "x-old".into(),
6582                },
6583                UpgradeInstruction::LoadModule { module: "x".into() },
6584                UpgradeInstruction::StateChange {
6585                    script: PathBuf::from("lib/m.lisp"),
6586                },
6587                UpgradeInstruction::StateChange {
6588                    script: PathBuf::from("lib/m.lisp"),
6589                },
6590            ],
6591        );
6592        let err = e.validate().unwrap_err();
6593        assert!(
6594            matches!(
6595                err,
6596                UpgradeError::PurgeWithoutPriorLoad {
6597                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6598                    ..
6599                }
6600            ),
6601            "purge-without-load must surface before duplicate-state-change, got {err:?}"
6602        );
6603    }
6604
6605    #[test]
6606    fn validate_state_change_singularity_fires_after_per_instr_shape() {
6607        // Order pin: a malformed `:script` value on a `:state-change`
6608        // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6609        // *before* the within-entry state-change-singularity gate fires.
6610        // The per-instruction shape pass walks the list inline before
6611        // the singularity check, so the narrower self-locating
6612        // diagnostic surfaces first — mirrors the empty-first cascade on
6613        // every peer path-shape gate and the
6614        // `validate_load_singularity_fires_after_per_instr_shape` /
6615        // `validate_cleanup_singularity_fires_after_per_instr_shape`
6616        // pins on the sibling singularity gates.
6617        //
6618        // Two empty-path `:state-change` would *otherwise* duplicate
6619        // (both scripts are the same empty PathBuf), so this pin double-
6620        // locks the precedence: the per-instr shape gate must win on the
6621        // first malformed instruction before the duplicate scan even
6622        // reaches the second.
6623        let e = entry(
6624            "0.1.0",
6625            vec![
6626                UpgradeInstruction::LoadModule { module: "x".into() },
6627                UpgradeInstruction::StateChange {
6628                    script: PathBuf::new(),
6629                },
6630                UpgradeInstruction::StateChange {
6631                    script: PathBuf::new(),
6632                },
6633            ],
6634        );
6635        let err = e.validate().unwrap_err();
6636        assert_eq!(
6637            err,
6638            UpgradeError::EmptyScript,
6639            "malformed instruction must surface its narrower diagnostic before the \
6640             state-change-singularity gate fires, got {err:?}"
6641        );
6642    }
6643
6644    #[test]
6645    fn validate_state_change_singularity_fires_after_load_singularity() {
6646        // Diagnostic-precedence pin: an entry that violates *both*
6647        // singularities — duplicate load on "x" *and* duplicate
6648        // state-change on "m.lisp" — must surface the load-side
6649        // diagnostic first. The load axis precedes the migration axis
6650        // in the canonical OTP sequence (`code:load_module/1` then
6651        // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6652        // declaration order (LoadModule before StateChange), so the
6653        // load-side singularity is the load-bearing diagnostic when
6654        // both fire — the migration-side duplicate is meaningless
6655        // either way without a coherent load. Guards the call order in
6656        // `validate`: `validate_load_singularity` runs before
6657        // `validate_state_change_singularity`.
6658        let e = entry(
6659            "0.1.0",
6660            vec![
6661                UpgradeInstruction::LoadModule { module: "x".into() },
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_eq!(
6673            err,
6674            UpgradeError::DuplicateLoadModule {
6675                from: "0.1.0".into(),
6676                module: "x".into(),
6677            },
6678            "duplicate-load must surface before duplicate-state-change, got {err:?}"
6679        );
6680    }
6681
6682    #[test]
6683    fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6684        // Diagnostic-precedence pin: an entry that violates *both*
6685        // singularities — duplicate state-change on "m.lisp" *and*
6686        // duplicate cleanup on "y-old" — must surface the migration-
6687        // side diagnostic first. The migration axis precedes the
6688        // cleanup axis in the canonical OTP sequence
6689        // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6690        // [`UpgradeInstruction`] declaration order (StateChange before
6691        // SoftPurge/Purge), so the migration-side singularity is the
6692        // load-bearing diagnostic when both fire — the cleanup-side
6693        // duplicate is irrelevant once the migration has corrupted
6694        // state by double-applying. Guards the call order in
6695        // `validate`: `validate_state_change_singularity` runs before
6696        // `validate_cleanup_singularity`.
6697        let e = entry(
6698            "0.1.0",
6699            vec![
6700                UpgradeInstruction::LoadModule { module: "x".into() },
6701                UpgradeInstruction::StateChange {
6702                    script: PathBuf::from("lib/m.lisp"),
6703                },
6704                UpgradeInstruction::StateChange {
6705                    script: PathBuf::from("lib/m.lisp"),
6706                },
6707                UpgradeInstruction::SoftPurge {
6708                    module: "y-old".into(),
6709                },
6710                UpgradeInstruction::SoftPurge {
6711                    module: "y-old".into(),
6712                },
6713            ],
6714        );
6715        let err = e.validate().unwrap_err();
6716        assert_eq!(
6717            err,
6718            UpgradeError::DuplicateStateChange {
6719                from: "0.1.0".into(),
6720                script: PathBuf::from("lib/m.lisp"),
6721            },
6722            "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6723        );
6724    }
6725
6726    #[test]
6727    fn validate_state_change_singularity_reports_first_collision() {
6728        // Determinism pin: with three state-changes on the same script
6729        // the gate reports the *first* collision (the second
6730        // occurrence) and stops — the third's duplicate is masked by
6731        // the first surfaced one. Mirrors
6732        // `validate_load_singularity_reports_first_collision` /
6733        // `validate_cleanup_singularity_reports_first_collision` on the
6734        // sibling singularity axes and every peer duplicate gate's
6735        // first-collision discipline.
6736        let e = entry(
6737            "0.1.0",
6738            vec![
6739                UpgradeInstruction::LoadModule { module: "x".into() },
6740                UpgradeInstruction::StateChange {
6741                    script: PathBuf::from("lib/m.lisp"),
6742                },
6743                UpgradeInstruction::StateChange {
6744                    script: PathBuf::from("lib/m.lisp"),
6745                },
6746                UpgradeInstruction::StateChange {
6747                    script: PathBuf::from("lib/m.lisp"),
6748                },
6749            ],
6750        );
6751        let err = e.validate().unwrap_err();
6752        assert_eq!(
6753            err,
6754            UpgradeError::DuplicateStateChange {
6755                from: "0.1.0".into(),
6756                script: PathBuf::from("lib/m.lisp"),
6757            },
6758            "the first colliding occurrence must surface, not the later third-migration collision"
6759        );
6760    }
6761
6762    #[test]
6763    fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6764        // The whole-list entry-point surfaces the per-entry singularity
6765        // error (mirrors
6766        // `validate_load_singularity_threads_through_validate_upgrade_from`
6767        // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6768        // the gate is reachable from the LayoutInvariants call site,
6769        // not only from a direct `entry.validate()`.
6770        let entries = vec![entry(
6771            "0.1.0",
6772            vec![
6773                UpgradeInstruction::LoadModule { module: "x".into() },
6774                UpgradeInstruction::StateChange {
6775                    script: PathBuf::from("lib/m.lisp"),
6776                },
6777                UpgradeInstruction::StateChange {
6778                    script: PathBuf::from("lib/m.lisp"),
6779                },
6780            ],
6781        )];
6782        let err = validate_upgrade_from(&entries).unwrap_err();
6783        assert!(
6784            matches!(err, UpgradeError::DuplicateStateChange { .. }),
6785            "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6786        );
6787    }
6788
6789    #[test]
6790    fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6791        // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6792        // per-instruction `StateChange`-arm script-path projection must
6793        // route through the sibling lifted
6794        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6795        // accessor, not the raw
6796        // `match instr { UpgradeInstruction::StateChange { script } =>
6797        // script.as_path(), _ => continue }` open-coded pattern-match
6798        // the gate previously carried.
6799        //
6800        // Structurally: the gate's projection accept-set is the union
6801        // of every [`UpgradeInstruction`] variant for which
6802        // `declared_path().is_some()` — today exactly
6803        // [`UpgradeInstruction::StateChange`] per the sibling
6804        // `declared_path_only_for_state_change` pin, so a
6805        // duplicate-scripts input trips `DuplicateStateChange` and a
6806        // non-`StateChange` input (module-bearing / terminal) leaves
6807        // `seen` empty and the gate returns `Ok(())` byte-identical to
6808        // the pattern-match shape.
6809        //
6810        // Byte-equal today (`declared_path` returns `Some(script)` iff
6811        // `StateChange`, byte-for-byte from the variant's own storage);
6812        // the pin catches any future accessor extension that promotes
6813        // an additional variant onto the `PathBuf`-carrying axis — the
6814        // gate then fires on duplicate scripts from that variant too,
6815        // and the singularity discipline the sibling
6816        // `validate_load_singularity` / `validate_cleanup_singularity`
6817        // gates share on the `String`-carrying axis's per-variant
6818        // consumers extends to the promoted variant by construction.
6819        //
6820        // Peer of the sibling four per-`UpgradeInstruction` consumers
6821        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6822        // sandbox-path fan-out, the layout-side per-`StateChange`
6823        // script-existence fan-out at
6824        // `caixa-core/src/layout.rs:1017`, the cross-slot
6825        // [`validate_upgrade_from_against_behavior`] gate's per-
6826        // `StateChange` detection loop, the peer
6827        // [`UpgradeInstruction::declared_module`] `String`-axis
6828        // per-variant unifier) — this gate now shares one typed
6829        // dispatch on the substrate primitive's `PathBuf`-carrying
6830        // axis with those consumers, so a future rebrand on the axis
6831        // migrates as a single caixa-core edit rather than a
6832        // coordinated rewrite of five call sites.
6833        //
6834        // Three-arm projective coverage:
6835        //   (a) `StateChange` scripts project through `declared_path()`
6836        //       byte-equal to the raw `script.as_path()` field access;
6837        //   (b) a duplicate-`StateChange` input trips the gate on the
6838        //       second occurrence with `DuplicateStateChange` carrying
6839        //       the offending script verbatim;
6840        //   (c) a non-`StateChange`-only input (`LoadModule` /
6841        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
6842        //       vacuous with `Ok(())` — the `declared_path().is_none()`
6843        //       arm's `continue` fall-through pins.
6844        //
6845        // Fail-before-pass-after verified locally: swapping the
6846        // production `let Some(script) = instr.declared_path() else {
6847        // continue };` back to `let script = match instr {
6848        // UpgradeInstruction::StateChange { script } =>
6849        // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6850        // passing but silently detaches the gate from the accessor's
6851        // typed dispatch — any future `declared_path` extension
6852        // (promotion of an additional variant onto the axis, an
6853        // operator-side pre-resolved-path cache the accessor
6854        // materializes) would then silently disagree between this
6855        // gate's raw pattern-match and the peer four sibling consumers
6856        // that route through the accessor.
6857        use std::path::PathBuf;
6858
6859        // (a) StateChange projection byte-equal via declared_path.
6860        let sc = UpgradeInstruction::StateChange {
6861            script: PathBuf::from("lib/m.lisp"),
6862        };
6863        assert_eq!(
6864            sc.declared_path().map(std::path::PathBuf::as_path),
6865            Some(PathBuf::from("lib/m.lisp").as_path()),
6866            "declared_path() must project the StateChange :script byte-equal to the raw \
6867             field access — accessor divergence would silently detach the gate from the \
6868             projection every peer per-`UpgradeInstruction` consumer routes through"
6869        );
6870
6871        // (b) Duplicate-StateChange input trips the gate.
6872        let dup = entry(
6873            "0.1.0",
6874            vec![
6875                UpgradeInstruction::LoadModule { module: "x".into() },
6876                UpgradeInstruction::StateChange {
6877                    script: PathBuf::from("lib/m.lisp"),
6878                },
6879                UpgradeInstruction::StateChange {
6880                    script: PathBuf::from("lib/m.lisp"),
6881                },
6882            ],
6883        );
6884        assert_eq!(
6885            dup.validate_state_change_singularity(),
6886            Err(UpgradeError::DuplicateStateChange {
6887                from: "0.1.0".into(),
6888                script: PathBuf::from("lib/m.lisp"),
6889            }),
6890            "duplicate StateChange scripts must trip the gate on the second occurrence \
6891             through the declared_path accessor's Some(script) arm"
6892        );
6893
6894        // (c) Non-StateChange-only inputs leave the gate vacuous.
6895        for instrs in [
6896            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6897            vec![
6898                UpgradeInstruction::LoadModule { module: "x".into() },
6899                UpgradeInstruction::SoftPurge {
6900                    module: "x-old".into(),
6901                },
6902            ],
6903            vec![
6904                UpgradeInstruction::LoadModule { module: "x".into() },
6905                UpgradeInstruction::Purge {
6906                    module: "x-old".into(),
6907                },
6908            ],
6909            vec![UpgradeInstruction::Restart],
6910        ] {
6911            for instr in &instrs {
6912                assert!(
6913                    instr.declared_path().is_none(),
6914                    "non-StateChange variants must project None through declared_path — \
6915                     accessor divergence would let this gate silently fire on a duplicate \
6916                     module reference far from any :state-change site"
6917                );
6918            }
6919            let e = entry("0.1.0", instrs);
6920            assert_eq!(
6921                e.validate_state_change_singularity(),
6922                Ok(()),
6923                "the state-change-singularity gate must return Ok(()) on an entry whose \
6924                 instructions all project None through declared_path — the accessor's \
6925                 continue arm the pattern-match's `_ => continue` previously carried"
6926            );
6927        }
6928    }
6929
6930    // ── within-entry state-change-before-cleanup ordering invariant ──
6931
6932    #[test]
6933    fn validate_rejects_state_change_after_soft_purge() {
6934        // Fail-before-pass-after pin: `:state-change` is the
6935        // gen_server:code_change/3 analog and folds the prior-version
6936        // state shape into the current shape; `:soft-purge` drains the
6937        // prior code. The operator runs instructions in declared order,
6938        // so a `:soft-purge` ahead of a `:state-change` drains the
6939        // prior module before the migration callback runs against the
6940        // state it held — the canonical OTP error mode
6941        // "`code_change/3` invoked on a purged module" the
6942        // release_handler closes by always ordering the migration
6943        // before the cleanup.
6944        let e = entry(
6945            "0.1.0",
6946            vec![
6947                UpgradeInstruction::LoadModule { module: "x".into() },
6948                UpgradeInstruction::SoftPurge {
6949                    module: "x-old".into(),
6950                },
6951                UpgradeInstruction::StateChange {
6952                    script: PathBuf::from("lib/m.lisp"),
6953                },
6954            ],
6955        );
6956        let err = e.validate().unwrap_err();
6957        assert_eq!(
6958            err,
6959            UpgradeError::StateChangeAfterCleanup {
6960                from: "0.1.0".into(),
6961                script: PathBuf::from("lib/m.lisp"),
6962                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6963                prior_cleanup_module: "x-old".into(),
6964            },
6965            "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6966             naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6967        );
6968    }
6969
6970    #[test]
6971    fn validate_rejects_state_change_after_purge() {
6972        // Per-arm coverage: `:purge` (immediate discard, no drain) is
6973        // the more catastrophic peer of `:soft-purge` on the cleanup
6974        // axis; same gate, same shape, the `prior_cleanup_kind` field
6975        // distinguishes the diagnostic so the author can grep their
6976        // caixa.lisp for the offending `(:purge …)` form.
6977        let e = entry(
6978            "0.1.0",
6979            vec![
6980                UpgradeInstruction::LoadModule { module: "x".into() },
6981                UpgradeInstruction::Purge {
6982                    module: "x-old".into(),
6983                },
6984                UpgradeInstruction::StateChange {
6985                    script: PathBuf::from("lib/m.lisp"),
6986                },
6987            ],
6988        );
6989        let err = e.validate().unwrap_err();
6990        assert_eq!(
6991            err,
6992            UpgradeError::StateChangeAfterCleanup {
6993                from: "0.1.0".into(),
6994                script: PathBuf::from("lib/m.lisp"),
6995                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6996                prior_cleanup_module: "x-old".into(),
6997            },
6998            "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6999             `prior_cleanup_kind: \":purge\"`, got {err:?}"
7000        );
7001    }
7002
7003    #[test]
7004    fn validate_accepts_state_change_before_cleanup() {
7005        // Positive control: the canonical `(:load-module …)
7006        // (:state-change …) (:soft-purge …)` order validates — the
7007        // exact shape the module doc example and `validate_accepts_
7008        // well_formed` already pin, restated here on the new gate's
7009        // identity element so a future shortcut that runs the
7010        // singularity gates first doesn't silently mask a regression
7011        // here.
7012        let e = entry(
7013            "0.1.0",
7014            vec![
7015                UpgradeInstruction::LoadModule { module: "x".into() },
7016                UpgradeInstruction::StateChange {
7017                    script: PathBuf::from("lib/m.lisp"),
7018                },
7019                UpgradeInstruction::SoftPurge {
7020                    module: "x-old".into(),
7021                },
7022            ],
7023        );
7024        e.validate().unwrap();
7025    }
7026
7027    #[test]
7028    fn validate_accepts_cleanup_without_state_change() {
7029        // Empty-set identity: an entry that carries no `:state-change`
7030        // at all has nothing to order against the cleanup, so the gate
7031        // passes regardless of how the cleanups are placed (after the
7032        // single required `:load-module`). Mirrors the
7033        // `validate_accepts_multiple_purges_after_one_load` positive
7034        // control on the peer purge-ordering gate; metadata-only
7035        // upgrades with cleanup-but-no-migration land here.
7036        let e = entry(
7037            "0.1.0",
7038            vec![
7039                UpgradeInstruction::LoadModule { module: "x".into() },
7040                UpgradeInstruction::SoftPurge {
7041                    module: "x-old".into(),
7042                },
7043                UpgradeInstruction::Purge {
7044                    module: "x-oldest".into(),
7045                },
7046            ],
7047        );
7048        e.validate().unwrap();
7049    }
7050
7051    #[test]
7052    fn validate_accepts_state_change_without_cleanup() {
7053        // Empty-set identity on the dual axis: an entry that carries no
7054        // cleanup at all has nothing to order against the state-change,
7055        // so the gate passes — additive-upgrade shapes (load new code,
7056        // migrate state, leave old code resident for in-flight callers
7057        // to drain naturally) land here.
7058        let e = entry(
7059            "0.1.0",
7060            vec![
7061                UpgradeInstruction::LoadModule { module: "x".into() },
7062                UpgradeInstruction::StateChange {
7063                    script: PathBuf::from("lib/m.lisp"),
7064                },
7065            ],
7066        );
7067        e.validate().unwrap();
7068    }
7069
7070    #[test]
7071    fn validate_accepts_multiple_state_changes_before_cleanup() {
7072        // Coverage: every state-change must precede every cleanup, not
7073        // just the first. A chain `(load) (sc) (sc) (sp)` is the
7074        // canonical "two distinct migration scripts on a chained
7075        // upgrade" shape (one module's schema *and* another's
7076        // projection per the DuplicateStateChange diagnostic), and
7077        // it must pass when each state-change has distinct script
7078        // paths. Pinned here so a future shortcut that only checks
7079        // the first state-change doesn't silently accept a
7080        // `(load) (sc-1) (sp) (sc-2)` regression.
7081        let e = entry(
7082            "0.1.0",
7083            vec![
7084                UpgradeInstruction::LoadModule { module: "x".into() },
7085                UpgradeInstruction::StateChange {
7086                    script: PathBuf::from("lib/m1.lisp"),
7087                },
7088                UpgradeInstruction::StateChange {
7089                    script: PathBuf::from("lib/m2.lisp"),
7090                },
7091                UpgradeInstruction::SoftPurge {
7092                    module: "x-old".into(),
7093                },
7094            ],
7095        );
7096        e.validate().unwrap();
7097    }
7098
7099    #[test]
7100    fn validate_rejects_state_change_sandwiched_between_cleanups() {
7101        // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
7102        // (sp-2)` violates the gate because the state-change runs
7103        // after the first cleanup. The reported `prior_cleanup_*`
7104        // names the *first* cleanup (the load-bearing one), not the
7105        // last — mirrors every peer first-collision diagnostic
7106        // posture on this module (`validate_state_change_ordering`,
7107        // `validate_purge_ordering`, `validate_load_singularity`,
7108        // `validate_state_change_singularity`,
7109        // `validate_cleanup_singularity` all report the first
7110        // colliding instruction, not the last).
7111        let e = entry(
7112            "0.1.0",
7113            vec![
7114                UpgradeInstruction::LoadModule { module: "x".into() },
7115                UpgradeInstruction::SoftPurge {
7116                    module: "x-old".into(),
7117                },
7118                UpgradeInstruction::StateChange {
7119                    script: PathBuf::from("lib/m.lisp"),
7120                },
7121                UpgradeInstruction::Purge {
7122                    module: "y-old".into(),
7123                },
7124            ],
7125        );
7126        let err = e.validate().unwrap_err();
7127        assert_eq!(
7128            err,
7129            UpgradeError::StateChangeAfterCleanup {
7130                from: "0.1.0".into(),
7131                script: PathBuf::from("lib/m.lisp"),
7132                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7133                prior_cleanup_module: "x-old".into(),
7134            },
7135            "the first cleanup the state-change follows must surface (not the trailing one), \
7136             got {err:?}"
7137        );
7138    }
7139
7140    #[test]
7141    fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
7142        // Diagnostic-precedence pin: an entry like `((:soft-purge
7143        // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
7144        // *both* purge-without-load (the cleanup runs before the
7145        // load) and state-change-after-cleanup (the state-change
7146        // runs after the cleanup). The more-fundamental ordering
7147        // gate must win — the missing-load defect (a cleanup that
7148        // drains the only resident version to nothing) is load-
7149        // bearing, and surfacing the state-change-after-cleanup
7150        // diagnostic first would mask the drain-to-nothing defect
7151        // the peer purge-ordering gate exists to close. Guards the
7152        // call order in `validate` against silent reordering. Same
7153        // posture as `validate_purge_ordering_fires_after_state_
7154        // change_ordering` on the sibling ordering gate.
7155        //
7156        // Pin specifically uses the load-after-cleanup shape (rather
7157        // than load-less) so the state-change-ordering gate (which
7158        // would otherwise fire first on a `((:soft-purge …)
7159        // (:state-change …))` shape with no leading load) is
7160        // sidestepped: with the load present after the cleanup,
7161        // state-change-ordering passes (its `loaded` latch is set
7162        // before the state-change is encountered) but purge-ordering
7163        // still fails (the cleanup precedes the load). That isolates
7164        // the precedence between purge-ordering and this gate
7165        // cleanly.
7166        let e = entry(
7167            "0.1.0",
7168            vec![
7169                UpgradeInstruction::SoftPurge {
7170                    module: "x-old".into(),
7171                },
7172                UpgradeInstruction::LoadModule { module: "x".into() },
7173                UpgradeInstruction::StateChange {
7174                    script: PathBuf::from("lib/m.lisp"),
7175                },
7176            ],
7177        );
7178        let err = e.validate().unwrap_err();
7179        assert!(
7180            matches!(
7181                err,
7182                UpgradeError::PurgeWithoutPriorLoad {
7183                    kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7184                    ..
7185                }
7186            ),
7187            "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
7188        );
7189    }
7190
7191    #[test]
7192    fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
7193        // Diagnostic-precedence pin: an entry like `((:state-change
7194        // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
7195        // load (because no `:load-module` precedes the state-change)
7196        // but *not* state-change-after-cleanup (the state-change
7197        // precedes the cleanup textually). The state-change-ordering
7198        // gate must surface first regardless — the missing-load
7199        // defect on the migration axis is the load-bearing semantic
7200        // and surfacing a different ordering diagnostic would mask
7201        // the migration-against-stale-code defect. Guards the call
7202        // order in `validate` against silent reordering on a shape
7203        // that fires only the state-change-ordering gate (not this
7204        // one), pinning that the state-change-ordering gate wins
7205        // ahead of this gate's chance to look at the list.
7206        let e = entry(
7207            "0.1.0",
7208            vec![
7209                UpgradeInstruction::StateChange {
7210                    script: PathBuf::from("lib/m.lisp"),
7211                },
7212                UpgradeInstruction::SoftPurge {
7213                    module: "x-old".into(),
7214                },
7215            ],
7216        );
7217        let err = e.validate().unwrap_err();
7218        assert!(
7219            matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
7220            "state-change-without-load must surface before purge-without-load (the canonical \
7221             validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
7222        );
7223    }
7224
7225    #[test]
7226    fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
7227        // Order pin: a malformed `:script` value on a `:state-change`
7228        // (an empty path) surfaces its narrower `EmptyScript`
7229        // diagnostic *before* the within-entry state-change-before-
7230        // cleanup gate fires. The per-instruction shape pass walks
7231        // the list inline before the ordering check, so the narrower
7232        // self-locating diagnostic surfaces first — mirrors the
7233        // empty-first cascade on every peer path-shape gate and the
7234        // `validate_purge_ordering_fires_after_per_instr_shape` pin
7235        // on the sibling ordering gate.
7236        let e = entry(
7237            "0.1.0",
7238            vec![
7239                UpgradeInstruction::LoadModule { module: "x".into() },
7240                UpgradeInstruction::SoftPurge {
7241                    module: "x-old".into(),
7242                },
7243                UpgradeInstruction::StateChange {
7244                    script: PathBuf::new(),
7245                },
7246            ],
7247        );
7248        let err = e.validate().unwrap_err();
7249        assert_eq!(
7250            err,
7251            UpgradeError::EmptyScript,
7252            "malformed instruction must surface its narrower diagnostic before the \
7253             state-change-before-cleanup gate fires, got {err:?}"
7254        );
7255    }
7256
7257    #[test]
7258    fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
7259        // Diagnostic-precedence pin: an entry like `((:load-module
7260        // "x") (:soft-purge "x-old") (:state-change "m.lisp")
7261        // (:state-change "m.lisp"))` violates *both* this ordering
7262        // gate (the first state-change follows the cleanup) and the
7263        // state-change-singularity gate (the same script appears
7264        // twice). The ordering gate must win — the canonical
7265        // "ordering before singularity" precedence the peer
7266        // `validate_state_change_ordering` / `validate_purge_
7267        // ordering` gates already establish over their own singularity
7268        // gates, applied uniformly across the OTP canonical-sequence
7269        // ordering axis here. Guards the call order in `validate`:
7270        // `validate_state_change_before_cleanup` runs before the
7271        // per-instruction-class singularity gates.
7272        let e = entry(
7273            "0.1.0",
7274            vec![
7275                UpgradeInstruction::LoadModule { module: "x".into() },
7276                UpgradeInstruction::SoftPurge {
7277                    module: "x-old".into(),
7278                },
7279                UpgradeInstruction::StateChange {
7280                    script: PathBuf::from("lib/m.lisp"),
7281                },
7282                UpgradeInstruction::StateChange {
7283                    script: PathBuf::from("lib/m.lisp"),
7284                },
7285            ],
7286        );
7287        let err = e.validate().unwrap_err();
7288        assert!(
7289            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7290            "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7291        );
7292    }
7293
7294    #[test]
7295    fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7296        // The whole-list entry-point surfaces the per-entry ordering
7297        // error (mirrors `validate_purge_ordering_threads_through_
7298        // validate_upgrade_from` and every peer wiring pin): the gate
7299        // is reachable from the LayoutInvariants call site, not only
7300        // from a direct `entry.validate()`.
7301        let entries = vec![entry(
7302            "0.1.0",
7303            vec![
7304                UpgradeInstruction::LoadModule { module: "x".into() },
7305                UpgradeInstruction::SoftPurge {
7306                    module: "x-old".into(),
7307                },
7308                UpgradeInstruction::StateChange {
7309                    script: PathBuf::from("lib/m.lisp"),
7310                },
7311            ],
7312        )];
7313        let err = validate_upgrade_from(&entries).unwrap_err();
7314        assert!(
7315            matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7316            "validate_upgrade_from must thread the state-change-before-cleanup error, \
7317             got {err:?}"
7318        );
7319    }
7320
7321    #[test]
7322    fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7323        // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7324        // per-instruction `StateChange`-arm script-path projection must
7325        // route through the sibling lifted
7326        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7327        // accessor, not the raw
7328        // `if let UpgradeInstruction::StateChange { script } = instr`
7329        // open-coded pattern-match the gate previously carried inside
7330        // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7331        //
7332        // Structurally: the gate's projection accept-set is the union
7333        // of every [`UpgradeInstruction`] variant for which
7334        // `declared_path().is_some()` — today exactly
7335        // [`UpgradeInstruction::StateChange`] per the sibling
7336        // `declared_path_only_for_state_change` pin, so a
7337        // state-change-after-cleanup input trips
7338        // `StateChangeAfterCleanup` and a non-`StateChange` input
7339        // (module-bearing / terminal) leaves the sticky-once latch
7340        // sweep quiet byte-identical to the pattern-match shape.
7341        //
7342        // Byte-equal today (`declared_path` returns `Some(script)` iff
7343        // `StateChange`, byte-for-byte from the variant's own storage);
7344        // the pin catches any future accessor extension that promotes
7345        // an additional variant onto the `PathBuf`-carrying axis — the
7346        // gate then fires on migrate-after-cleanup for that variant too,
7347        // and the migrate→cleanup ordering discipline the peer
7348        // [`validate_state_change_singularity`] /
7349        // [`validate_upgrade_from_against_behavior`] gates share on the
7350        // same axis extends to the promoted variant by construction.
7351        //
7352        // Peer of the sibling four per-`UpgradeInstruction` consumers
7353        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7354        // sandbox-path fan-out, the layout-side per-`StateChange`
7355        // script-existence fan-out at
7356        // `caixa-core/src/layout.rs:1058`, the within-entry
7357        // [`UpgradeFromEntry::validate_state_change_singularity`]
7358        // per-`StateChange` script-projection fan-out, the cross-slot
7359        // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7360        // detection loop) — the fifth (and last unlifted inside
7361        // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7362        // the `PathBuf`-carrying axis to now route through the accessor.
7363        // Same shape as the sibling
7364        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7365        // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7366        // pins extended onto the within-entry migrate→cleanup ordering
7367        // gate.
7368        //
7369        // Three-arm projective coverage:
7370        //   (a) `StateChange` scripts project through `declared_path()`
7371        //       byte-equal to the raw `script.clone()` field access
7372        //       the diagnostic previously carried;
7373        //   (b) a `:state-change`-after-cleanup input trips the gate
7374        //       with `StateChangeAfterCleanup` carrying the offending
7375        //       script + the prior cleanup's kind/module verbatim;
7376        //   (c) a non-`StateChange`-only input (`LoadModule` /
7377        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7378        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7379        //       arm's fall-through pins.
7380        //
7381        // Fail-before-pass-after verified structurally: swapping the
7382        // production
7383        //   `else if let Some(script) = instr.declared_path() && … { … }`
7384        // back to
7385        //   `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7386        // keeps arms (a)-(c) passing but silently detaches this within-
7387        // entry ordering gate from the accessor's typed dispatch — any
7388        // future `declared_path` extension (promotion of an additional
7389        // variant onto the axis, an operator-side pre-resolved-path
7390        // cache the accessor materializes) would then silently disagree
7391        // between this gate's raw pattern-match and the peer four
7392        // sibling consumers that route through the accessor.
7393
7394        // (a) StateChange projection byte-equal via declared_path.
7395        let sc = UpgradeInstruction::StateChange {
7396            script: PathBuf::from("lib/m.lisp"),
7397        };
7398        assert_eq!(
7399            sc.declared_path().cloned(),
7400            Some(PathBuf::from("lib/m.lisp")),
7401            "declared_path() must project the StateChange :script byte-equal to the raw \
7402             field access — accessor divergence would silently detach this within-entry \
7403             migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7404             consumer routes through"
7405        );
7406
7407        // (b) StateChange-after-cleanup trips the gate through the accessor.
7408        let after = entry(
7409            "0.1.0",
7410            vec![
7411                UpgradeInstruction::LoadModule { module: "x".into() },
7412                UpgradeInstruction::SoftPurge {
7413                    module: "x-old".into(),
7414                },
7415                UpgradeInstruction::StateChange {
7416                    script: PathBuf::from("lib/m.lisp"),
7417                },
7418            ],
7419        );
7420        assert_eq!(
7421            after.validate(),
7422            Err(UpgradeError::StateChangeAfterCleanup {
7423                from: "0.1.0".into(),
7424                script: PathBuf::from("lib/m.lisp"),
7425                prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7426                prior_cleanup_module: "x-old".into(),
7427            }),
7428            "a :state-change following a cleanup must trip the gate through the declared_path \
7429             accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7430             kind/module verbatim byte-identical to the pattern-match shape"
7431        );
7432
7433        // (c) Non-StateChange-only inputs leave the gate vacuous.
7434        for instrs in [
7435            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7436            vec![
7437                UpgradeInstruction::LoadModule { module: "x".into() },
7438                UpgradeInstruction::SoftPurge {
7439                    module: "x-old".into(),
7440                },
7441            ],
7442            vec![
7443                UpgradeInstruction::LoadModule { module: "x".into() },
7444                UpgradeInstruction::Purge {
7445                    module: "x-old".into(),
7446                },
7447            ],
7448            vec![UpgradeInstruction::Restart],
7449        ] {
7450            for instr in &instrs {
7451                assert!(
7452                    instr.declared_path().is_none(),
7453                    "non-StateChange variants must project None through declared_path — \
7454                     accessor divergence would let this within-entry ordering gate silently \
7455                     fire on a cleanup-only sequence far from any :state-change site"
7456                );
7457            }
7458            let e = entry("0.1.0", instrs);
7459            assert_eq!(
7460                e.validate(),
7461                Ok(()),
7462                "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7463                 instructions all project None through declared_path — the accessor's \
7464                 None arm the pattern-match's implicit fall-through previously carried"
7465            );
7466        }
7467    }
7468
7469    #[test]
7470    fn validate_restart_order_independent() {
7471        // Position-agnostic: `(:restart)` leading or trailing the
7472        // mixed sequence surfaces the same RestartNotExclusive shape.
7473        // Mirrors OTP appup's order-insensitive
7474        // `restart_emulator | restart_new_emulator` terminal rule —
7475        // the position of the restart instruction in the script is
7476        // irrelevant; what matters is the script *contains* it
7477        // alongside other instructions at all. The gate must not
7478        // gain a false positive by depending on instruction ordering.
7479        let leading = entry(
7480            "0.1.0",
7481            vec![
7482                UpgradeInstruction::Restart,
7483                UpgradeInstruction::LoadModule { module: "x".into() },
7484            ],
7485        );
7486        let trailing = entry(
7487            "0.1.0",
7488            vec![
7489                UpgradeInstruction::LoadModule { module: "x".into() },
7490                UpgradeInstruction::Restart,
7491            ],
7492        );
7493        let middle = entry(
7494            "0.1.0",
7495            vec![
7496                UpgradeInstruction::LoadModule { module: "a".into() },
7497                UpgradeInstruction::Restart,
7498                UpgradeInstruction::SoftPurge {
7499                    module: "a-old".into(),
7500                },
7501            ],
7502        );
7503        for e in [&leading, &trailing, &middle] {
7504            assert!(
7505                matches!(
7506                    e.validate().unwrap_err(),
7507                    UpgradeError::RestartNotExclusive {
7508                        restart_count: 1,
7509                        ..
7510                    }
7511                ),
7512                "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7513                 instruction order, got {:?}",
7514                e.validate()
7515            );
7516        }
7517    }
7518
7519    #[test]
7520    fn validate_restart_exclusive_fires_after_per_instr_shape() {
7521        // Order pin: a malformed `:module` value on a Module-bearing
7522        // instruction (an empty string) surfaces its narrower
7523        // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7524        // entry restart-exclusivity gate fires. The per-instruction
7525        // shape pass walks the list inline before the restart-
7526        // exclusive check, so the narrower self-locating diagnostic
7527        // surfaces first — mirrors the empty-first cascade on every
7528        // peer DNS-1123 gate (`validate_module`,
7529        // `validate_membro_caixa`, `validate_placement_cluster`) and
7530        // the `*_invalid_fires_before_duplicate_check` arm-ordering
7531        // pins on every typed-graph axis. Without this pin a future
7532        // shortcut that runs the restart-exclusive check ahead of
7533        // per-instruction shape would surface a less-actionable
7534        // RestartNotExclusive over an instruction list that's also
7535        // malformed at the per-instruction layer.
7536        let e = entry(
7537            "0.1.0",
7538            vec![
7539                UpgradeInstruction::LoadModule {
7540                    module: String::new(),
7541                },
7542                UpgradeInstruction::Restart,
7543            ],
7544        );
7545        let err = e.validate().unwrap_err();
7546        assert_eq!(
7547            err,
7548            UpgradeError::ModuleEmpty {
7549                kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7550            },
7551            "malformed instruction must surface its kind-tagged diagnostic before the \
7552             restart-exclusivity gate fires, got {err:?}"
7553        );
7554    }
7555
7556    fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7557        // Helper for the cross-slot composition gate's pass arm: a
7558        // BehaviorSpec carrying just the `:on-state-change` callback,
7559        // the runtime hook the per-version `(:state-change "…")`
7560        // instruction is delivered through during hot upgrade. Mirrors
7561        // the canonical authoring shape pinned in the module doc.
7562        crate::BehaviorSpec {
7563            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7564            ..Default::default()
7565        }
7566    }
7567
7568    #[test]
7569    fn behavior_gate_rejects_state_change_without_any_behavior() {
7570        // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7571        // caixa carries no `:behavior` at all surfaces the missing-
7572        // callback diagnostic naming the offending entry's `:from` +
7573        // script. The "I added the upgrade path but never declared
7574        // `:behavior`" footgun: `:behavior` is optional at the typed
7575        // root, the typed `:upgrade-from` slot validates on its own
7576        // merits, and the operator's hot-upgrade dispatch reaches for
7577        // a callback that doesn't exist.
7578        let entries = vec![entry(
7579            "0.1.0",
7580            vec![
7581                UpgradeInstruction::LoadModule { module: "x".into() },
7582                UpgradeInstruction::StateChange {
7583                    script: PathBuf::from("lib/m.lisp"),
7584                },
7585            ],
7586        )];
7587        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7588        assert_eq!(
7589            err,
7590            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7591                from: "0.1.0".into(),
7592                script: PathBuf::from("lib/m.lisp"),
7593            },
7594        );
7595    }
7596
7597    #[test]
7598    fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7599        // `:behavior` declared with *other* callbacks set
7600        // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7601        // None still surfaces the missing-callback diagnostic — only
7602        // the `:on-state-change` axis matters for this gate. The
7603        // "I declared `:behavior` but missed the migration callback"
7604        // footgun: a caixa that registers its lifecycle hooks but
7605        // forgets the migration delivery path leaves the
7606        // `:state-change` instruction with no runtime hook to
7607        // dispatch through.
7608        let entries = vec![entry(
7609            "0.1.0",
7610            vec![
7611                UpgradeInstruction::LoadModule { module: "x".into() },
7612                UpgradeInstruction::StateChange {
7613                    script: PathBuf::from("lib/m.lisp"),
7614                },
7615            ],
7616        )];
7617        let b = crate::BehaviorSpec {
7618            on_init: Some(PathBuf::from("lib/init.lisp")),
7619            on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7620            ..Default::default()
7621        };
7622        let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7623        assert_eq!(
7624            err,
7625            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7626                from: "0.1.0".into(),
7627                script: PathBuf::from("lib/m.lisp"),
7628            },
7629            "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7630             the missing migration hook"
7631        );
7632    }
7633
7634    #[test]
7635    fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7636        // The canonical composition shape: a per-version
7637        // `(:state-change "lib/m.lisp")` instruction paired with the
7638        // `:behavior :on-state-change "lib/migrations.lisp"` callback
7639        // it is delivered through at hot-upgrade time. Pins the gate's
7640        // pass arm — drift here = a future tighten that rejects the
7641        // canonical OTP-shape composition surfaces as a regression at
7642        // this positive-control pin.
7643        let entries = vec![entry(
7644            "0.1.0",
7645            vec![
7646                UpgradeInstruction::LoadModule { module: "x".into() },
7647                UpgradeInstruction::StateChange {
7648                    script: PathBuf::from("lib/m.lisp"),
7649                },
7650            ],
7651        )];
7652        let b = behavior_with_state_change_callback();
7653        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7654    }
7655
7656    #[test]
7657    fn behavior_gate_accepts_entries_without_any_state_change() {
7658        // Empty-set identity: entries carrying no `:state-change`
7659        // instruction at all (load + cleanup only — the metadata-only
7660        // upgrade shape the module doc names, "On any failure, the
7661        // current version stays load-bearing — a typed atomic
7662        // upgrade") leave the gate vacuous. The composition only
7663        // requires a callback when the per-version script exists; a
7664        // load + cleanup pair has no migration to deliver, so the
7665        // absence of `:on-state-change` is coherent.
7666        let entries = vec![entry(
7667            "0.1.0",
7668            vec![
7669                UpgradeInstruction::LoadModule { module: "x".into() },
7670                UpgradeInstruction::SoftPurge {
7671                    module: "x-old".into(),
7672                },
7673            ],
7674        )];
7675        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7676    }
7677
7678    #[test]
7679    fn behavior_gate_accepts_restart_only_entry() {
7680        // The terminal-fallback `((:restart))` shape carries no
7681        // `:state-change` — the operator restarts the pod and the
7682        // new version comes up fresh against its initial state, no
7683        // migration. Pinned alongside the metadata-only positive
7684        // control above as the second empty-state-change shape.
7685        let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7686        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7687    }
7688
7689    #[test]
7690    fn behavior_gate_accepts_empty_entries_list() {
7691        // Empty `:upgrade-from` (a caixa with no declared upgrade
7692        // paths — the v0.1.0 caixa before any upgrade entries are
7693        // added) trivially passes the gate. Pinned so the gate
7694        // doesn't accidentally fire on a caixa that hasn't yet
7695        // declared any upgrades.
7696        let entries: Vec<UpgradeFromEntry> = vec![];
7697        validate_upgrade_from_against_behavior(&entries, None).unwrap();
7698    }
7699
7700    #[test]
7701    fn behavior_gate_reports_first_state_change_in_first_entry() {
7702        // First-collision determinism: with multiple `:state-change`
7703        // instructions across multiple entries, the gate reports the
7704        // *first* one encountered in declaration order — the entry's
7705        // declaration order first, then the within-entry instruction
7706        // order. Mirrors every peer first-collision diagnostic posture
7707        // on this module (`validate_state_change_ordering`,
7708        // `validate_purge_ordering`, the singularity gates), so a
7709        // future shortcut that walks the list in reverse or returns
7710        // the last collision surfaces as a regression here.
7711        let entries = vec![
7712            entry(
7713                "0.1.0",
7714                vec![
7715                    UpgradeInstruction::LoadModule { module: "x".into() },
7716                    UpgradeInstruction::StateChange {
7717                        script: PathBuf::from("lib/m1.lisp"),
7718                    },
7719                    UpgradeInstruction::StateChange {
7720                        script: PathBuf::from("lib/m2.lisp"),
7721                    },
7722                ],
7723            ),
7724            entry(
7725                "0.1.5",
7726                vec![
7727                    UpgradeInstruction::LoadModule { module: "x".into() },
7728                    UpgradeInstruction::StateChange {
7729                        script: PathBuf::from("lib/m3.lisp"),
7730                    },
7731                ],
7732            ),
7733        ];
7734        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7735        assert_eq!(
7736            err,
7737            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7738                from: "0.1.0".into(),
7739                script: PathBuf::from("lib/m1.lisp"),
7740            },
7741            "the first :state-change in the first entry must surface, not later collisions"
7742        );
7743    }
7744
7745    #[test]
7746    fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7747        // Cross-entry pin: a first entry with no `:state-change` (just
7748        // a load + cleanup) leaves the gate's per-entry walk continuing
7749        // to the second entry, where the offending instruction lives.
7750        // The diagnostic names the *second* entry's `:from` because
7751        // that's where the missing-callback shape is exposed — pinned
7752        // so a shortcut that bails on the first entry without a
7753        // `:state-change` (rather than continuing) doesn't mask the
7754        // defect in a later entry.
7755        let entries = vec![
7756            entry(
7757                "0.1.0",
7758                vec![
7759                    UpgradeInstruction::LoadModule { module: "x".into() },
7760                    UpgradeInstruction::SoftPurge {
7761                        module: "x-old".into(),
7762                    },
7763                ],
7764            ),
7765            entry(
7766                "0.1.5",
7767                vec![
7768                    UpgradeInstruction::LoadModule { module: "x".into() },
7769                    UpgradeInstruction::StateChange {
7770                        script: PathBuf::from("lib/m.lisp"),
7771                    },
7772                ],
7773            ),
7774        ];
7775        let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7776        assert_eq!(
7777            err,
7778            UpgradeError::StateChangeWithoutOnStateChangeCallback {
7779                from: "0.1.5".into(),
7780                script: PathBuf::from("lib/m.lisp"),
7781            },
7782            "the offending entry's `:from` must surface even when an earlier entry carries no \
7783             :state-change"
7784        );
7785    }
7786
7787    #[test]
7788    fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7789        // Positive control: a multi-entry `:upgrade-from` (chained
7790        // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7791        // a `:state-change` passes when the callback is declared once
7792        // at the caixa root. The callback is a single per-caixa
7793        // runtime hook; one declaration covers every entry's
7794        // `:state-change`, mirroring OTP's
7795        // `release_handler:install_release/1` which dispatches every
7796        // appup's `code_change` instruction through the single
7797        // `gen_server:code_change/3` callback registered on the
7798        // module.
7799        let entries = vec![
7800            entry(
7801                "0.1.0",
7802                vec![
7803                    UpgradeInstruction::LoadModule { module: "x".into() },
7804                    UpgradeInstruction::StateChange {
7805                        script: PathBuf::from("lib/m1.lisp"),
7806                    },
7807                ],
7808            ),
7809            entry(
7810                "0.1.5",
7811                vec![
7812                    UpgradeInstruction::LoadModule { module: "x".into() },
7813                    UpgradeInstruction::StateChange {
7814                        script: PathBuf::from("lib/m2.lisp"),
7815                    },
7816                ],
7817            ),
7818        ];
7819        let b = behavior_with_state_change_callback();
7820        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7821    }
7822
7823    #[test]
7824    fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7825        // Symmetry pin: the gate's pass arm doesn't depend on the
7826        // entry actually carrying a `:state-change` — if no
7827        // `:state-change` is declared, the gate is vacuous regardless
7828        // of the callback (an `:on-state-change` declared without a
7829        // matching per-version script is fine, the callback is the
7830        // runtime default for any *future* migration the author hasn't
7831        // yet added). Pins that a caixa author can declare the
7832        // callback ahead of any migration without the gate
7833        // complaining.
7834        let entries = vec![entry(
7835            "0.1.0",
7836            vec![
7837                UpgradeInstruction::LoadModule { module: "x".into() },
7838                UpgradeInstruction::SoftPurge {
7839                    module: "x-old".into(),
7840                },
7841            ],
7842        )];
7843        let b = behavior_with_state_change_callback();
7844        validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7845    }
7846
7847    #[test]
7848    fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7849        // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7850        // per-instruction `StateChange`-arm script-path projection must
7851        // route through the sibling lifted
7852        // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7853        // accessor, not the raw
7854        // `if let UpgradeInstruction::StateChange { script } = instr`
7855        // open-coded pattern-match the cross-slot gate previously
7856        // carried at caixa-core/src/upgrade.rs:1365.
7857        //
7858        // Structurally: the gate's projection accept-set is the union
7859        // of every [`UpgradeInstruction`] variant for which
7860        // `declared_path().is_some()` — today exactly
7861        // [`UpgradeInstruction::StateChange`] per the sibling
7862        // `declared_path_only_for_state_change` pin, so a
7863        // `:state-change`-carrying entry without an `:on-state-change`
7864        // callback trips `StateChangeWithoutOnStateChangeCallback` and
7865        // a non-`StateChange` entry (load-only / cleanup-only /
7866        // restart-only / empty-`:instructions`) leaves the per-entry
7867        // walk continuing past every non-projecting instruction
7868        // byte-identical to the pattern-match shape.
7869        //
7870        // Byte-equal today (`declared_path` returns `Some(script)` iff
7871        // `StateChange`, byte-for-byte from the variant's own storage);
7872        // the pin catches any future accessor extension that promotes
7873        // an additional variant onto the `PathBuf`-carrying axis — the
7874        // gate then fires on scripts from that variant too, and the
7875        // cross-slot composition discipline the sibling per-
7876        // `UpgradeInstruction` consumers share on the `PathBuf`-
7877        // carrying axis extends to the promoted variant by
7878        // construction.
7879        //
7880        // Peer of the sibling four per-`UpgradeInstruction` consumers
7881        // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7882        // sandbox-path fan-out, the layout-side per-`StateChange`
7883        // script-existence fan-out at
7884        // `caixa-core/src/layout.rs:1058`, the within-entry
7885        // [`UpgradeFromEntry::validate_state_change_singularity`]
7886        // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7887        // peer [`UpgradeInstruction::declared_module`] `String`-axis
7888        // per-variant unifier) — the fourth (and last) per-
7889        // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7890        // to now route through the accessor. Same shape as the
7891        // sibling
7892        // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7893        // pin extended onto the cross-slot composition gate.
7894        //
7895        // Three-arm projective coverage:
7896        //   (a) `StateChange` scripts project through `declared_path()`
7897        //       byte-equal to the raw `script.clone()` field access
7898        //       the diagnostic previously carried;
7899        //   (b) a `:state-change`-carrying entry with `behavior: None`
7900        //       trips the gate with `StateChangeWithoutOnStateChangeCallback`
7901        //       carrying the offending script verbatim;
7902        //   (c) a non-`StateChange`-only entry (`LoadModule` /
7903        //       `SoftPurge` / `Purge` / `Restart`) leaves the gate
7904        //       vacuous with `Ok(())` — the `declared_path().is_none()`
7905        //       arm's fall-through pins.
7906        //
7907        // Fail-before-pass-after verified structurally: swapping the
7908        // production
7909        //   `if let Some(script) = instr.declared_path() { … }`
7910        // back to
7911        //   `if let UpgradeInstruction::StateChange { script } = instr { … }`
7912        // keeps arms (a)-(c) passing but silently detaches the gate
7913        // from the accessor's typed dispatch — any future
7914        // `declared_path` extension (promotion of an additional
7915        // variant onto the axis, an operator-side pre-resolved-path
7916        // cache the accessor materializes) would then silently
7917        // disagree between this cross-slot gate's raw pattern-match
7918        // and the peer four sibling consumers that route through the
7919        // accessor.
7920
7921        // (a) StateChange projection byte-equal via declared_path.
7922        let sc = UpgradeInstruction::StateChange {
7923            script: PathBuf::from("lib/m.lisp"),
7924        };
7925        assert_eq!(
7926            sc.declared_path().cloned(),
7927            Some(PathBuf::from("lib/m.lisp")),
7928            "declared_path() must project the StateChange :script byte-equal to the raw \
7929             field access — accessor divergence would silently detach this cross-slot \
7930             composition gate from the projection every peer per-`UpgradeInstruction` \
7931             consumer routes through"
7932        );
7933
7934        // (b) StateChange-carrying entry with behavior: None trips gate.
7935        let entries = vec![entry(
7936            "0.1.0",
7937            vec![
7938                UpgradeInstruction::LoadModule { module: "x".into() },
7939                UpgradeInstruction::StateChange {
7940                    script: PathBuf::from("lib/m.lisp"),
7941                },
7942            ],
7943        )];
7944        assert_eq!(
7945            validate_upgrade_from_against_behavior(&entries, None),
7946            Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7947                from: "0.1.0".into(),
7948                script: PathBuf::from("lib/m.lisp"),
7949            }),
7950            "a :state-change-carrying entry with behavior: None must trip the gate through \
7951             the declared_path accessor's Some(script) arm — carrying the offending script \
7952             verbatim byte-identical to the pattern-match shape"
7953        );
7954
7955        // (c) Non-StateChange-only inputs leave the gate vacuous.
7956        for instrs in [
7957            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7958            vec![
7959                UpgradeInstruction::LoadModule { module: "x".into() },
7960                UpgradeInstruction::SoftPurge {
7961                    module: "x-old".into(),
7962                },
7963            ],
7964            vec![
7965                UpgradeInstruction::LoadModule { module: "x".into() },
7966                UpgradeInstruction::Purge {
7967                    module: "x-old".into(),
7968                },
7969            ],
7970            vec![UpgradeInstruction::Restart],
7971        ] {
7972            for instr in &instrs {
7973                assert!(
7974                    instr.declared_path().is_none(),
7975                    "non-StateChange variants must project None through declared_path — \
7976                     accessor divergence would let this cross-slot composition gate silently \
7977                     fire on a module reference far from any :state-change site"
7978                );
7979            }
7980            let entries = vec![entry("0.1.0", instrs)];
7981            assert_eq!(
7982                validate_upgrade_from_against_behavior(&entries, None),
7983                Ok(()),
7984                "the cross-slot composition gate must return Ok(()) on an entry whose \
7985                 instructions all project None through declared_path — the accessor's \
7986                 None arm the pattern-match's implicit fall-through previously carried"
7987            );
7988        }
7989    }
7990
7991    #[test]
7992    fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7993        // Wiring pin: the within-entry restart-exclusivity gate fires
7994        // through [`validate_upgrade_from`] (which delegates to
7995        // [`UpgradeFromEntry::validate`] per entry) before the cross-
7996        // entry duplicate-`:from` gate would have a chance to run on
7997        // the malformed entry. Pinned here so a future refactor that
7998        // walks the cross-entry gate first doesn't accidentally
7999        // surface a DuplicateFrom over an entry that's also malformed
8000        // at the within-entry restart-exclusivity layer.
8001        let entries = vec![
8002            entry(
8003                "0.1.0",
8004                vec![
8005                    UpgradeInstruction::LoadModule { module: "x".into() },
8006                    UpgradeInstruction::Restart,
8007                ],
8008            ),
8009            entry("0.1.0", vec![UpgradeInstruction::Restart]),
8010        ];
8011        let err = validate_upgrade_from(&entries).unwrap_err();
8012        assert!(
8013            matches!(
8014                err,
8015                UpgradeError::RestartNotExclusive {
8016                    restart_count: 1,
8017                    ..
8018                }
8019            ),
8020            "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
8021             duplicate-`:from` gate fires, got {err:?}"
8022        );
8023    }
8024
8025    // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
8026
8027    #[test]
8028    fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
8029        // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
8030        // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
8031        // name the exact camelCase JSON keys the `#[serde(rename_all =
8032        // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
8033        // test-side probe across the caixa-core / caixa-flux renderer
8034        // test fixtures navigates into each element of the rendered
8035        // `:upgrade-from` overlay sequence by consulting one of these two
8036        // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
8037        // and pin that each canonical byte-sequence appears verbatim in
8038        // the JSON — a future accidental `rename_all = "snake_case"` /
8039        // `"kebab-case"` / verbatim-field-name flip at the derive
8040        // attribute (any of which would silently break every test-side
8041        // probe that reaches for one of the two consts) surfaces here as
8042        // a build-time test failure at `upgrade.rs`, not as an apply-time
8043        // `.get(<stale-canonical-const>)` returning `None` far from the
8044        // derive-attr drift's commit. Same discipline the sibling
8045        // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
8046        // (d8b8b4f) and
8047        // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
8048        // (21fe462) pins established on the peer `:limits` / `:behavior`
8049        // sub-slot axes: one canonical byte-string per typed sub-key
8050        // axis, pinned to the load-bearing serde derivation at the type
8051        // itself.
8052        let e = UpgradeFromEntry {
8053            from: "0.1.0".into(),
8054            instructions: vec![UpgradeInstruction::LoadModule {
8055                module: "hello-rio".into(),
8056            }],
8057        };
8058        let json = serde_json::to_string(&e).unwrap();
8059        for key in [
8060            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8061            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8062        ] {
8063            let quoted = format!("\"{key}\"");
8064            assert!(
8065                json.contains(&quoted),
8066                "serialized UpgradeFromEntry must carry the lifted \
8067                 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
8068                 the JSON emission (got: {json})",
8069            );
8070        }
8071    }
8072
8073    #[test]
8074    fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
8075        // Cross-axis drift-detection pin: a future collapse of the two
8076        // canonical sub-key byte-strings onto the same value (e.g. an
8077        // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
8078        // to also read `"from"`) would silently reroute every test-side
8079        // probe on one axis onto the sibling axis's per-entry field and
8080        // pass every propagation-probe test that expected only the stale
8081        // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
8082        // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
8083        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8084        let all = [
8085            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8086            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8087        ];
8088        for (i, a) in all.iter().enumerate() {
8089            for b in all.iter().skip(i + 1) {
8090                assert_ne!(
8091                    a, b,
8092                    "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
8093                     canonical byte-sequences — got `{a}` == `{b}`",
8094                );
8095            }
8096        }
8097    }
8098
8099    #[test]
8100    fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
8101        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8102        // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
8103        // tagged variant-discriminator key axis: the
8104        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
8105        // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
8106        // attribute on [`UpgradeInstruction`] emits, and every downstream
8107        // consumer that navigates the serialized instruction blob to
8108        // route by variant (the caixa-core reflection-vs-serde round-trip
8109        // check in `dispatcher_registration.rs` that probes
8110        // `v.get("kind")` against every variant's expected kebab-case
8111        // tag, the future M4 admission-webhook path, any wasm-operator
8112        // dispatch step consuming the serialized instruction blob) reads
8113        // through the same `&'static str`. Serialize every variant and
8114        // pin that the const's byte-sequence appears verbatim as the
8115        // tag-slot JSON key with the expected kebab-case value — a
8116        // future accidental `tag = "type"` / `tag = "op"` /
8117        // `tag = "instruction"` rebrand at the derive attribute (any of
8118        // which would silently break every consumer probe reaching for
8119        // the stale-tag-key const) surfaces here as a build-time test
8120        // failure at `upgrade.rs`, not as an apply-time
8121        // `.get(<stale-tag-key>)` returning `None` far from the derive-
8122        // attr drift's commit.
8123        //
8124        // Same "one canonical byte-string per typed axis" discipline the
8125        // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8126        // pin (36ffe65) established on the peer `:upgrade-from` per-entry
8127        // outer-container axis — this pin extends the discipline one
8128        // altitude deeper onto the per-instruction *tag* axis inside
8129        // each element of the `:instructions` list, completing the
8130        // typed coverage of the `:upgrade-from :instructions` dual
8131        // (key = "kind" + five variant-value tags): the five
8132        // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
8133        // per-variant kebab-case *values*; this pin pins the tag *key*
8134        // above them.
8135        let samples: [(UpgradeInstruction, &'static str); 5] = [
8136            (
8137                UpgradeInstruction::LoadModule {
8138                    module: "hello-rio".into(),
8139                },
8140                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
8141            ),
8142            (
8143                UpgradeInstruction::StateChange {
8144                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8145                },
8146                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
8147            ),
8148            (
8149                UpgradeInstruction::SoftPurge {
8150                    module: "hello-rio-old".into(),
8151                },
8152                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
8153            ),
8154            (
8155                UpgradeInstruction::Purge {
8156                    module: "hello-rio-old".into(),
8157                },
8158                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
8159            ),
8160            (
8161                UpgradeInstruction::Restart,
8162                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
8163            ),
8164        ];
8165        for (sample, expected_value) in &samples {
8166            let v: serde_json::Value = serde_json::to_value(sample).unwrap();
8167            let got = v
8168                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
8169                .and_then(|k| k.as_str());
8170            assert_eq!(
8171                got,
8172                Some(*expected_value),
8173                "serialized {sample:?} must carry the lifted \
8174                 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
8175                 ({:?}) verbatim as the tag-slot JSON key, holding the \
8176                 expected kebab-case value {expected_value:?} (got: {v})",
8177                crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
8178            );
8179        }
8180    }
8181
8182    #[test]
8183    fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
8184        // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
8185        // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8186        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8187        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8188        // whitespace / colons / dots) — the canonical shape a serde
8189        // internally-tagged discriminator key takes across every peer
8190        // enum in this crate. A future flip to a non-camelCase byte at
8191        // the const surfaces here at build time. Peer of
8192        // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
8193        // sibling per-entry outer-container axis.
8194        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8195        assert!(
8196            !key.is_empty(),
8197            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
8198        );
8199        let first = key.chars().next().unwrap();
8200        assert!(
8201            first.is_ascii_lowercase(),
8202            "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
8203             byte (got {key:?}, leads with {first:?})",
8204        );
8205        assert!(
8206            key.chars().all(|c| c.is_ascii_alphanumeric()),
8207            "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
8208             — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8209        );
8210    }
8211
8212    #[test]
8213    fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
8214        // Cross-axis drift-detection pin: the tag-slot key
8215        // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
8216        // disjoint from every per-variant data-field key the
8217        // internally-tagged serialization also emits (`"module"` for
8218        // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
8219        // future accidental rebrand that collapses `tag = "kind"` onto
8220        // one of the data-field names (e.g. `tag = "module"`) would
8221        // silently corrupt every serialized LoadModule blob (the
8222        // module string and the variant tag would collide on the same
8223        // JSON key) and every consumer probe would either misread the
8224        // tag or fail to distinguish variants. Pin the disjointness at
8225        // build time. Same cross-axis discipline the sibling
8226        // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
8227        // (36ffe65) established on the outer container's own
8228        // `from`/`instructions` pair.
8229        let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8230        // Enumerate every per-variant data-field key across all five
8231        // variants of [`UpgradeInstruction`], routing through the two
8232        // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
8233        // that name the same per-variant data-field JSON keys the
8234        // `variant_fields` reflection in
8235        // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
8236        // per-variant struct-field rebrand (`module` → `component`,
8237        // `script` → `path`) lands as an edit to exactly one const and
8238        // reaches this disjointness pin by construction — the two axes
8239        // (tag-slot key on one side, per-variant data-field keys on the
8240        // other) share one source of truth per axis.
8241        for data_field in [
8242            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8243            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8244        ] {
8245            assert_ne!(
8246                key, data_field,
8247                "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
8248                 must be disjoint from every UpgradeInstruction per-variant \
8249                 data-field key — got tag-key {key:?} colliding with \
8250                 data-field {data_field:?}, which would silently corrupt \
8251                 the internally-tagged serialization",
8252            );
8253        }
8254    }
8255
8256    #[test]
8257    fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
8258        // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8259        // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
8260        // data-field JSON key axis: the two
8261        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
8262        // `_SCRIPT`) name the exact per-variant field JSON keys the
8263        // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
8264        // [`UpgradeInstruction`] emits alongside the tag-slot key from the
8265        // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
8266        // const — the `module: String` struct-field on
8267        // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
8268        // struct-field on `StateChange` are promoted to sibling JSON keys
8269        // at the same nesting level as the tag by the internally-tagged
8270        // serialization, and every downstream consumer that navigates the
8271        // serialized instruction blob to reach the payload (the caixa-core
8272        // reflection round-trip in `dispatcher_registration.rs` that
8273        // consults `variant_fields`, the sibling disjointness pin below,
8274        // any future wasm-operator upgrade-dispatch step consuming the
8275        // serialized instruction blob to route the per-module load /
8276        // soft-purge / purge action or the per-script state-change action)
8277        // reads through the same `&'static str`. Serialize one Module-
8278        // bearing variant and one Script-bearing variant, then pin that
8279        // each const's byte-sequence appears verbatim in the JSON emission
8280        // — a future accidental struct-field rebrand (`module: String` →
8281        // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8282        // either variant surfaces here as a build-time test failure at
8283        // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8284        // returning `None` far from the field-name drift's commit.
8285        //
8286        // Same "one canonical byte-string per typed axis" discipline the
8287        // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8288        // pin established on the peer tag-slot key axis on the same
8289        // enum — this pin extends the discipline onto the per-variant
8290        // data-field key axis, completing the `:upgrade-from :instructions`
8291        // variant-JSON dual (tag key + tag values + per-variant field keys)
8292        // fully into caixa-core.
8293        let module_sample = UpgradeInstruction::LoadModule {
8294            module: "hello-rio".into(),
8295        };
8296        let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8297        assert_eq!(
8298            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8299                .and_then(|k| k.as_str()),
8300            Some("hello-rio"),
8301            "serialized {module_sample:?} must carry the lifted \
8302             M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8303             ({:?}) verbatim as the data-field JSON key holding the \
8304             module string (got: {v})",
8305            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8306        );
8307
8308        let script_sample = UpgradeInstruction::StateChange {
8309            script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8310        };
8311        let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8312        assert_eq!(
8313            v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8314                .and_then(|k| k.as_str()),
8315            Some("lib/migrations/v01-to-v02.lisp"),
8316            "serialized {script_sample:?} must carry the lifted \
8317             M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8318             ({:?}) verbatim as the data-field JSON key holding the \
8319             script path (got: {v})",
8320            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8321        );
8322    }
8323
8324    #[test]
8325    fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8326        // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8327        // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8328        // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8329        // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8330        // whitespace / colons / dots) — the canonical shape a Rust
8331        // struct-field name promoted to a JSON key by serde takes on this
8332        // internally-tagged variant surface, matching the sibling
8333        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8334        // shape. A future flip to a non-camelCase byte at either const
8335        // (an accidental `rename_all` regime interleave, or a struct-
8336        // field flip like `module` → `module_name`) surfaces here at
8337        // build time. Peer of
8338        // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8339        // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8340        // the sibling wire-key axes.
8341        for key in [
8342            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8343            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8344        ] {
8345            assert!(
8346                !key.is_empty(),
8347                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8348            );
8349            let first = key.chars().next().unwrap();
8350            assert!(
8351                first.is_ascii_lowercase(),
8352                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8353                 byte (got {key:?}, leads with {first:?})",
8354            );
8355            assert!(
8356                key.chars().all(|c| c.is_ascii_alphanumeric()),
8357                "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8358                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8359            );
8360        }
8361    }
8362
8363    #[test]
8364    fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8365        // Cross-axis drift-detection pin: a future collapse of the two
8366        // canonical per-variant data-field byte-strings onto the same
8367        // value (e.g. an accidental copy-paste flip of
8368        // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8369        // `"module"`) would silently reroute every test-side probe on one
8370        // variant's payload onto the sibling variant's payload and pass
8371        // every propagation-probe test that expected only the stale
8372        // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8373        // on the sibling per-entry outer-container axis, and of
8374        // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8375        // on the sibling tag-slot key ↔ per-variant data-field key axis.
8376        let all = [
8377            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8378            crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8379        ];
8380        for (i, a) in all.iter().enumerate() {
8381            for b in all.iter().skip(i + 1) {
8382                assert_ne!(
8383                    a, b,
8384                    "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8385                     canonical byte-sequences — got `{a}` == `{b}`",
8386                );
8387            }
8388        }
8389    }
8390
8391    #[test]
8392    fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8393        // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8394        // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8395        // `kebab-case` hyphens, no `PascalCase` leading capital, no
8396        // whitespace / colons / dots) — the canonical shape the
8397        // `#[serde(rename_all = "camelCase")]` derive produces on
8398        // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8399        // at the derive surfaces both here (this test fails on the
8400        // stale-constant shape) and at
8401        // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8402        // (that test fails on the mismatch between const and derive).
8403        // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8404        // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8405        // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8406        for key in [
8407            crate::render::M2_UPGRADE_FROM_KEY_FROM,
8408            crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8409        ] {
8410            assert!(
8411                !key.is_empty(),
8412                "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8413            );
8414            let first = key.chars().next().unwrap();
8415            assert!(
8416                first.is_ascii_lowercase(),
8417                "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8418                 byte (got {key:?}, leads with {first:?})",
8419            );
8420            assert!(
8421                key.chars().all(|c| c.is_ascii_alphanumeric()),
8422                "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8423                 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8424            );
8425        }
8426    }
8427
8428    #[test]
8429    fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8430        // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8431        // OTP-appup variant-tag axis: the five canonical author-facing
8432        // kebab-case labels (`:load-module` / `:state-change` /
8433        // `:soft-purge` / `:purge` / `:restart`) the substrate's
8434        // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8435        // from and every downstream consumer probes for verbatim. Same
8436        // scalar-value discipline the peer
8437        // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8438        // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8439        // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8440        // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8441        // (be40492) established for the sibling M2 / M3 / Supervisor
8442        // top-level and sub-slot author-facing-label axes. Fail-before-
8443        // pass-after locally verified by mutating
8444        // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8445        // pin fires as expected; restoring passes.
8446        //
8447        // A future OTP-lineage per-variant rebrand (e.g.
8448        // `:load-module` → `:load` matching Erlang's abbreviated
8449        // `code:load_module` name, `:state-change` → `:code-change`
8450        // matching Erlang's verbatim `code_change/3` callback,
8451        // `:soft-purge` → `:drain` matching a hypothetical operator-side
8452        // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8453        // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8454        // matching a supervisor-tree vocabulary alignment) lands as an
8455        // edit to exactly one const, and every consumer that reaches for
8456        // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8457        // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8458        // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8459        // `prior_cleanup_kind:` diagnostic field, the
8460        // [`LayoutError::UpgradeViolation`] `issue:` probe in
8461        // `layout.rs`) picks it up at build time rather than at runtime
8462        // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8463        // far from the rename's commit.
8464        assert_eq!(
8465            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8466            ":load-module"
8467        );
8468        assert_eq!(
8469            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8470            ":state-change"
8471        );
8472        assert_eq!(
8473            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8474            ":soft-purge"
8475        );
8476        assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8477        assert_eq!(
8478            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8479            ":restart"
8480        );
8481    }
8482
8483    #[test]
8484    fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8485        // Cross-arm drift-detection pin on the M2
8486        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8487        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8488        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8489        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8490        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8491        // closed-set OTP-appup variant-tag pentad: a future collapse
8492        // of two canonical variant byte-strings onto the same value
8493        // (an accidental copy-paste flip of
8494        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8495        // to also read `":purge"`, a per-arm rebrand that lands one
8496        // const without touching its paired peer) would silently
8497        // reroute every downstream OTP-appup dispatcher's per-
8498        // instruction branch onto the sibling arm's runtime
8499        // behavior and pass every propagation-probe test that
8500        // expected only the stale arm's tag — a `:soft-purge`
8501        // instruction (drain-then-swap: existing callers finish
8502        // under the old module, new callers land on the new one)
8503        // would come up under the `:purge` reconcile branch
8504        // (drop-existing: every in-flight caller terminates
8505        // immediately) on every hot-upgrade cycle, so a rolling
8506        // module swap would silently downgrade to a hard cutover
8507        // against its declared appup discipline, with no field
8508        // naming the instruction-tag drift root cause. Every
8509        // [`crate::UpgradeError`] diagnostic that surfaces the tag
8510        // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8511        // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8512        // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8513        // `prior_cleanup_kind:` field, the
8514        // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8515        // `layout.rs`) would emit the sibling arm's stale bytes at
8516        // the operator's console, far from the source rebrand
8517        // commit. Peer of the sibling
8518        // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8519        // (09ffb2d) /
8520        // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8521        // (ccdf955) /
8522        // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8523        // (d739850) distinctness pins on the sibling OTP-shape /
8524        // caixa-kind closed-set typed-enum discriminator axes —
8525        // the fifth closed-set OTP-appup / typed-enum axis to
8526        // converge on the same
8527        // "pairwise-distinct-by-construction" discipline, and the
8528        // canonical companion to the peer
8529        // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8530        // (ff980bb) distinctness pin on the sibling internally-
8531        // tagged-JSON per-variant data-field-key axis (the tag axis
8532        // this pin covers vs. the data-field-key axis its peer
8533        // covers — two paired axes on the same
8534        // [`crate::UpgradeInstruction`] typed enum surface).
8535        //
8536        // Fail-before-pass-after locally verified by mutating
8537        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8538        // to also read `":purge"` — this pin fires as expected;
8539        // restoring passes.
8540        let all = [
8541            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8542            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8543            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8544            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8545            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8546        ];
8547        for (i, a) in all.iter().enumerate() {
8548            for (j, b) in all.iter().enumerate() {
8549                if i != j {
8550                    assert_ne!(
8551                        a, b,
8552                        "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8553                         distinct — got duplicate {a:?} at indices {i} and {j}",
8554                    );
8555                }
8556            }
8557        }
8558    }
8559
8560    #[test]
8561    fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8562        // Production-through-const pin: the five per-variant labels
8563        // [`UpgradeInstruction::lisp_form`] returns route through the
8564        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8565        // so a future rebrand that reaches the const but not the
8566        // dispatch (or vice versa) surfaces here at build time rather
8567        // than at runtime as a downstream
8568        // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8569        // diagnostic drift far from the rename's commit. Mirror of the
8570        // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8571        // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8572        // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8573        // (f49c8b0) production-through-const pins on the sibling M3 /
8574        // M2 top-level slot axes.
8575        //
8576        // Fail-before-pass-after locally verified by mutating
8577        // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8578        // `":purge-drift"` — this pin fires as expected; restoring
8579        // passes.
8580        let cases: &[(UpgradeInstruction, &'static str)] = &[
8581            (
8582                UpgradeInstruction::LoadModule { module: "x".into() },
8583                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8584            ),
8585            (
8586                UpgradeInstruction::StateChange {
8587                    script: PathBuf::from("lib/m.lisp"),
8588                },
8589                crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8590            ),
8591            (
8592                UpgradeInstruction::SoftPurge {
8593                    module: "x-old".into(),
8594                },
8595                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8596            ),
8597            (
8598                UpgradeInstruction::Purge {
8599                    module: "x-old".into(),
8600                },
8601                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8602            ),
8603            (
8604                UpgradeInstruction::Restart,
8605                crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8606            ),
8607        ];
8608        for (instr, expected) in cases {
8609            assert_eq!(
8610                instr.lisp_form(),
8611                *expected,
8612                "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8613                 const (expected {expected:?})",
8614            );
8615        }
8616    }
8617
8618    #[test]
8619    fn upgrade_instruction_as_str_routes_through_lifted_wire_consts() {
8620        // Production-through-const pin on the peer wire-form axis: the
8621        // five per-variant un-prefixed kebab byte-strings
8622        // [`UpgradeInstruction::as_str`] returns route through the
8623        // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_*`] consts,
8624        // so a future rebrand that reaches the const but not the
8625        // dispatch (or vice versa) surfaces here at build time rather
8626        // than at runtime as a divergent JSON `"kind"` tag between the
8627        // serde-derived wire byte-string and the accessor-routed
8628        // source of truth on every K8s-CR round-trip / structured-log
8629        // line / dispatcher-catalog lookup. Peer of the sibling
8630        // [`upgrade_instruction_lisp_form_routes_through_lifted_kind_consts`]
8631        // pin on the tatara-lisp author-surface form axis — the
8632        // two-axis discipline (author-surface `:load-module` /
8633        // wire-form `load-module`) is now fully lifted into caixa-core
8634        // through paired `M2_UPGRADE_INSTRUCTION_KIND_*` +
8635        // `M2_UPGRADE_INSTRUCTION_WIRE_*` const families, so a per-
8636        // consumer rebrand at either axis lands at exactly one edit
8637        // site and every downstream projection picks it up by
8638        // construction.
8639        //
8640        // Fail-before-pass-after locally verified by mutating
8641        // `UpgradeInstruction::as_str`'s `Self::Purge` arm to return
8642        // `"purge-drift"` — this pin fires as expected; restoring
8643        // passes.
8644        let cases: &[(UpgradeInstruction, &'static str)] = &[
8645            (
8646                UpgradeInstruction::LoadModule { module: "x".into() },
8647                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
8648            ),
8649            (
8650                UpgradeInstruction::StateChange {
8651                    script: PathBuf::from("lib/m.lisp"),
8652                },
8653                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
8654            ),
8655            (
8656                UpgradeInstruction::SoftPurge {
8657                    module: "x-old".into(),
8658                },
8659                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
8660            ),
8661            (
8662                UpgradeInstruction::Purge {
8663                    module: "x-old".into(),
8664                },
8665                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
8666            ),
8667            (
8668                UpgradeInstruction::Restart,
8669                crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
8670            ),
8671        ];
8672        for (instr, expected) in cases {
8673            assert_eq!(
8674                instr.as_str(),
8675                *expected,
8676                "UpgradeInstruction::as_str on {instr:?} must route through the lifted \
8677                 M2_UPGRADE_INSTRUCTION_WIRE_* const (expected {expected:?})",
8678            );
8679            assert!(
8680                !expected.starts_with(':'),
8681                "M2_UPGRADE_INSTRUCTION_WIRE_* entry {expected:?} must \
8682                 not open with a `:` prefix — a bare `:` -prefixed entry \
8683                 would collide the wire-form axis with the peer tatara-\
8684                 lisp author-surface form the M2_UPGRADE_INSTRUCTION_KIND_* \
8685                 family carries",
8686            );
8687        }
8688    }
8689
8690    #[test]
8691    fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8692        // Return-lifetime pin on the substrate primitive: because
8693        // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8694        // (threaded verbatim from the paired
8695        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8696        // roster's program-lifetime storage), the label survives
8697        // dropping the borrow through `self` — a downstream logger
8698        // that stashes the tag in a `&'static`-bounded position
8699        // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8700        // accept-set, a static formatter's `%s` argument) reads it
8701        // without re-borrowing through the instruction reference. A
8702        // future refactor that accidentally narrows the return to
8703        // `&str` (lifetime-bound to `&self`) — say by projecting through
8704        // an owned `String` intermediate — would fail this compile-time
8705        // pin at build time far from the runtime-side lifetime
8706        // regression at every downstream `&'static str` consumer. Peer
8707        // pin discipline the sibling
8708        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8709        // `pub const _: &str = "..."` shape already carries at the
8710        // paired wire-form axis.
8711        //
8712        // The pin fires by taking the label from an instruction that
8713        // goes out of scope before the label is read — if
8714        // `lisp_form` returned a `&str` tied to `&self`, this would
8715        // fail to compile with "borrowed value does not live long
8716        // enough". Fail-before-pass-after locally verified: narrowing
8717        // the signature to `fn lisp_form(&self) -> &str { … }`
8718        // reproduces the compile error.
8719        fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8720            instr.lisp_form()
8721        }
8722        let label = {
8723            let instr = UpgradeInstruction::LoadModule {
8724                module: "ephemeral".into(),
8725            };
8726            stash_label_as_static(&instr)
8727            // instr drops here; label must survive
8728        };
8729        assert_eq!(
8730            label,
8731            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8732            "the &'static str return must survive the borrowed \
8733             UpgradeInstruction going out of scope — a lifetime narrowing \
8734             to &str would fail this pin at build time",
8735        );
8736    }
8737
8738    #[test]
8739    fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8740        // Const-position pin on the substrate primitive: because
8741        // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8742        // consumers can call it in `const` contexts — a `const`
8743        // declaration threading the label through, a `static` lookup
8744        // table pre-computed at compile time, a `match` arm's
8745        // `const`-eligible branch label. `pub` matters here: a
8746        // `pub(crate) const fn` would compile in-crate const contexts
8747        // but no external caixa-<target> renderer or feira verb could
8748        // reach the projection in a const context. Fail-before-pass-
8749        // after locally verified: reverting the visibility to
8750        // `pub(crate) const fn` (or removing `pub`) makes this pin
8751        // fail to compile at the const-context call site below.
8752        const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8753        assert_eq!(
8754            RESTART_LABEL,
8755            crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8756            "const-position dispatch on Restart must yield the lifted \
8757             M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8758        );
8759    }
8760
8761    #[test]
8762    fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8763        // The canonical per-`:upgrade-from :instructions` OTP-appup
8764        // migration-instruction-list slice-shape pin:
8765        // [`UpgradeFromEntry::instructions`] must return the
8766        // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8767        // a `&[UpgradeInstruction]` slice-view over the same backing
8768        // buffer the raw `self.instructions.as_slice()` field access
8769        // borrows from, byte-equal across every representative fixture
8770        // in the accept-set — the empty slice (the "no-op upgrade" /
8771        // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8772        // field's own docstring names), the singleton slice on every
8773        // variant of the [`UpgradeInstruction`] arm-space
8774        // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8775        // `Restart` — the five OTP-appup runtime-primitive variants),
8776        // and multi-instruction cohorts (the canonical
8777        // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8778        // load + state-migration triad the module doc names as the
8779        // "runs the instructions in order" example).
8780        //
8781        // Pins against a future silent detour that returned
8782        // `&Vec<UpgradeInstruction>` (which would type-check but leak
8783        // the storage-side `Vec`'s grow/push/reserve surface no
8784        // consumer of the typed view reaches for), a fresh-allocated
8785        // `Vec<UpgradeInstruction>` copy (which would type-check via
8786        // a coercion but silently break every downstream caller that
8787        // relied on the slice sharing the backing buffer's identity),
8788        // or an out-of-order or length-drifted projection (which
8789        // would silently split the paired within-entry cross-
8790        // instruction ordering gates' inputs from the peer per-
8791        // instruction shape-check loop's input, one seven-gate cohort
8792        // silently drifting from the peer gate's actual traversal
8793        // input).
8794        //
8795        // Peer of the sibling
8796        // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8797        // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8798        // `:contratos` edge-list axis, extended onto the M2 per-
8799        // `:upgrade-from :instructions` migration-instruction-list
8800        // axis — the fifth `&[T]`-return byte-equal pin, closing the
8801        // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8802        let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8803            Vec::new(),
8804            vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8805            vec![UpgradeInstruction::StateChange {
8806                script: PathBuf::from("lib/m.lisp"),
8807            }],
8808            vec![UpgradeInstruction::SoftPurge {
8809                module: "x-old".into(),
8810            }],
8811            vec![UpgradeInstruction::Purge {
8812                module: "x-old".into(),
8813            }],
8814            vec![UpgradeInstruction::Restart],
8815            vec![
8816                UpgradeInstruction::LoadModule { module: "x".into() },
8817                UpgradeInstruction::StateChange {
8818                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8819                },
8820                UpgradeInstruction::SoftPurge {
8821                    module: "x-old".into(),
8822                },
8823            ],
8824        ];
8825        for instructions in fixtures {
8826            let e = UpgradeFromEntry {
8827                from: "0.1.0".into(),
8828                instructions: instructions.clone(),
8829            };
8830            assert_eq!(
8831                e.instructions(),
8832                e.instructions.as_slice(),
8833                "UpgradeFromEntry::instructions must project the raw \
8834                 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8835                 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8836                 (fixture: {instructions:?})",
8837            );
8838            assert_eq!(
8839                e.instructions().len(),
8840                instructions.len(),
8841                "UpgradeFromEntry::instructions length must match the raw \
8842                 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8843            );
8844        }
8845    }
8846
8847    #[test]
8848    fn validate_reads_through_lifted_instructions_accessor() {
8849        // Three-consumer coherence pin on the lifted
8850        // [`UpgradeFromEntry::instructions`] slice-return accessor:
8851        // exercises three of the nine paired production consumers of
8852        // the per-`:upgrade-from :instructions` OTP-appup migration-
8853        // instruction-list surface through end-to-end validate() paths
8854        // that require the accessor to reach each of the fixture's
8855        // instructions.
8856        //
8857        // (1) The per-instruction shape-check fan-out
8858        // ([`UpgradeFromEntry::validate`]'s `for instr in
8859        // self.instructions()` loop): pass the well-formed load →
8860        // state-change → soft-purge triad — `validate()` must accept
8861        // it, which requires the accessor to project every entry so
8862        // each `instr.validate()` fires.
8863        //
8864        // (2) The within-entry state-change-ordering gate
8865        // ([`Self::validate_state_change_ordering`]): pass a
8866        // `((:state-change …))` singleton — `validate()` must return
8867        // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8868        // requires the accessor to reach the state-change so the
8869        // no-prior-load probe fires.
8870        //
8871        // (3) The within-entry per-module cleanup-singularity gate
8872        // ([`Self::validate_cleanup_singularity`]): pass a
8873        // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8874        // "x-old"))` cohort — `validate()` must return
8875        // [`UpgradeError::DuplicateCleanup`], which requires the
8876        // accessor to iterate the whole list so the second `SoftPurge`
8877        // matches the first via the `seen` set.
8878        //
8879        // Peer of the sibling
8880        // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8881        // three-consumer coherence pin on the M3 per-`:contratos`
8882        // edge-list axis, extended onto the M2 per-`:upgrade-from
8883        // :instructions` migration-instruction-list axis.
8884
8885        // (1) accept the well-formed OTP two-phase code-load triad
8886        let well_formed = entry(
8887            "0.1.0",
8888            vec![
8889                UpgradeInstruction::LoadModule { module: "x".into() },
8890                UpgradeInstruction::StateChange {
8891                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8892                },
8893                UpgradeInstruction::SoftPurge {
8894                    module: "x-old".into(),
8895                },
8896            ],
8897        );
8898        assert!(
8899            well_formed.validate().is_ok(),
8900            "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8901             the per-instruction shape-check fan-out requires the accessor to reach every entry"
8902        );
8903
8904        // (2) refuse a `((:state-change …))` singleton — the
8905        // state-change-without-prior-load gate must fire, which
8906        // requires the accessor to reach the single instruction.
8907        let no_prior_load = entry(
8908            "0.1.0",
8909            vec![UpgradeInstruction::StateChange {
8910                script: PathBuf::from("lib/m.lisp"),
8911            }],
8912        );
8913        match no_prior_load.validate() {
8914            Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8915            other => panic!(
8916                "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8917                 — the within-entry state-change-ordering gate must reach the single \
8918                 instruction through the lifted accessor; got: {other:?}"
8919            ),
8920        }
8921
8922        // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8923        // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8924        // singularity gate must fire on the second `SoftPurge`, which
8925        // requires the accessor to iterate the whole list.
8926        let duplicate_cleanup = entry(
8927            "0.1.0",
8928            vec![
8929                UpgradeInstruction::LoadModule { module: "x".into() },
8930                UpgradeInstruction::SoftPurge {
8931                    module: "x-old".into(),
8932                },
8933                UpgradeInstruction::SoftPurge {
8934                    module: "x-old".into(),
8935                },
8936            ],
8937        );
8938        match duplicate_cleanup.validate() {
8939            Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8940                assert_eq!(
8941                    module, "x-old",
8942                    "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8943                     cleanup-singularity gate must iterate through the lifted accessor to \
8944                     match the second SoftPurge against the first via the `seen` set"
8945                );
8946            }
8947            other => panic!(
8948                "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8949                 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8950                 iterate the whole list through the lifted accessor; got: {other:?}"
8951            ),
8952        }
8953
8954        // Path::new suppresses the unused-import warning if the
8955        // outer module trims `use std::path::Path;` in a future edit.
8956        let _ = Path::new("lib/m.lisp");
8957    }
8958
8959    // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8960    // macro definition (see the paired doc-block above the macro
8961    // definition) — every generated `<ctor>(from: &str, script: &Path)
8962    // -> Self` constructor folds the uniform `Self::<Variant> { from:
8963    // from.to_string(), script: script.to_path_buf() }` two-field
8964    // struct-literal onto one substrate primitive. The three per-variant
8965    // equivalence pins below (fail-before-pass-after by construction — a
8966    // byte-mismatched macro arm would trip its equivalence pin first)
8967    // lock each generated constructor to its struct-literal peer under
8968    // `PartialEq`, so every wire-up in
8969    // [`UpgradeFromEntry::validate_state_change_ordering`],
8970    // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8971    // [`validate_state_change_on_state_change_callback`] on that
8972    // variant produces a byte-equal `UpgradeError` to the pre-lift
8973    // open-coded struct-literal. The cross-axis pin that follows
8974    // (non-default `(from, script)` pair) routes both constructor input
8975    // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8976    // not silently collapse onto a fixed `from` / `script` value.
8977    //
8978    // Peer of the sibling `empty_child_version_ctor_matches_struct_
8979    // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8980    // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8981    // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8982    // to_string` equivalence + cross-axis pins the sibling
8983    // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8984    // established on the peer `SupervisorError` envelope; extended
8985    // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8986    // two-slot envelope so every substrate-primitive ctor family in
8987    // caixa-core guarantees the same-shape fold every wire-up on the
8988    // family reads through one dispatch.
8989
8990    #[test]
8991    fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8992        let from = "0.1.0";
8993        let script = Path::new("lib/migrations/v01-to-v02.lisp");
8994        assert_eq!(
8995            UpgradeError::state_change_without_prior_load(from, script),
8996            UpgradeError::StateChangeWithoutPriorLoad {
8997                from: from.to_string(),
8998                script: script.to_path_buf(),
8999            },
9000            "generated state_change_without_prior_load ctor must produce \
9001             byte-equal UpgradeError to the open-coded struct-literal \
9002             wrap on the same (&str, &Path) fixture",
9003        );
9004    }
9005
9006    #[test]
9007    fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
9008        let from = "0.1.0";
9009        let script = Path::new("lib/migrations/v01-to-v02.lisp");
9010        assert_eq!(
9011            UpgradeError::duplicate_state_change(from, script),
9012            UpgradeError::DuplicateStateChange {
9013                from: from.to_string(),
9014                script: script.to_path_buf(),
9015            },
9016            "generated duplicate_state_change ctor must produce byte-equal \
9017             UpgradeError to the open-coded struct-literal wrap on the \
9018             same (&str, &Path) fixture",
9019        );
9020    }
9021
9022    #[test]
9023    fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
9024        let from = "0.1.0";
9025        let script = Path::new("lib/migrations/v01-to-v02.lisp");
9026        assert_eq!(
9027            UpgradeError::state_change_without_on_state_change_callback(from, script),
9028            UpgradeError::StateChangeWithoutOnStateChangeCallback {
9029                from: from.to_string(),
9030                script: script.to_path_buf(),
9031            },
9032            "generated state_change_without_on_state_change_callback ctor \
9033             must produce byte-equal UpgradeError to the open-coded \
9034             struct-literal wrap on the same (&str, &Path) fixture",
9035        );
9036    }
9037
9038    #[test]
9039    fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
9040        // Cross-axis pin: sweep both constructor input axes (`from:
9041        // &str`, `script: &Path`) through non-default fixtures against
9042        // every generated arm in the [`upgrade_from_script_ctors!`]
9043        // macro, so any wrapper-side lowercase / trim / truncate /
9044        // re-order / fixed-path substitution on the two-field
9045        // construction surfaces here rather than at a downstream
9046        // diagnostic-shape mismatch. Also exercises the `&Path`
9047        // parameter under both `&Path` (direct `Path::new`) and
9048        // `&PathBuf` (via Deref coercion), matching the two shapes the
9049        // three wire-up sites thread through — the ordering /
9050        // callback-declaration gates hand a `&PathBuf` from
9051        // `instr.declared_path()`; the uniqueness gate hands a `&Path`
9052        // from `script.as_path()`. Peer of the sibling
9053        // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
9054        // cross-axis pin on the peer `SupervisorError` `{ caixa:
9055        // String }` envelope.
9056        let from = "1.2.3-rc.1";
9057        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9058        let script_ref: &Path = script_owned.as_path();
9059        for script in [script_ref, &script_owned as &Path] {
9060            assert_eq!(
9061                UpgradeError::state_change_without_prior_load(from, script),
9062                UpgradeError::StateChangeWithoutPriorLoad {
9063                    from: from.to_string(),
9064                    script: script.to_path_buf(),
9065                },
9066            );
9067            assert_eq!(
9068                UpgradeError::duplicate_state_change(from, script),
9069                UpgradeError::DuplicateStateChange {
9070                    from: from.to_string(),
9071                    script: script.to_path_buf(),
9072                },
9073            );
9074            assert_eq!(
9075                UpgradeError::state_change_without_on_state_change_callback(from, script),
9076                UpgradeError::StateChangeWithoutOnStateChangeCallback {
9077                    from: from.to_string(),
9078                    script: script.to_path_buf(),
9079                },
9080            );
9081        }
9082    }
9083
9084    // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
9085    // macro definition (see the paired doc-block above the macro
9086    // definition) — every generated `<ctor>(script: &Path) -> Self`
9087    // constructor folds the uniform `Self::<Variant> { script:
9088    // script.to_path_buf() }` one-field struct-literal onto one substrate
9089    // primitive. The three per-variant equivalence pins below
9090    // (fail-before-pass-after by construction — a byte-mismatched macro
9091    // arm would trip its equivalence pin first) lock each generated
9092    // constructor to its struct-literal peer under `PartialEq`, so every
9093    // closure passed to [`crate::render::require_sandboxed_lisp_path`]
9094    // at [`UpgradeInstruction::validate`] on that variant produces a
9095    // byte-equal `UpgradeError` to the pre-lift open-coded
9096    // struct-literal. The cross-axis pin that follows (non-default
9097    // `script` path, both `&Path` and `&PathBuf` shapes) routes the
9098    // constructor input axis through `.to_path_buf()`, so the fold does
9099    // not silently collapse onto a fixed `script` value or drop the
9100    // Deref-coercion arm the wire-up sites depend on.
9101    //
9102    // Peer of the sibling
9103    // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
9104    // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
9105    // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
9106    // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
9107    // equivalence + cross-axis pins the sibling
9108    // [`upgrade_from_script_ctors!`] family (8e67041) established on the
9109    // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
9110    // extended here onto the `{ script: PathBuf }` one-slot envelope
9111    // shape so every substrate-primitive ctor family on `UpgradeError`
9112    // guarantees the same-shape fold every wire-up on the family reads
9113    // through one dispatch.
9114
9115    #[test]
9116    fn absolute_script_ctor_matches_struct_literal_wrap() {
9117        let script = Path::new("/etc/nope.lisp");
9118        assert_eq!(
9119            UpgradeError::absolute_script(script),
9120            UpgradeError::AbsoluteScript {
9121                script: script.to_path_buf(),
9122            },
9123            "generated absolute_script ctor must produce byte-equal \
9124             UpgradeError to the open-coded struct-literal wrap on the \
9125             same &Path fixture",
9126        );
9127    }
9128
9129    #[test]
9130    fn parent_escape_script_ctor_matches_struct_literal_wrap() {
9131        let script = Path::new("../oops.lisp");
9132        assert_eq!(
9133            UpgradeError::parent_escape_script(script),
9134            UpgradeError::ParentEscapeScript {
9135                script: script.to_path_buf(),
9136            },
9137            "generated parent_escape_script ctor must produce byte-equal \
9138             UpgradeError to the open-coded struct-literal wrap on the \
9139             same &Path fixture",
9140        );
9141    }
9142
9143    #[test]
9144    fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
9145        let script = Path::new("lib/migrations.rs");
9146        assert_eq!(
9147            UpgradeError::non_lisp_extension_script(script),
9148            UpgradeError::NonLispExtensionScript {
9149                script: script.to_path_buf(),
9150            },
9151            "generated non_lisp_extension_script ctor must produce \
9152             byte-equal UpgradeError to the open-coded struct-literal \
9153             wrap on the same &Path fixture",
9154        );
9155    }
9156
9157    #[test]
9158    fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
9159        // Cross-axis pin: sweep the constructor input axis (`script:
9160        // &Path`) through a non-default fixture against every generated
9161        // arm in the [`upgrade_script_only_ctors!`] macro, so any
9162        // wrapper-side lowercase / trim / truncate / re-order /
9163        // fixed-path substitution on the one-field construction
9164        // surfaces here rather than at a downstream diagnostic-shape
9165        // mismatch. Also exercises the `&Path` parameter under both
9166        // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
9167        // coercion), matching the shape the three closures at
9168        // [`UpgradeInstruction::validate`] thread through — the
9169        // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
9170        // each closure, so the Deref-coercion arm the ctor advertises
9171        // must actually route through `.to_path_buf()` and not
9172        // silently swap in a fixed path.
9173        //
9174        // Peer of the sibling
9175        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9176        // cross-axis pin on the sibling `{ from, script }` two-slot
9177        // envelope shape.
9178        let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9179        let script_ref: &Path = script_owned.as_path();
9180        for script in [script_ref, &script_owned as &Path] {
9181            assert_eq!(
9182                UpgradeError::absolute_script(script),
9183                UpgradeError::AbsoluteScript {
9184                    script: script.to_path_buf(),
9185                },
9186            );
9187            assert_eq!(
9188                UpgradeError::parent_escape_script(script),
9189                UpgradeError::ParentEscapeScript {
9190                    script: script.to_path_buf(),
9191                },
9192            );
9193            assert_eq!(
9194                UpgradeError::non_lisp_extension_script(script),
9195                UpgradeError::NonLispExtensionScript {
9196                    script: script.to_path_buf(),
9197                },
9198            );
9199        }
9200    }
9201
9202    // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
9203    // macro definition (see the paired doc-block above the macro
9204    // definition) — every generated `<ctor>(from: &str, <axis>: &str)
9205    // -> Self` constructor folds the uniform `Self::<Variant> { from:
9206    // from.to_string(), <axis>: <axis>.to_string() }` two-field
9207    // struct-literal onto one substrate primitive. The three per-variant
9208    // equivalence pins below (fail-before-pass-after by construction — a
9209    // byte-mismatched macro arm would trip its equivalence pin first)
9210    // lock each generated constructor to its struct-literal peer under
9211    // `PartialEq`, so every wire-up in
9212    // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
9213    // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
9214    // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
9215    // `:from < :versao` gate on that variant produces a byte-equal
9216    // `UpgradeError` to the pre-lift open-coded struct-literal. The
9217    // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
9218    // pair) routes both constructor input axes through `.to_string()`
9219    // in declared field order, so the fold does not silently swap `from`
9220    // and the middle `<axis>` field, or silently collapse onto a fixed
9221    // `from` / `<axis>` value on any one variant.
9222    //
9223    // Peer of the sibling `state_change_without_prior_load_ctor_matches_
9224    // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
9225    // struct_literal_wrap` / `state_change_without_on_state_change_
9226    // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
9227    // ctors_route_from_and_script_verbatim` equivalence + cross-axis
9228    // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
9229    // established on the sibling `{ from: String, script: PathBuf }`
9230    // two-slot envelope shape; extended here onto the `{ from: String,
9231    // <axis>: String }` two-slot envelope shape so every substrate-
9232    // primitive ctor family on `UpgradeError` guarantees the same-shape
9233    // fold every wire-up on the family reads through one dispatch. Also
9234    // mirror-symmetric peer of the sibling
9235    // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9236    // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
9237    // <axis>: String }` two-slot envelope shape.
9238
9239    #[test]
9240    fn from_invalid_ctor_matches_struct_literal_wrap() {
9241        let from = "not-a-semver";
9242        let reason = "unexpected character '-' at position 3";
9243        assert_eq!(
9244            UpgradeError::from_invalid(from, reason),
9245            UpgradeError::FromInvalid {
9246                from: from.to_string(),
9247                reason: reason.to_string(),
9248            },
9249            "generated from_invalid ctor must produce byte-equal \
9250             UpgradeError to the open-coded struct-literal wrap on the \
9251             same (&str, &str) fixture",
9252        );
9253    }
9254
9255    #[test]
9256    fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
9257        let from = "0.2.0";
9258        let versao = "0.1.0";
9259        assert_eq!(
9260            UpgradeError::from_not_before_versao(from, versao),
9261            UpgradeError::FromNotBeforeVersao {
9262                from: from.to_string(),
9263                versao: versao.to_string(),
9264            },
9265            "generated from_not_before_versao ctor must produce byte-equal \
9266             UpgradeError to the open-coded struct-literal wrap on the \
9267             same (&str, &str) fixture",
9268        );
9269    }
9270
9271    #[test]
9272    fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
9273        let from = "0.1.0";
9274        let module = "hello-rio";
9275        assert_eq!(
9276            UpgradeError::duplicate_load_module(from, module),
9277            UpgradeError::DuplicateLoadModule {
9278                from: from.to_string(),
9279                module: module.to_string(),
9280            },
9281            "generated duplicate_load_module ctor must produce byte-equal \
9282             UpgradeError to the open-coded struct-literal wrap on the \
9283             same (&str, &str) fixture",
9284        );
9285    }
9286
9287    #[test]
9288    fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
9289        // Cross-axis routing pin: sweep the two constructor input axes
9290        // (`from: &str`, `<axis>: &str`) through distinct-per-axis
9291        // fixtures against every generated arm in the
9292        // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
9293        // lowercase / trim / truncate at codegen time — a silent field
9294        // swap between `from` and the middle `<axis>` field, or a
9295        // `<axis>` axis silently rerouted through the wrong field on any
9296        // one variant — surfaces here rather than at a downstream
9297        // diagnostic-shape mismatch. Peer of the sibling
9298        // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9299        // (8e67041) cross-axis pin on the same envelope's sibling
9300        // `{ from: String, script: PathBuf }` two-slot family, and of the
9301        // sibling
9302        // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9303        // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
9304        // String, <axis>: String }` two-slot envelope. Distinct-per-
9305        // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
9306        // that would still pass a same-fixture-per-axis pin. Both
9307        // `&str`-literal and `&String` (via Deref coercion) carriers
9308        // are exercised because the three wire-up sites hand a mix of
9309        // both (the `from_invalid` site hands `&e.to_string()` — an
9310        // owned `String` — for `reason`; the `duplicate_load_module`
9311        // site hands a `&str` slice for `module`; the
9312        // `from_not_before_versao` site hands the caller-supplied
9313        // `versao: &str` for `versao`).
9314        let from = "0.1.0";
9315        let axis = "distinct-axis-value";
9316        let from_owned: String = from.to_string();
9317        let axis_owned: String = axis.to_string();
9318        for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
9319            assert_eq!(
9320                UpgradeError::from_invalid(from_in, axis_in),
9321                UpgradeError::FromInvalid {
9322                    from: from.to_string(),
9323                    reason: axis.to_string(),
9324                },
9325                "from_invalid must route `from` → `from`, `axis` → `reason` \
9326                 in declared field order",
9327            );
9328            assert_eq!(
9329                UpgradeError::from_not_before_versao(from_in, axis_in),
9330                UpgradeError::FromNotBeforeVersao {
9331                    from: from.to_string(),
9332                    versao: axis.to_string(),
9333                },
9334                "from_not_before_versao must route `from` → `from`, \
9335                 `axis` → `versao` in declared field order",
9336            );
9337            assert_eq!(
9338                UpgradeError::duplicate_load_module(from_in, axis_in),
9339                UpgradeError::DuplicateLoadModule {
9340                    from: from.to_string(),
9341                    module: axis.to_string(),
9342                },
9343                "duplicate_load_module must route `from` → `from`, \
9344                 `axis` → `module` in declared field order",
9345            );
9346        }
9347    }
9348
9349    // Per-variant equivalence + accessor-fidelity + cross-axis pins for
9350    // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
9351    // the paired doc-block above the ctor definition) — the fold of the
9352    // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9353    // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9354    // duplicate gate onto one substrate primitive on the
9355    // [`UpgradeError`] envelope, projecting through the paired
9356    // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9357    // primitive. A byte-mismatched ctor body would trip the equivalence
9358    // pin first, ahead of any downstream diagnostic-shape drift.
9359    //
9360    // Peer of the sibling standalone-ctor equivalence pins on the peer
9361    // one-off variants across caixa-core:
9362    // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9363    // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9364    // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9365    // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9366    // envelope, the sibling
9367    // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9368    // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9369    // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9370    // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9371
9372    #[test]
9373    fn duplicate_from_ctor_matches_struct_literal_wrap() {
9374        // Equivalence pin: the ctor produces byte-equal
9375        // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9376        // struct-literal that read the same `from` field through
9377        // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9378        // addition / reordering / string-conversion tweak on the
9379        // variant. Same equivalence-pin shape as the sibling
9380        // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9381        // (b30edfe) on the paired two-slot `{ caixa, wit }`
9382        // envelope inside `impl AplicacaoSpec`.
9383        let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9384        let lifted = UpgradeError::duplicate_from(&entry);
9385        let struct_literal = UpgradeError::DuplicateFrom {
9386            from: entry.prior_versao().to_string(),
9387        };
9388        assert_eq!(lifted, struct_literal);
9389    }
9390
9391    #[test]
9392    fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9393        // Routing pin sweeping a non-default `:from` value
9394        // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9395        // release and build metadata) through the paired
9396        // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9397        // wrapper-side lowercase / trim / truncate on the one-field
9398        // construction surfaces here rather than at a downstream
9399        // diagnostic-shape drift. Peer of the sibling
9400        // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9401        // (b30edfe) routing pin on the sibling two-slot envelope.
9402        //
9403        // The pre-release + build-metadata carrier value is deliberately
9404        // chosen to exercise the `.to_string()` path against a `:from`
9405        // shape [`semver::Version::PartialEq`] treats as distinct from
9406        // its release-only sibling (per the
9407        // `validate_upgrade_from_treats_pre_release_as_distinct` and
9408        // build-metadata-tightening-note doc-block on
9409        // [`validate_upgrade_from`]) — so any silent normalization at
9410        // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9411        // `.split_once('-')` collapse) would drop bytes from the
9412        // rendered diagnostic and surface here.
9413        let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9414        let built = UpgradeError::duplicate_from(&entry);
9415        match built {
9416            UpgradeError::DuplicateFrom { from } => {
9417                assert_eq!(
9418                    from, "1.2.3-rc.4+build.5",
9419                    "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9420                     preserving pre-release + build-metadata bytes"
9421                );
9422            }
9423            other => panic!("expected DuplicateFrom, got {other:?}"),
9424        }
9425    }
9426
9427    #[test]
9428    fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9429        // Accessor-fidelity pin: the ctor's `from` slot keys off the
9430        // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9431        // the pre-lift open-coded body's field selection), not any
9432        // stringified rendering of the full entry (e.g. the
9433        // `impl Display for UpgradeFromEntry` output, if one were later
9434        // added, or a `format!("{:?}", entry)` debug dump). Pins the
9435        // projection axis so a silent swap at the ctor body — say, a
9436        // future refactor that projects through `entry.instructions()`
9437        // in shape (dropping the `:from` axis entirely) or through a
9438        // whole-entry `format!` — surfaces here rather than at a
9439        // downstream diagnostic mis-attribution far from the duplicate
9440        // gate's owner.
9441        //
9442        // A future consumer that constructs the ctor against a not-yet-
9443        // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9444        // CR admission webhook re-checking a per-`:upgrade-from`-patched
9445        // candidate before the cross-entry duplicate gate re-fires, a
9446        // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9447        // `(:from …)` introduced by a cluster-local `:upgrade-from`
9448        // override) needs the pre-lift projection axis pinned.
9449        //
9450        // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9451        // paired with a distinctive multi-instruction sequence so a
9452        // silent swap that projects through the whole-entry rendering
9453        // instead of the paired scalar accessor would land debug bytes
9454        // from the `:instructions` list into the `from` slot and trip
9455        // the assertion here.
9456        let entry = entry(
9457            "0.2.0-alpha.7",
9458            vec![
9459                UpgradeInstruction::LoadModule {
9460                    module: "distinctive-load-target".into(),
9461                },
9462                UpgradeInstruction::StateChange {
9463                    script: PathBuf::from("lib/distinctive-migrate.lisp"),
9464                },
9465                UpgradeInstruction::Restart,
9466            ],
9467        );
9468        let built = UpgradeError::duplicate_from(&entry);
9469        match built {
9470            UpgradeError::DuplicateFrom { from } => {
9471                assert_eq!(
9472                    from, "0.2.0-alpha.7",
9473                    "from slot must project UpgradeFromEntry::prior_versao() \
9474                     (not any whole-entry rendering)"
9475                );
9476            }
9477            other => panic!("expected DuplicateFrom, got {other:?}"),
9478        }
9479    }
9480
9481    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9482    // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9483    // ctor (see the paired doc-block above the ctor definition) — the
9484    // fold of the last open-coded three-slot `{ from: String, kind:
9485    // &'static str, module: String }` struct-literal wire-up on
9486    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9487    // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9488    // load-family sticky-latch dispatch onto one substrate primitive.
9489    // A byte-mismatched ctor body would trip the equivalence pin first,
9490    // ahead of any downstream diagnostic-shape drift.
9491    //
9492    // Peer of the sibling standalone-ctor equivalence + routing pins on
9493    // the sibling one-off variants across `UpgradeError`
9494    // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9495    // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9496    // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9497    // the paired one-slot `{ from: String }` envelope) and across
9498    // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9499    // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9500    // String }` `AplicacaoError` envelope).
9501
9502    #[test]
9503    fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9504        // Equivalence pin: the ctor produces byte-equal
9505        // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9506        // open-coded three-field struct-literal on the same `(&str,
9507        // &'static str, &str)` fixture. Guards any future field-
9508        // addition / reordering / string-conversion tweak on the
9509        // variant. Same equivalence-pin shape as the sibling
9510        // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9511        // on the peer one-slot `{ from: String }` envelope.
9512        let from = "0.1.0";
9513        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9514        let module = "hello-rio-old";
9515        assert_eq!(
9516            UpgradeError::purge_without_prior_load(from, kind, module),
9517            UpgradeError::PurgeWithoutPriorLoad {
9518                from: from.to_string(),
9519                kind,
9520                module: module.to_string(),
9521            },
9522            "generated purge_without_prior_load ctor must produce \
9523             byte-equal UpgradeError to the open-coded struct-literal \
9524             wrap on the same (&str, &'static str, &str) fixture",
9525        );
9526    }
9527
9528    #[test]
9529    fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9530        // Cross-axis routing pin: sweep the three constructor input
9531        // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9532        // through distinct-per-axis fixtures across every cleanup-family
9533        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9534        // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9535        // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9536        // wrapper-side lowercase / trim / truncate / silent axis-swap
9537        // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9538        // three-field construction surfaces at assert time rather than
9539        // at a downstream diagnostic consumer that reads the fields
9540        // back and gets a different value than the one it stored. Both
9541        // `&str`-literal and `&String` (via Deref coercion) carriers
9542        // are exercised for `from` / `module` because the sole wire-up
9543        // hands `self.prior_versao()` (a `&str` accessor) and
9544        // `instr.declared_module().expect(…)` (also a `&str`) — the
9545        // ctor must accept both shapes without a pre-conversion.
9546        let kinds: [&'static str; 2] = [
9547            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9548            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9549        ];
9550        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9551        let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9552        for kind in kinds {
9553            for from in froms {
9554                for module in modules {
9555                    let from_owned: String = from.to_string();
9556                    let module_owned: String = module.to_string();
9557                    for (from_in, module_in) in
9558                        [(from, module), (from_owned.as_str(), module_owned.as_str())]
9559                    {
9560                        assert_eq!(
9561                            UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9562                            UpgradeError::PurgeWithoutPriorLoad {
9563                                from: from.to_string(),
9564                                kind,
9565                                module: module.to_string(),
9566                            },
9567                            "purge_without_prior_load must route from → from, \
9568                             kind → kind, module → module in declared field \
9569                             order verbatim on ({from:?}, {kind:?}, {module:?})",
9570                        );
9571                    }
9572                }
9573            }
9574        }
9575    }
9576
9577    #[test]
9578    fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9579        // End-to-end wire-up pin: build an entry whose declared
9580        // `:instructions` list places a `:soft-purge` (and separately a
9581        // `:purge`) before any `:load-module` so
9582        // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9583        // sticky-latch dispatch surfaces
9584        // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9585        // observed `Err` byte-equals the substrate-primitive
9586        // [`UpgradeError::purge_without_prior_load`] ctor's output on
9587        // the same fixture. A future silent de-lift of the wire-up back
9588        // to the open-coded struct-literal (or a silent axis-swap on
9589        // the three-field construction at the wire-up site) trips at
9590        // caixa-core test time rather than at a downstream diagnostic
9591        // consumer far from the wire-up commit. Same end-to-end-wire-up
9592        // discipline as the sibling
9593        // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9594        // on the peer cross-entry duplicate-`:from` gate; both key off
9595        // exactly one typed dispatch on the substrate primitive.
9596        let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9597            (
9598                "0.1.0",
9599                UpgradeInstruction::SoftPurge {
9600                    module: "hello-rio-old".into(),
9601                },
9602                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9603                "hello-rio-old",
9604            ),
9605            (
9606                "1.2.3-rc.1",
9607                UpgradeInstruction::Purge {
9608                    module: "cache-v2-ancient".into(),
9609                },
9610                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9611                "cache-v2-ancient",
9612            ),
9613        ];
9614        for (from, instr, kind, module) in cases {
9615            let e = entry(from, vec![instr]);
9616            let observed = e.validate().unwrap_err();
9617            assert_eq!(
9618                observed,
9619                UpgradeError::purge_without_prior_load(from, kind, module),
9620                "validate_purge_ordering must route its refusal through \
9621                 UpgradeError::purge_without_prior_load(from, kind, \
9622                 module) on a bare-cleanup {kind:?} entry, byte-equal \
9623                 to the pre-lift open-coded struct-literal wrap on the \
9624                 same fixture",
9625            );
9626        }
9627    }
9628
9629    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9630    // the standalone [`UpgradeError::state_change_after_cleanup`]
9631    // inherent ctor (see the paired doc-block above the ctor
9632    // definition) — the fold of the last open-coded four-slot `{ from:
9633    // String, script: PathBuf, prior_cleanup_kind: &'static str,
9634    // prior_cleanup_module: String }` struct-literal wire-up on
9635    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9636    // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9637    // migrate-family sticky-latch dispatch onto one substrate primitive.
9638    // A byte-mismatched ctor body would trip the equivalence pin first,
9639    // ahead of any downstream diagnostic-shape drift. Peer of the
9640    // sibling standalone-ctor equivalence + routing pins on the sibling
9641    // one-off variants across `UpgradeError`
9642    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9643    // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9644    // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9645    // on the paired three-slot `{ from, kind, module }` envelope;
9646    // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9647    // one-slot `{ from }` envelope).
9648
9649    #[test]
9650    fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9651        // Equivalence pin: the ctor produces byte-equal
9652        // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9653        // open-coded four-field struct-literal on the same `(&str,
9654        // &Path, &'static str, &str)` fixture. Guards any future
9655        // field-addition / reordering / string-conversion tweak on the
9656        // variant. Same equivalence-pin shape as the sibling
9657        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9658        // (9752da1) on the peer three-slot envelope.
9659        let from = "0.1.0";
9660        let script = Path::new("lib/m.lisp");
9661        let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9662        let prior_cleanup_module = "x-old";
9663        assert_eq!(
9664            UpgradeError::state_change_after_cleanup(
9665                from,
9666                script,
9667                prior_cleanup_kind,
9668                prior_cleanup_module,
9669            ),
9670            UpgradeError::StateChangeAfterCleanup {
9671                from: from.to_string(),
9672                script: script.to_path_buf(),
9673                prior_cleanup_kind,
9674                prior_cleanup_module: prior_cleanup_module.to_string(),
9675            },
9676            "generated state_change_after_cleanup ctor must produce \
9677             byte-equal UpgradeError to the open-coded struct-literal \
9678             wrap on the same (&str, &Path, &'static str, &str) fixture",
9679        );
9680    }
9681
9682    #[test]
9683    fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9684        // Cross-axis routing pin: sweep the four constructor input
9685        // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9686        // &'static str`, `prior_cleanup_module: &str`) through
9687        // distinct-per-axis fixtures across every cleanup-family
9688        // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9689        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9690        // build-metadata, zero), sibling-`.lisp` script-path shapes
9691        // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9692        // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9693        // lowercase / trim / truncate / silent axis-swap
9694        // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9695        // `from`, `prior_cleanup_kind` misrouted onto
9696        // `prior_cleanup_module`) on the four-field construction
9697        // surfaces at assert time rather than at a downstream diagnostic
9698        // consumer that reads the fields back and gets a different value
9699        // than the one it stored. Both `&str`-literal and `&String` (via
9700        // Deref coercion) carriers are exercised for `from` /
9701        // `prior_cleanup_module` because the sole wire-up hands
9702        // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9703        // (also `&str`, from `declared_module().expect(…)`) — the ctor
9704        // must accept both shapes without a pre-conversion. Both
9705        // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9706        // are exercised for `script` because the sole wire-up hands a
9707        // `&PathBuf` sticky-latch projection from `declared_path()`'s
9708        // `Option<&PathBuf>` return — the ctor must accept both shapes
9709        // without a pre-conversion.
9710        let kinds: [&'static str; 2] = [
9711            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9712            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9713        ];
9714        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9715        let scripts: [&str; 3] = [
9716            "m.lisp",
9717            "lib/migrations.lisp",
9718            "lib/migrations/v01/step-1.lisp",
9719        ];
9720        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9721        for kind in kinds {
9722            for from in froms {
9723                for script_str in scripts {
9724                    for module in modules {
9725                        let from_owned: String = from.to_string();
9726                        let module_owned: String = module.to_string();
9727                        let script_path = Path::new(script_str);
9728                        let script_pathbuf = PathBuf::from(script_str);
9729                        for (from_in, module_in, script_in) in [
9730                            (from, module, script_path),
9731                            (
9732                                from_owned.as_str(),
9733                                module_owned.as_str(),
9734                                script_pathbuf.as_path(),
9735                            ),
9736                        ] {
9737                            assert_eq!(
9738                                UpgradeError::state_change_after_cleanup(
9739                                    from_in, script_in, kind, module_in,
9740                                ),
9741                                UpgradeError::StateChangeAfterCleanup {
9742                                    from: from.to_string(),
9743                                    script: PathBuf::from(script_str),
9744                                    prior_cleanup_kind: kind,
9745                                    prior_cleanup_module: module.to_string(),
9746                                },
9747                                "state_change_after_cleanup must route from → from, \
9748                                 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9749                                 prior_cleanup_module → prior_cleanup_module in declared \
9750                                 field order verbatim on ({from:?}, {script_str:?}, \
9751                                 {kind:?}, {module:?})",
9752                            );
9753                        }
9754                    }
9755                }
9756            }
9757        }
9758    }
9759
9760    #[test]
9761    fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9762        // End-to-end wire-up pin: build an entry whose declared
9763        // `:instructions` list places a `:soft-purge` (and separately a
9764        // `:purge`) before a `:state-change` so
9765        // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9766        // migrate-family sticky-latch dispatch surfaces
9767        // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9768        // observed `Err` byte-equals the substrate-primitive
9769        // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9770        // the same fixture. A future silent de-lift of the wire-up back
9771        // to the open-coded struct-literal (or a silent axis-swap on
9772        // the four-field construction at the wire-up site) trips at
9773        // caixa-core test time rather than at a downstream diagnostic
9774        // consumer far from the wire-up commit. Same end-to-end-wire-up
9775        // discipline as the sibling
9776        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9777        // on the peer load → cleanup ordering gate; both key off
9778        // exactly one typed dispatch on the substrate primitive. Every
9779        // entry here front-loads a `:load-module` so the sole surviving
9780        // ordering refusal is the migrate → cleanup one this gate
9781        // owns — the peer `validate_purge_ordering` load → cleanup gate
9782        // returns `Ok(())` on these fixtures, so the migrate-after-
9783        // cleanup arm is the only path to an `Err`.
9784        let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9785            (
9786                "0.1.0",
9787                UpgradeInstruction::SoftPurge {
9788                    module: "hello-rio-old".into(),
9789                },
9790                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9791                "hello-rio-old",
9792                "lib/migrations/v01.lisp",
9793            ),
9794            (
9795                "1.2.3-rc.1",
9796                UpgradeInstruction::Purge {
9797                    module: "cache-v2-ancient".into(),
9798                },
9799                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9800                "cache-v2-ancient",
9801                "lib/migrations/v02.lisp",
9802            ),
9803        ];
9804        for (from, cleanup, kind, module, script_str) in cases {
9805            let script = PathBuf::from(script_str);
9806            let e = entry(
9807                from,
9808                vec![
9809                    UpgradeInstruction::LoadModule {
9810                        module: "hello-rio".into(),
9811                    },
9812                    cleanup,
9813                    UpgradeInstruction::StateChange {
9814                        script: script.clone(),
9815                    },
9816                ],
9817            );
9818            let observed = e.validate().unwrap_err();
9819            assert_eq!(
9820                observed,
9821                UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9822                "validate_state_change_before_cleanup must route its \
9823                 refusal through \
9824                 UpgradeError::state_change_after_cleanup(from, script, \
9825                 prior_cleanup_kind, prior_cleanup_module) on a \
9826                 `:state-change` after a bare-cleanup {kind:?} entry, \
9827                 byte-equal to the pre-lift open-coded struct-literal \
9828                 wrap on the same fixture",
9829            );
9830        }
9831    }
9832
9833    // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9834    // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9835    // (see the paired doc-block above the ctor definition) — the fold of
9836    // the last open-coded three-slot `{ from: String, module: String,
9837    // kinds: Vec<&'static str> }` struct-literal wire-up on
9838    // [`UpgradeError`] closes the sole in-crate wire-up site inside
9839    // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9840    // cleanup-family dedup arm onto one substrate primitive. A byte-
9841    // mismatched ctor body would trip the equivalence pin first, ahead of
9842    // any downstream diagnostic-shape drift. Peer of the sibling
9843    // standalone-ctor equivalence + routing pins on the sibling one-off
9844    // variants across `UpgradeError`
9845    // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9846    // paired three-slot `{ from, kind, module }` envelope for the sibling
9847    // load → cleanup ordering axis;
9848    // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9849    // the paired four-slot `{ from, script, prior_cleanup_kind,
9850    // prior_cleanup_module }` envelope for the migrate → cleanup
9851    // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9852    // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9853    // `:from` gate).
9854
9855    #[test]
9856    fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9857        // Equivalence pin: the ctor produces byte-equal
9858        // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9859        // three-field struct-literal on the same `(&str, &str,
9860        // Vec<&'static str>)` fixture. Guards any future field-addition /
9861        // reordering / string-conversion tweak on the variant. Same
9862        // equivalence-pin shape as the sibling
9863        // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9864        // (9752da1) on the peer three-slot envelope.
9865        let from = "0.1.0";
9866        let module = "x-old";
9867        let kinds: Vec<&'static str> = vec![
9868            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9869            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9870        ];
9871        assert_eq!(
9872            UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9873            UpgradeError::DuplicateCleanup {
9874                from: from.to_string(),
9875                module: module.to_string(),
9876                kinds,
9877            },
9878            "generated duplicate_cleanup ctor must produce byte-equal \
9879             UpgradeError to the open-coded struct-literal wrap on the \
9880             same (&str, &str, Vec<&'static str>) fixture",
9881        );
9882    }
9883
9884    #[test]
9885    fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9886        // Cross-axis routing pin: sweep the three constructor input axes
9887        // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9888        // through distinct-per-axis fixtures across every ordered pair of
9889        // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9890        // four `(prior_kind, kind)` combinations `validate_cleanup_
9891        // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9892        // SemVer-2 `from` shapes (release, pre-release, pre-release +
9893        // build-metadata, zero) + DNS-1123 module shapes (leaf,
9894        // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9895        // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9896        // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9897        // owned `Vec<&'static str>`) on the three-field construction
9898        // surfaces at assert time rather than at a downstream diagnostic
9899        // consumer that reads the fields back and gets a different value
9900        // than the one it stored. Both `&str`-literal and `&String` (via
9901        // Deref coercion) carriers are exercised for `from` / `module`
9902        // because the sole wire-up hands `self.prior_versao()` (a `&str`
9903        // accessor) and `module` (also `&str`, from `declared_module().
9904        // expect(…)`) — the ctor must accept both shapes without a
9905        // pre-conversion.
9906        let all_kinds: [&'static str; 2] = [
9907            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9908            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9909        ];
9910        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9911        let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9912        for prior_kind in all_kinds {
9913            for kind in all_kinds {
9914                for from in froms {
9915                    for module in modules {
9916                        let from_owned: String = from.to_string();
9917                        let module_owned: String = module.to_string();
9918                        for (from_in, module_in) in
9919                            [(from, module), (from_owned.as_str(), module_owned.as_str())]
9920                        {
9921                            let kinds: Vec<&'static str> = vec![prior_kind, kind];
9922                            assert_eq!(
9923                                UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
9924                                UpgradeError::DuplicateCleanup {
9925                                    from: from.to_string(),
9926                                    module: module.to_string(),
9927                                    kinds,
9928                                },
9929                                "duplicate_cleanup must route from → from, \
9930                                 module → module, kinds → kinds in declared \
9931                                 field order verbatim on ({from:?}, \
9932                                 {module:?}, [{prior_kind:?}, {kind:?}])",
9933                            );
9934                        }
9935                    }
9936                }
9937            }
9938        }
9939    }
9940
9941    #[test]
9942    fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
9943        // End-to-end wire-up pin: build an entry whose declared
9944        // `:instructions` list front-loads a `:load-module` (so the
9945        // sibling `validate_purge_ordering` load → cleanup gate returns
9946        // `Ok(())` on the fixture) and then places two cleanup
9947        // instructions targeting the same module so
9948        // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9949        // cleanup-family dedup arm surfaces
9950        // `UpgradeError::DuplicateCleanup`, then pin that the observed
9951        // `Err` byte-equals the substrate-primitive
9952        // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
9953        // fixture. A future silent de-lift of the wire-up back to the
9954        // open-coded struct-literal (or a silent axis-swap on the three-
9955        // field construction at the wire-up site, or a kinds-pair
9956        // reorder) trips at caixa-core test time rather than at a
9957        // downstream diagnostic consumer far from the wire-up commit.
9958        // Same end-to-end-wire-up discipline as the sibling
9959        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9960        // on the peer load → cleanup ordering gate and
9961        // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
9962        // on the peer migrate → cleanup boundary; all three key off
9963        // exactly one typed dispatch on the substrate primitive.
9964        let cases: [(
9965            &str,
9966            UpgradeInstruction,
9967            UpgradeInstruction,
9968            &str,
9969            [&'static str; 2],
9970        ); 4] = [
9971            (
9972                "0.1.0",
9973                UpgradeInstruction::SoftPurge {
9974                    module: "hello-rio-old".into(),
9975                },
9976                UpgradeInstruction::SoftPurge {
9977                    module: "hello-rio-old".into(),
9978                },
9979                "hello-rio-old",
9980                [
9981                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9982                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9983                ],
9984            ),
9985            (
9986                "1.2.3-rc.1",
9987                UpgradeInstruction::Purge {
9988                    module: "cache-v2-ancient".into(),
9989                },
9990                UpgradeInstruction::Purge {
9991                    module: "cache-v2-ancient".into(),
9992                },
9993                "cache-v2-ancient",
9994                [
9995                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9996                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9997                ],
9998            ),
9999            (
10000                "0.2.0-alpha.7+build.5",
10001                UpgradeInstruction::SoftPurge {
10002                    module: "x-old".into(),
10003                },
10004                UpgradeInstruction::Purge {
10005                    module: "x-old".into(),
10006                },
10007                "x-old",
10008                [
10009                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10010                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10011                ],
10012            ),
10013            (
10014                "0.0.0",
10015                UpgradeInstruction::Purge {
10016                    module: "x-old".into(),
10017                },
10018                UpgradeInstruction::SoftPurge {
10019                    module: "x-old".into(),
10020                },
10021                "x-old",
10022                [
10023                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10024                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10025                ],
10026            ),
10027        ];
10028        for (from, first, second, module, kinds) in cases {
10029            let e = entry(
10030                from,
10031                vec![
10032                    UpgradeInstruction::LoadModule {
10033                        module: "hello-rio".into(),
10034                    },
10035                    first,
10036                    second,
10037                ],
10038            );
10039            let observed = e.validate().unwrap_err();
10040            assert_eq!(
10041                observed,
10042                UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
10043                "validate_cleanup_singularity must route its refusal \
10044                 through UpgradeError::duplicate_cleanup(from, module, \
10045                 kinds) on a two-cleanup {kinds:?} entry targeting the \
10046                 same module, byte-equal to the pre-lift open-coded \
10047                 struct-literal wrap on the same fixture",
10048            );
10049        }
10050    }
10051
10052    #[test]
10053    fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
10054        // Equivalence pin: the ctor produces byte-equal
10055        // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
10056        // three-field struct-literal on the same `(&str, usize,
10057        // Vec<&'static str>)` fixture. Guards any future field-addition /
10058        // reordering / string-conversion tweak on the variant. Same
10059        // equivalence-pin shape as the sibling
10060        // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
10061        // on the peer three-slot envelope.
10062        let from = "0.1.0";
10063        let restart_count: usize = 1;
10064        let other_kinds: Vec<&'static str> =
10065            vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
10066        assert_eq!(
10067            UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10068            UpgradeError::RestartNotExclusive {
10069                from: from.to_string(),
10070                restart_count,
10071                other_kinds,
10072            },
10073            "generated restart_not_exclusive ctor must produce byte-equal \
10074             UpgradeError to the open-coded struct-literal wrap on the \
10075             same (&str, usize, Vec<&'static str>) fixture",
10076        );
10077    }
10078
10079    #[test]
10080    fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
10081        // Cross-axis routing pin: sweep the three constructor input axes
10082        // (`from: &str`, `restart_count: usize`, `other_kinds:
10083        // Vec<&'static str>`) through distinct-per-axis fixtures across a
10084        // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
10085        // pre-release + build-metadata, zero) × non-degenerate
10086        // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
10087        // pure-duplication shape, 3 — the deeply-duplicated shape) ×
10088        // ordered `other_kinds` lisp-form lists spanning the four
10089        // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
10090        // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
10091        // empty (the `((:restart) (:restart))` shape), singleton
10092        // (`((:load-module …) (:restart))`), and the full typed sequence
10093        // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
10094        // …) (:restart))`) — so any wrapper-side silent lowercase / trim
10095        // / truncate / silent axis-swap (`from` ↔ swap onto
10096        // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
10097        // duplicate on the four-element owned `Vec<&'static str>`,
10098        // `other_kinds` reorder against declared instruction order) on
10099        // the three-field construction surfaces at assert time rather
10100        // than at a downstream diagnostic consumer that reads the fields
10101        // back and gets a different value than the one it stored. Both
10102        // `&str`-literal and `&String` (via Deref coercion) carriers are
10103        // exercised for `from` because the sole wire-up hands
10104        // `self.prior_versao()` (a `&str` accessor).
10105        let all_typed_kinds: [&'static str; 4] = [
10106            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10107            crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10108            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10109            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10110        ];
10111        let other_kinds_matrix: [Vec<&'static str>; 3] =
10112            [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
10113        let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
10114        let restart_counts: [usize; 3] = [1, 2, 3];
10115        for other_kinds in &other_kinds_matrix {
10116            for restart_count in restart_counts {
10117                for from in froms {
10118                    let from_owned: String = from.to_string();
10119                    for from_in in [from, from_owned.as_str()] {
10120                        assert_eq!(
10121                            UpgradeError::restart_not_exclusive(
10122                                from_in,
10123                                restart_count,
10124                                other_kinds.clone(),
10125                            ),
10126                            UpgradeError::RestartNotExclusive {
10127                                from: from.to_string(),
10128                                restart_count,
10129                                other_kinds: other_kinds.clone(),
10130                            },
10131                            "restart_not_exclusive must route from → from, \
10132                             restart_count → restart_count, other_kinds → \
10133                             other_kinds in declared field order verbatim \
10134                             on ({from:?}, {restart_count:?}, \
10135                             {other_kinds:?})",
10136                        );
10137                    }
10138                }
10139            }
10140        }
10141    }
10142
10143    #[test]
10144    fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
10145        // End-to-end wire-up pin: sweep the three canonical exclusivity-
10146        // violation shapes the `validate_restart_exclusive` gate can
10147        // refuse — restart + one typed instruction (`restart_count: 1,
10148        // other_kinds: [load-module]`), restart + full typed sequence
10149        // (`restart_count: 1, other_kinds: [load-module, state-change,
10150        // soft-purge, purge]`), and duplicated restart only
10151        // (`restart_count: 2, other_kinds: []`) — and pin that each
10152        // observed `Err` byte-equals the substrate-primitive
10153        // [`UpgradeError::restart_not_exclusive`] ctor's output on the
10154        // same fixture. A future silent de-lift of the wire-up back to
10155        // the open-coded struct-literal (or a silent axis-swap on the
10156        // three-field construction at the wire-up site, or an
10157        // `other_kinds` reorder / drop) trips at caixa-core test time
10158        // rather than at a downstream diagnostic consumer far from the
10159        // wire-up commit. Same end-to-end-wire-up discipline as the
10160        // sibling
10161        // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
10162        // (10a5b48) on the peer per-module cleanup-singularity axis and
10163        // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
10164        // on the peer load → cleanup ordering gate; all three key off
10165        // exactly one typed dispatch on the substrate primitive.
10166        let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
10167            (
10168                "0.1.0",
10169                vec![
10170                    UpgradeInstruction::LoadModule {
10171                        module: "hello-rio".into(),
10172                    },
10173                    UpgradeInstruction::Restart,
10174                ],
10175                1,
10176                vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
10177            ),
10178            (
10179                "1.2.3-rc.1",
10180                vec![
10181                    UpgradeInstruction::LoadModule {
10182                        module: "hello-rio".into(),
10183                    },
10184                    UpgradeInstruction::StateChange {
10185                        script: PathBuf::from("lib/m.lisp"),
10186                    },
10187                    UpgradeInstruction::SoftPurge {
10188                        module: "hello-rio-old".into(),
10189                    },
10190                    UpgradeInstruction::Purge {
10191                        module: "hello-rio-old".into(),
10192                    },
10193                    UpgradeInstruction::Restart,
10194                ],
10195                1,
10196                vec![
10197                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10198                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10199                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10200                    crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10201                ],
10202            ),
10203            (
10204                "0.0.0",
10205                vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
10206                2,
10207                vec![],
10208            ),
10209        ];
10210        for (from, instructions, restart_count, other_kinds) in cases {
10211            let e = entry(from, instructions);
10212            let observed = e.validate().unwrap_err();
10213            assert_eq!(
10214                observed,
10215                UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10216                "validate_restart_exclusive must route its refusal \
10217                 through UpgradeError::restart_not_exclusive(from, \
10218                 restart_count, other_kinds) on a mixed-`(:restart)` \
10219                 entry, byte-equal to the pre-lift open-coded struct-\
10220                 literal wrap on the same fixture",
10221            );
10222        }
10223    }
10224
10225    #[test]
10226    fn module_invalid_ctor_matches_struct_literal_wrap() {
10227        // Fail-before-pass-after equivalence pin on
10228        // [`UpgradeError::module_invalid`] — the constructor must
10229        // produce a byte-equal `UpgradeError` to the pre-lift open-
10230        // coded `Self::ModuleInvalid { kind, module: module.to_string(),
10231        // reason }` struct-literal on the same `(:load-module …)` /
10232        // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
10233        // mismatched constructor body (a stray `.trim()`, a rebased
10234        // field order, a `String::new()` reason substitution) would
10235        // trip this pin first, byte-for-byte against the sibling
10236        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
10237        // [`crate::SupervisorError::child_caixa_invalid`] /
10238        // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
10239        // discipline on the peer three-slot `{ *, reason: String }`
10240        // invalid-arm ctor family.
10241        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10242        let module = "Hello-Rio";
10243        let reason = "must be lowercase alphanumeric or `-`";
10244        assert_eq!(
10245            UpgradeError::module_invalid(kind, module, reason),
10246            UpgradeError::ModuleInvalid {
10247                kind,
10248                module: module.to_string(),
10249                reason: reason.to_string(),
10250            },
10251            "generated module_invalid ctor must produce byte-equal \
10252             UpgradeError to the open-coded struct-literal wrap on the \
10253             same (kind, module, reason) fixture",
10254        );
10255    }
10256
10257    #[test]
10258    fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10259        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10260        // input across every [`UpgradeInstruction::declared_module`]-
10261        // bearing variant's canonical
10262        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10263        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10264        // canonical `":phantom"` fourth arm proving the ctor does not
10265        // silently clamp `kind` to the three-arm roster. The
10266        // `reason: impl Into<String>` bound accepts both `&str`
10267        // literals and the [`String`] the underlying
10268        // [`crate::render::is_dns_1123_label`] predicate returns via
10269        // `.into()`, matching the peer
10270        // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
10271        // sweep on the sibling `:contratos` per-edge envelope.
10272        let module = "Hello-Rio";
10273        let reason = "must be lowercase alphanumeric or `-`";
10274        for kind in [
10275            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10276            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10277            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10278            ":phantom",
10279        ] {
10280            assert_eq!(
10281                UpgradeError::module_invalid(kind, module, reason),
10282                UpgradeError::ModuleInvalid {
10283                    kind,
10284                    module: module.to_string(),
10285                    reason: reason.to_string(),
10286                },
10287                "module_invalid ctor must thread kind={kind:?} verbatim",
10288            );
10289        }
10290    }
10291
10292    #[test]
10293    fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
10294        // End-to-end wire-up pin: [`validate_module`]'s
10295        // [`crate::render::require_valid_dns_1123_label`] invalid-arm
10296        // must emit a diagnostic byte-equal to the ctor's output on the
10297        // same `(kind, module)` fixture — the fold's invariant that
10298        // [`validate_module`]'s cascade reaches the
10299        // [`UpgradeError::ModuleInvalid`] envelope through the
10300        // substrate primitive [`UpgradeError::module_invalid`] rather
10301        // than the pre-lift open-coded struct-literal. Sweep every
10302        // [`UpgradeInstruction::declared_module`]-bearing variant
10303        // against a canonical footgun (`"Hello-Rio"` — the uppercase-
10304        // lead footgun the peer `validate_rejects_non_dns_1123_module`
10305        // test above already carries) so every wire-up on the invalid-
10306        // arm cascade lands on the ctor's output. Matches the peer
10307        // sibling end-to-end pin
10308        // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
10309        // carries on `validate_contrato_caixa`'s
10310        // `require_valid_dns_1123_label` invalid-arm.
10311        let module = "Hello-Rio";
10312        let cases: &[(UpgradeInstruction, &'static str)] = &[
10313            (
10314                UpgradeInstruction::LoadModule {
10315                    module: module.to_string(),
10316                },
10317                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10318            ),
10319            (
10320                UpgradeInstruction::SoftPurge {
10321                    module: module.to_string(),
10322                },
10323                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10324            ),
10325            (
10326                UpgradeInstruction::Purge {
10327                    module: module.to_string(),
10328                },
10329                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10330            ),
10331        ];
10332        for (instr, expected_kind) in cases {
10333            let observed = instr.validate().unwrap_err();
10334            let UpgradeError::ModuleInvalid {
10335                reason: observed_reason,
10336                ..
10337            } = &observed
10338            else {
10339                panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
10340            };
10341            assert_eq!(
10342                observed,
10343                UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
10344                "validate_module must route its invalid-arm refusal \
10345                 through UpgradeError::module_invalid(kind, module, \
10346                 reason) on {instr:?}, byte-equal to the pre-lift open-\
10347                 coded struct-literal wrap on the same fixture",
10348            );
10349        }
10350    }
10351
10352    #[test]
10353    fn module_empty_ctor_matches_struct_literal_wrap() {
10354        // Fail-before-pass-after equivalence pin on
10355        // [`UpgradeError::module_empty`] — the constructor must produce
10356        // a byte-equal `UpgradeError` to the pre-lift open-coded
10357        // `Self::ModuleEmpty { kind }` struct-literal on the same
10358        // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10359        // axis-tag fixture. A byte-mismatched constructor body (a stray
10360        // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10361        // one of the three canonical arms, a fixed-slot substitution)
10362        // would trip this pin first, matching the sibling
10363        // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10364        // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10365        // pin discipline on the peer one-slot `{ *: &'static str }`
10366        // empty-arm ctor family.
10367        let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10368        assert_eq!(
10369            UpgradeError::module_empty(kind),
10370            UpgradeError::ModuleEmpty { kind },
10371            "generated module_empty ctor must produce byte-equal \
10372             UpgradeError to the open-coded struct-literal wrap on the \
10373             same kind fixture",
10374        );
10375    }
10376
10377    #[test]
10378    fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10379        // Cross-axis pin: sweep the constructor's `kind: &'static str`
10380        // input across every [`UpgradeInstruction::declared_module`]-
10381        // bearing variant's canonical
10382        // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10383        // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10384        // canonical `":phantom"` fourth arm proving the ctor does not
10385        // silently clamp `kind` to the three-arm roster (a future
10386        // fourth `declared_module`-bearing `UpgradeInstruction` variant
10387        // lands on this ctor without a per-arm rewrite). Matches the
10388        // sibling [`Self::module_invalid`] cross-axis sweep at
10389        // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10390        // on the paired three-slot invalid-arm envelope so both arms of
10391        // the [`validate_module`] two-closure cascade carry the same
10392        // axis-invariance guarantee.
10393        for kind in [
10394            crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10395            crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10396            crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10397            ":phantom",
10398        ] {
10399            assert_eq!(
10400                UpgradeError::module_empty(kind),
10401                UpgradeError::ModuleEmpty { kind },
10402                "module_empty ctor must thread kind={kind:?} verbatim",
10403            );
10404        }
10405    }
10406
10407    #[test]
10408    fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10409        // End-to-end wire-up pin: [`validate_module`]'s
10410        // [`crate::render::require_valid_dns_1123_label`] empty-arm
10411        // must emit a diagnostic byte-equal to the ctor's output on the
10412        // same `(kind, "")` fixture — the fold's invariant that
10413        // [`validate_module`]'s cascade reaches the
10414        // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10415        // primitive [`UpgradeError::module_empty`] rather than the
10416        // pre-lift open-coded struct-literal. Sweep every
10417        // [`UpgradeInstruction::declared_module`]-bearing variant
10418        // against the empty-string module value so every wire-up on the
10419        // empty-arm cascade lands on the ctor's output. Closes the pair
10420        // on the [`validate_module`] two-closure cascade the sibling
10421        // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10422        // (3d0d64a) already anchors on the invalid-arm.
10423        let cases: &[(UpgradeInstruction, &'static str)] = &[
10424            (
10425                UpgradeInstruction::LoadModule {
10426                    module: String::new(),
10427                },
10428                crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10429            ),
10430            (
10431                UpgradeInstruction::SoftPurge {
10432                    module: String::new(),
10433                },
10434                crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10435            ),
10436            (
10437                UpgradeInstruction::Purge {
10438                    module: String::new(),
10439                },
10440                crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10441            ),
10442        ];
10443        for (instr, expected_kind) in cases {
10444            assert_eq!(
10445                instr.validate().unwrap_err(),
10446                UpgradeError::module_empty(expected_kind),
10447                "validate_module must route its empty-arm refusal \
10448                 through UpgradeError::module_empty(kind) on {instr:?}, \
10449                 byte-equal to the pre-lift open-coded struct-literal \
10450                 wrap on the same fixture",
10451            );
10452        }
10453    }
10454
10455    /// Fixture roster covering every [`UpgradeInstruction`] arm — a
10456    /// concrete-instance witness per variant so the four
10457    /// canonical-projection-triple pin tests below sweep the same five
10458    /// arms without duplicating the arm-shape declaration at each
10459    /// probe site. A future arm addition (a `Discard` peer the
10460    /// `code:delete/1` analog might inspire, a `SoftPurge` split into
10461    /// `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-down policy
10462    /// grows a two-arm shape) extends this fixture list as a single
10463    /// edit; the pin sweeps below then reach the new arm by iteration
10464    /// rather than a hand-authored per-arm probe.
10465    fn upgrade_instruction_arm_roster() -> Vec<(UpgradeInstruction, &'static str)> {
10466        vec![
10467            (
10468                UpgradeInstruction::LoadModule {
10469                    module: "hello-rio".into(),
10470                },
10471                "load-module",
10472            ),
10473            (
10474                UpgradeInstruction::StateChange {
10475                    script: std::path::PathBuf::from("lib/migrations/v01-to-v02.lisp"),
10476                },
10477                "state-change",
10478            ),
10479            (
10480                UpgradeInstruction::SoftPurge {
10481                    module: "hello-rio-old".into(),
10482                },
10483                "soft-purge",
10484            ),
10485            (
10486                UpgradeInstruction::Purge {
10487                    module: "hello-rio-old".into(),
10488                },
10489                "purge",
10490            ),
10491            (UpgradeInstruction::Restart, "restart"),
10492        ]
10493    }
10494
10495    #[test]
10496    fn upgrade_instruction_as_str_returns_canonical_kebab_wire_bytes() {
10497        // Fail-before-pass-after pin on the [`UpgradeInstruction::as_str`]
10498        // canonical-projection accessor: the five match arms each return
10499        // the un-prefixed kebab wire byte-string every serde-carried CR /
10500        // structured-log / fleet-catalog identity consumer converges onto.
10501        // A future variant rename or a per-arm typo (e.g. dropping the
10502        // hyphen from `"load-module"` → `"loadmodule"`) trips at
10503        // caixa-core test time rather than surfacing as a downstream K8s-
10504        // CR round-trip miss where the paired `Deserialize` derive
10505        // rejects the drifted arm on every apply.
10506        for (variant, expected) in upgrade_instruction_arm_roster() {
10507            assert_eq!(
10508                variant.as_str(),
10509                expected,
10510                "UpgradeInstruction::{variant:?}.as_str() must return the \
10511                 canonical un-prefixed kebab wire byte-string"
10512            );
10513        }
10514    }
10515
10516    #[test]
10517    fn upgrade_instruction_as_str_matches_discriminant_derive() {
10518        // Load-bearing pin on the two-source alignment: the hand-authored
10519        // [`UpgradeInstruction::as_str`] match arms must byte-equal the
10520        // [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
10521        // per-arm output for every variant. `.discriminant()` is the
10522        // fleet-wide dispatcher-catalog identity (registered under
10523        // `"caixa.upgrade-instruction"` by the sibling
10524        // `gen_platform::register_dispatcher!` macro invocation at
10525        // upgrade.rs:88); [`Self::as_str`] is the standard-library
10526        // `AsRef<str>` / [`std::fmt::Display`]-routed diagnostic byte-
10527        // string. Both must stay aligned so a consumer that reaches
10528        // through either path lands on the same per-arm byte-string.
10529        // A future rename on either side (a per-arm serde-attribute
10530        // drift silently splitting the derive's kebab output from the
10531        // hand-authored arms, a hand-authored typo on the [`Self::as_str`]
10532        // match arm silently splitting the standard-library-routed path
10533        // from the catalog identity) trips here at caixa-core test time
10534        // rather than as a divergent per-consumer dispatch at some future
10535        // downstream site.
10536        for (variant, _expected) in upgrade_instruction_arm_roster() {
10537            assert_eq!(
10538                variant.as_str(),
10539                variant.discriminant(),
10540                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10541                 the gen_platform::Discriminant-derived discriminant() \
10542                 output — the two axes are the substrate's kebab-case wire \
10543                 identity and must stay aligned by construction"
10544            );
10545        }
10546    }
10547
10548    #[test]
10549    fn upgrade_instruction_as_str_matches_serialize_wire_kind_tag() {
10550        // Load-bearing pin on the derive-to-hand alignment on the *wire*
10551        // axis: the hand-authored [`UpgradeInstruction::as_str`] match
10552        // arms must byte-equal the JSON tag the un-`rename`d
10553        // `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
10554        // emits under the paired
10555        // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag key.
10556        // A future accidental `rename_all = "snake_case"` /
10557        // `"UPPERCASE"` attribute drift at the derive surface, or a
10558        // per-variant `#[serde(rename = "…")]` overlay silently
10559        // targeting one arm, would silently split the wire byte-shape
10560        // every K8s-CR / tatara-lisp round-trip / fleet-catalog
10561        // consumer reads through the two paths — pinning the identity
10562        // here makes any such drift a caixa-core-test-time failure.
10563        // Sibling in shape to
10564        // [`crate::kind::tests::caixa_kind_wire_name_matches_serialize_wire_byte_string`]
10565        // on the top-level [`crate::CaixaKind`] axis.
10566        for (variant, _expected) in upgrade_instruction_arm_roster() {
10567            let json = serde_json::to_value(&variant).expect("serialize must succeed");
10568            let kind_tag = json
10569                .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
10570                .and_then(serde_json::Value::as_str)
10571                .unwrap_or_else(|| {
10572                    panic!(
10573                        "serialized UpgradeInstruction::{variant:?} must \
10574                         carry the M2_UPGRADE_INSTRUCTION_KEY_KIND tag as \
10575                         a JSON string"
10576                    )
10577                });
10578            assert_eq!(
10579                variant.as_str(),
10580                kind_tag,
10581                "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10582                 the serde-derived JSON \"kind\" tag — a mismatch means \
10583                 either the derive attributes drifted or the as_str match \
10584                 arms drifted; either way downstream K8s-CR round-trip \
10585                 silently splits from the accessor-routed source of truth"
10586            );
10587        }
10588    }
10589
10590    #[test]
10591    fn upgrade_instruction_display_routes_through_as_str_helper() {
10592        // Fail-before-pass-after pin on the two-path convergence: pre-
10593        // lift [`UpgradeInstruction`] carried no [`std::fmt::Display`]
10594        // surface at all — every consumer past the wire format had to
10595        // pick between [`Self::lisp_form`] returning the tatara-lisp
10596        // author-surface with `:` prefix or `format!("{v:?}")` on the
10597        // `Debug` derive returning the PascalCase variant name plus
10598        // struct-literal fields. Wiring [`std::fmt::Display`] through
10599        // [`Self::as_str`] closes the drift footgun: every
10600        // `format!("{v}")` call reaches the same kebab wire byte-string
10601        // the [`Self::as_str`] helper returns, so a future variant
10602        // rename lands at exactly one place. Pin the routing here so a
10603        // future `impl std::fmt::Display for UpgradeInstruction`
10604        // reimplementation that hand-rolls the arms instead of
10605        // delegating to [`Self::as_str`] fails at caixa-core build
10606        // time. Peer of the sibling
10607        // [`crate::supervisor::tests::restart_strategy_display_routes_through_as_str_helper`]
10608        // /
10609        // [`crate::supervisor::tests::restart_policy_display_routes_through_as_str_helper`]
10610        // /
10611        // [`crate::kind::tests::caixa_kind_display_routes_through_as_str_helper`]
10612        // pins on the sibling closed-set typed-enum discriminator axes.
10613        for (variant, _expected) in upgrade_instruction_arm_roster() {
10614            assert_eq!(
10615                variant.to_string(),
10616                variant.as_str(),
10617                "UpgradeInstruction::{variant:?} Display must route \
10618                 through UpgradeInstruction::as_str (single source of \
10619                 truth: the kebab wire byte-string per arm)"
10620            );
10621        }
10622    }
10623
10624    #[test]
10625    fn upgrade_instruction_display_matches_as_str_and_not_lisp_form() {
10626        // Two-axis-split pin: the tatara-lisp author-surface form
10627        // ([`UpgradeInstruction::lisp_form`], with `:` prefix) and the
10628        // wire form ([`UpgradeInstruction::as_str`], without `:`
10629        // prefix) are structurally distinct by design. The pin here
10630        // makes the split load-bearing: a future accidental collapse
10631        // of either axis onto the other (routing `Display` through
10632        // [`Self::lisp_form`] via a mistaken match-arm re-inlining, or
10633        // routing [`Self::lisp_form`] through [`Self::as_str`] and
10634        // dropping the `:` prefix) would trip here at caixa-core
10635        // build time rather than silently merging the two axes at
10636        // some future consumer's per-instruction dispatch step. Peer
10637        // of the sibling
10638        // [`crate::kind::tests::caixa_kind_display_matches_as_str_and_not_serialize_wire`]
10639        // pin on the top-level [`crate::CaixaKind`] two-axis surface.
10640        for (variant, _expected) in upgrade_instruction_arm_roster() {
10641            let display = variant.to_string();
10642            let lisp = variant.lisp_form();
10643            assert_eq!(
10644                display,
10645                variant.as_str(),
10646                "UpgradeInstruction::{variant:?} Display must byte-equal \
10647                 as_str (kebab wire form, no `:` prefix)"
10648            );
10649            assert_ne!(
10650                display, lisp,
10651                "UpgradeInstruction::{variant:?} Display / as_str (wire \
10652                 kebab form) must stay structurally distinct from \
10653                 lisp_form (tatara-lisp author-surface with `:` prefix) — \
10654                 collapsing the two axes would break the tatara-lisp \
10655                 grep-and-fix workflow that keys off the `:` prefix"
10656            );
10657            assert!(
10658                lisp.starts_with(':'),
10659                "UpgradeInstruction::{variant:?}.lisp_form() must open \
10660                 with a `:` prefix (tatara-lisp author-surface form)"
10661            );
10662            assert!(
10663                !display.starts_with(':'),
10664                "UpgradeInstruction::{variant:?} Display must not open \
10665                 with a `:` prefix (wire form is un-prefixed kebab-case)"
10666            );
10667        }
10668    }
10669
10670    #[test]
10671    fn upgrade_instruction_as_ref_str_routes_through_as_str_accessor() {
10672        // Byte-parity pin on the standard-library `impl AsRef<str>`
10673        // route: every arm's `<UpgradeInstruction as
10674        // AsRef<str>>::as_ref(&v)` must byte-equal `v.as_str()`. Any
10675        // future silent detour that routes the impl through a
10676        // divergent projection (a per-arm inline `match self { … }`
10677        // re-inlining that opens a compile-time link to the un-lifted
10678        // arm-literal, a swap onto [`Self::lisp_form`] that would
10679        // collide the wire axis with the tatara-lisp author-surface
10680        // axis) trips here at caixa-core test time rather than at a
10681        // downstream `impl AsRef<str>`-bound consumer's silent split.
10682        // Peer of the sibling
10683        // [`crate::supervisor::tests::restart_strategy_as_ref_str_routes_through_as_str_accessor`]
10684        // /
10685        // [`crate::kind::tests::caixa_kind_as_ref_str_routes_through_as_str_accessor`]
10686        // pins.
10687        for (variant, _expected) in upgrade_instruction_arm_roster() {
10688            assert_eq!(
10689                <UpgradeInstruction as AsRef<str>>::as_ref(&variant),
10690                variant.as_str(),
10691                "UpgradeInstruction::{variant:?} AsRef<str> must route \
10692                 through UpgradeInstruction::as_str"
10693            );
10694        }
10695    }
10696
10697    #[test]
10698    fn upgrade_instruction_as_str_is_const_fn() {
10699        // Const-context pin: [`UpgradeInstruction::as_str`] must remain
10700        // `const fn`. Downstream consumers reaching for the accessor
10701        // from a `const` context (a module-scope `const _:() =
10702        // assert!(<variant>.as_str().len() > 0)` invariant pin, a
10703        // `const fn` per-instruction wire-shape audit table an M4
10704        // admission webhook materializes at build time) rely on the
10705        // const-ness. A future accidental downgrade to non-`const`
10706        // (an added runtime helper reachable only from a non-`const`
10707        // context, a manual hand-rolled `impl` that shadows this
10708        // method) trips at caixa-core build time rather than
10709        // surfacing as a downstream `const`-context regression far
10710        // from the accessor declaration. Peer of the sibling
10711        // [`crate::kind::tests::caixa_kind_wire_name_is_const_fn`] pin
10712        // on the top-level [`crate::CaixaKind`] axis.
10713        const RESTART_WIRE: &str = UpgradeInstruction::Restart.as_str();
10714        assert_eq!(RESTART_WIRE, "restart");
10715    }
10716
10717    #[test]
10718    fn upgrade_instruction_lisp_forms_covers_every_arm() {
10719        // Load-bearing pin on the substrate-canonical
10720        // [`UpgradeInstruction::LISP_FORMS`] exhaustive accept-set
10721        // roster: every arm of the shared
10722        // [`upgrade_instruction_arm_roster`] fixture must project
10723        // through [`UpgradeInstruction::lisp_form`] onto an entry the
10724        // [`UpgradeInstruction::LISP_FORMS`] roster carries, and the
10725        // roster's length must byte-equal the fixture's arm count so
10726        // a silent skew between the [`UpgradeInstruction::lisp_form`]
10727        // match's arm-set and the roster's arm-set trips here at
10728        // caixa-core test time rather than at a downstream M4
10729        // admission-webhook rejection body's accepted-set enumeration
10730        // miss / `feira lint --upgrade-from` per-instruction author-
10731        // audit unknown-tag-cascade miss / LSP hover completion
10732        // source's partial-position accepted-tag miss. A future arm
10733        // addition (a `Discard` peer the `code:delete/1` analog might
10734        // inspire, a `SoftPurge` split into `SoftPurgeCoop` /
10735        // `SoftPurgeForce` as the drain-cool-down policy grows a two-
10736        // arm shape) extends the shared
10737        // [`upgrade_instruction_arm_roster`] fixture as a single edit
10738        // and this pin sweeps the new arm by iteration; the paired
10739        // [`UpgradeInstruction::LISP_FORMS`] roster must grow in
10740        // lockstep or this assertion trips. Peer of the sibling
10741        // fieldless-enum roster round-trips
10742        // [`crate::supervisor::tests::restart_strategy_all_matches_from_wire_accept_set`]
10743        // /
10744        // [`crate::supervisor::tests::restart_policy_all_matches_from_wire_accept_set`]
10745        // /
10746        // [`crate::aplicacao::tests::placement_strategy_all_matches_from_wire_accept_set`]
10747        // /
10748        // [`crate::kind::tests::caixa_kind_all_matches_wire_name_emit_set`]
10749        // pins on the peer closed-set typed-enum exhaustive-iteration
10750        // surfaces, extended onto the discriminator axis of the
10751        // discriminated-union [`UpgradeInstruction`] enum where the
10752        // per-variant data payload rules out a `&'static [Self]`
10753        // roster.
10754        let fixture = upgrade_instruction_arm_roster();
10755        assert_eq!(
10756            UpgradeInstruction::LISP_FORMS.len(),
10757            fixture.len(),
10758            "UpgradeInstruction::LISP_FORMS.len() must byte-equal the \
10759             shared upgrade_instruction_arm_roster fixture's arm count \
10760             — a mismatch means the roster and the enum's arm-set \
10761             have drifted"
10762        );
10763        for (variant, _wire) in fixture {
10764            let lisp = variant.lisp_form();
10765            assert!(
10766                UpgradeInstruction::LISP_FORMS.contains(&lisp),
10767                "UpgradeInstruction::{variant:?}.lisp_form() = {lisp:?} \
10768                 must be a member of UpgradeInstruction::LISP_FORMS — \
10769                 the emitter and the roster have drifted out of lockstep"
10770            );
10771        }
10772        for tag in UpgradeInstruction::LISP_FORMS {
10773            assert!(
10774                tag.starts_with(':'),
10775                "UpgradeInstruction::LISP_FORMS entry {tag:?} must \
10776                 open with a `:` prefix (tatara-lisp author-surface \
10777                 form) — a bare kebab entry would collide the roster \
10778                 with the un-prefixed wire axis UpgradeInstruction::as_str \
10779                 emits"
10780            );
10781        }
10782    }
10783}