caixa_core/upgrade.rs
1//! Erlang/OTP-style appup — declarative upgrade instructions per
2//! prior caixa version. Composes with the `:behavior :on-state-change`
3//! callback to deliver state migration during hot upgrades.
4//!
5//! See `theory/INSPIRATIONS.md` §II.4 for the prior-art frame.
6//!
7//! ```lisp
8//! (defcaixa
9//! :nome "hello-rio"
10//! :versao "0.2.0"
11//! :upgrade-from
12//! ((:from "0.1.0"
13//! :instructions ((:load-module "hello-rio")
14//! (:state-change "lib/migrations/v01-to-v02.lisp")
15//! (:soft-purge "hello-rio-old")))
16//! (:from "0.1.5"
17//! :instructions ((:load-module "hello-rio")
18//! (:soft-purge "hello-rio-old")))))
19//! ```
20//!
21//! Each `(:from <prior>)` block declares the upgrade path *from* that
22//! version *to* the current `:versao`. wasm-operator picks the
23//! matching block at upgrade time, runs the instructions in order,
24//! and only swaps traffic to the new instance after all instructions
25//! succeed (transactional upgrade). On any failure, the current
26//! version stays load-bearing — a typed atomic upgrade.
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// One upgrade instruction. The set mirrors OTP's appup low-level
34/// instructions: enough to express every common upgrade pattern,
35/// few enough that the wasm-operator can implement each
36/// deterministically.
37#[derive(
38 Serialize,
39 Deserialize,
40 Debug,
41 Clone,
42 PartialEq,
43 Eq,
44 gen_platform::TypedDispatcher,
45 gen_platform::Discriminant,
46 gen_platform::IsVariant,
47)]
48#[serde(tag = "kind", rename_all = "kebab-case")]
49pub enum UpgradeInstruction {
50 /// Load a new wasm module alongside the current one — the analog
51 /// of OTP's `code:load_module/1`. Both versions remain in memory
52 /// after this instruction; in-flight requests stay on the old
53 /// version, new requests route to the new version.
54 LoadModule { module: String },
55
56 /// Run a state-migration tatara-lisp file. Receives the old state
57 /// + the prior version string; returns the new state. Analog of
58 /// `gen_server:code_change/3`.
59 StateChange { script: PathBuf },
60
61 /// Wait for in-flight requests on a named module to drain, then
62 /// GC it — the analog of `code:soft_purge/1`. Default cooldown is
63 /// 60s; longer-running requests block the upgrade.
64 SoftPurge { module: String },
65
66 /// Discard a named module immediately, without waiting for
67 /// drain — the analog of `code:purge/1`. Used when we don't
68 /// care about in-flight callers (cron, oneShot).
69 Purge { module: String },
70
71 /// Fall back to a full restart for this entry. Used when a typed
72 /// upgrade is impossible (e.g. wasm component world incompatible).
73 Restart,
74}
75
76// Fleet-wide dispatcher-catalog registration. UpgradeInstruction is
77// the OTP-style hot-upgrade primitive (load_module/code_change/
78// soft_purge/purge/restart) — the first NON-ADAPTER consumer of
79// gen-platform's typed-dispatcher catamorphism, satisfying the ★★
80// "two classes of consumer" promotion criterion from
81// theory/QUIRK-APPLIER.md §V.1.
82//
83// Operators query via:
84// gen dispatchers --from-catalog | jq '.[] | select(.label=="caixa.upgrade-instruction")'
85//
86// The substrate's lib/build/shared/fleet-catalog-coverage-test.nix
87// adds an assertion row for this label on the next snapshot refresh.
88gen_platform::register_dispatcher!("caixa.upgrade-instruction", UpgradeInstruction);
89
90/// One upgrade entry: the *prior* version we're upgrading from, plus
91/// the instruction sequence to execute.
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct UpgradeFromEntry {
95 /// Semver of the *prior* version. Authored as a literal string;
96 /// validated lazily by [`UpgradeFromEntry::validate`].
97 pub from: String,
98
99 /// Ordered list of instructions to execute. Empty list = "no-op
100 /// upgrade" (rare; usually means only documentation changed).
101 #[serde(default)]
102 pub instructions: Vec<UpgradeInstruction>,
103}
104
105impl UpgradeFromEntry {
106 /// Prior-versao semver-2 literal this entry declares an upgrade
107 /// path *from* — the string the OTP-shape `release_handler:install_release/1`
108 /// analog matches the running caixa's `:versao` against at hot-
109 /// upgrade dispatch time to pick this entry's `:instructions`
110 /// sequence. Returned byte-for-byte from the typed slot's own
111 /// `String` storage; no cloning, no re-parsing.
112 ///
113 /// The M2 companion of the closed M3 mesh-slot scalar-accessor
114 /// family — sibling in shape to [`crate::Membro::versao_requirement`]
115 /// (a40b0e3), [`crate::Membro::nome`] (4a32abf), and the
116 /// [`crate::WitContract::{source, destination, world_ref}`]
117 /// (7f0fd43 / 0804823) / [`crate::Entrada::{hostname, destination}`]
118 /// (11f3dfe / 6db982c) `&str` accessors already routing every
119 /// per-mesh-slot-atom scalar-value axis through one typed dispatch
120 /// on the substrate primitive — extended here onto the first per-
121 /// M2-slot scalar-value axis. Every downstream consumer of the
122 /// M2 `:upgrade-from :from` axis (the [`UpgradeFromEntry::validate`]
123 /// SemVer-2 parse gate, the [`validate_upgrade_from`] cross-entry
124 /// duplicate-detection re-parse assertion, the
125 /// [`validate_upgrade_from_against_versao`] precedence gate,
126 /// the [`validate_upgrade_from_against_behavior`] state-change-
127 /// callback coherence gate, every per-arm error variant carrying
128 /// the offending `:from` verbatim for `feira lint` rendering)
129 /// now reads through this one accessor rather than open-coding
130 /// `&self.from` / `&entry.from` / `self.from.clone()` /
131 /// `entry.from.clone()`.
132 ///
133 /// A future extension of the axis (an M4 typed `:from`-range slot
134 /// composing multiple prior versions into one entry, an operator-
135 /// side pre-parsed [`semver::Version`] cache the accessor could
136 /// materialize behind the same `&str` return contract, a per-
137 /// cluster `:placement`-scoped prior-versao overlay the
138 /// `caixa-operator` reconciles ahead of dispatch) migrates as a
139 /// single caixa-core edit rather than a coordinated rewrite of
140 /// the four validate-side call sites + every downstream error-
141 /// variant carrying `:from`.
142 #[must_use]
143 pub const fn prior_versao(&self) -> &str {
144 self.from.as_str()
145 }
146
147 /// Substrate-canonical per-`:upgrade-from :instructions`
148 /// OTP-appup migration-instruction-list slice-return accessor
149 /// every per-entry instructions-list reader keys off — returns
150 /// the author-declared `:instructions` list verbatim as a
151 /// `&[UpgradeInstruction]` slice-view over the same backing
152 /// buffer the raw `self.instructions.as_slice()` field access
153 /// borrows from. Non-optional: an empty slice is the load-bearing
154 /// "author declared `:instructions ()`" sentinel — the
155 /// `Vec<UpgradeInstruction>::default()`-produced empty tail the
156 /// [`UpgradeFromEntry::instructions`] field's own docstring already
157 /// names as the "no-op upgrade" shape (a metadata-only upgrade
158 /// entry — the operator's `:from`-match dispatch matches the entry
159 /// but runs no instructions, advancing straight to the "traffic
160 /// swap" step) and every peer within-entry cross-instruction gate
161 /// no-ops against without allocating a new `Vec` per gate.
162 ///
163 /// The `:upgrade-from :instructions` slot carries the per-`:from`
164 /// OTP-appup ordered instruction list the wasm-operator's hot-
165 /// upgrade dispatch materializes one per-instruction runtime
166 /// primitive from — the Erlang/OTP appup's per-`{from, to,
167 /// UpgradeInstructions, DowngradeInstructions}` entry's
168 /// `UpgradeInstructions` list (`code:load_module/1` /
169 /// `gen_server:code_change/3` / `code:soft_purge/1` /
170 /// `code:purge/1` / `restart_new_emulator` — see INSPIRATIONS
171 /// §II.4), projected through the tatara-lisp
172 /// `:upgrade-from ((:from … :instructions …))` author surface
173 /// onto a typed `Vec<UpgradeInstruction>` whose per-element
174 /// variant is [`UpgradeInstruction::LoadModule`] /
175 /// [`UpgradeInstruction::StateChange`] /
176 /// [`UpgradeInstruction::SoftPurge`] / [`UpgradeInstruction::Purge`]
177 /// / [`UpgradeInstruction::Restart`]. Every downstream consumer
178 /// that fans on the per-entry instruction list keys off this
179 /// slice (the [`UpgradeFromEntry::validate`] per-instruction
180 /// shape-check fan-out, the seven paired within-entry cross-
181 /// instruction gates [`Self::validate_restart_exclusive`] /
182 /// [`Self::validate_state_change_ordering`] /
183 /// [`Self::validate_purge_ordering`] /
184 /// [`Self::validate_state_change_before_cleanup`] /
185 /// [`Self::validate_load_singularity`] /
186 /// [`Self::validate_state_change_singularity`] /
187 /// [`Self::validate_cleanup_singularity`], the layout-side
188 /// [`crate::layout::StandardLayout`]'s per-`:state-change`
189 /// script-existence fan-out
190 /// ([`crate::layout::LayoutError::MissingEntry`]'s
191 /// `LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT` arm), the cross-slot
192 /// [`validate_upgrade_from_against_behavior`] gate's per-entry
193 /// `:state-change`-instruction detection loop, every future
194 /// wasm-operator (M2.5) per-`:from`-match hot-upgrade dispatch's
195 /// per-instruction runtime-primitive fan-out, every future M4
196 /// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-entry
197 /// upgrade-plan admission-webhook fan-out).
198 ///
199 /// Prior to this lift the `.instructions` `Vec<UpgradeInstruction>`
200 /// was accessed inline at nine production sites across
201 /// `caixa-core/src/upgrade.rs` and `caixa-core/src/layout.rs` —
202 /// the [`UpgradeFromEntry::validate`] per-instruction shape-check
203 /// fan-out (`for instr in &self.instructions`), the paired
204 /// [`Self::validate_restart_exclusive`] restart-count / other-kind
205 /// projections + `.len()` probe (three raw-access sites in one
206 /// gate), the [`Self::validate_state_change_ordering`] /
207 /// [`Self::validate_purge_ordering`] /
208 /// [`Self::validate_state_change_before_cleanup`] /
209 /// [`Self::validate_load_singularity`] /
210 /// [`Self::validate_state_change_singularity`] /
211 /// [`Self::validate_cleanup_singularity`] within-entry cross-
212 /// instruction gate traversal heads, the peer
213 /// [`validate_upgrade_from_against_behavior`] cross-slot
214 /// composition gate's `for instr in &entry.instructions`
215 /// per-entry `:state-change` detection loop, and the
216 /// [`crate::layout::StandardLayout`]-side
217 /// `for instr in &entry.instructions` per-`:state-change`
218 /// script-existence fan-out — nine open-coded field-accesses
219 /// that expressed no compile-time link back to the typed slot.
220 /// A future extension of the `:instructions` axis to a richer
221 /// author surface (a per-cluster overlay the operator pins
222 /// through a future `:upgrade-from :instructions-overrides` slot
223 /// so a canary cluster runs a `(:state-change …)` before the
224 /// production fleet does, a per-tenant instruction-list overlay
225 /// the M4 CR materializer resolves per-CR to inject cluster-
226 /// specific `(:soft-purge …)` cooldown adjustments, a promotion
227 /// of the plain `Vec<UpgradeInstruction>` to a richer
228 /// `{static, dynamic}` partition once virtual-actor-style
229 /// dynamic-instruction composition (an operator-derived
230 /// `(:load-module …)` sequence computed from the running
231 /// module set at upgrade time) comes into typed scope, a
232 /// per-instruction pre-condition scalar the future adaptive-
233 /// upgrade engine reads to bias per-instruction retry
234 /// strategy) would have had to be threaded through all nine
235 /// open-coded copies in lockstep or one consumer would silently
236 /// disagree with the peers on which instruction sequence a
237 /// given `:upgrade-from` entry resolves to — the per-
238 /// instruction shape-check reading the raw slot while the
239 /// paired within-entry ordering gates read an operator-resolved
240 /// slot would silently split the build-time per-entry gate
241 /// cohort from the layout-side script-existence gate + the
242 /// cross-slot behavior-composition gate + the runtime hot-
243 /// upgrade dispatch, a nine-consumer split across the seven
244 /// within-entry cross-instruction gates + the layout invariant +
245 /// the cross-slot composition gate far from the source
246 /// `caixa.lisp` with no field naming the instruction-sequence-
247 /// drift root cause. Lifting the resolution rule to a typed
248 /// method on the substrate primitive means every downstream
249 /// consumer of the per-entry OTP-appup instruction-list surface
250 /// reaches for exactly one typed dispatch — the resolver's
251 /// accept-set migrates as a unit on any future axis addition.
252 ///
253 /// Fifth slice-return (`&[T]`) accessor on any M2 or M3 typed
254 /// slot — sibling to the seed M2
255 /// [`crate::SupervisorSpec::children`] (bc92bce) `&[ChildSpec]`
256 /// accessor on the peer per-`:supervisor` static-child-list
257 /// `Vec`-carry axis, the M3 [`crate::Placement::clusters`]
258 /// (a6e18d7) `&[String]` accessor on the peer per-`:placement`
259 /// distribution-target-list `Vec`-carry axis, the M3
260 /// [`crate::AplicacaoSpec::membros`] (6c77e36) `&[Membro]`
261 /// accessor on the peer per-`:membros` node-list `Vec`-carry
262 /// axis, and the M3 [`crate::AplicacaoSpec::contratos`]
263 /// (0dcc926) `&[WitContract]` accessor on the peer per-
264 /// `:contratos` edge-list `Vec`-carry axis. This lift closes the
265 /// last unlifted `Vec`-carry axis on any M2 or M3 typed slot in
266 /// the substrate — the four peer axes named in the
267 /// [`crate::SupervisorSpec::children`] seed docstring
268 /// (`Placement::clusters`, `AplicacaoSpec::membros`,
269 /// `AplicacaoSpec::contratos`, `UpgradeFromEntry::instructions`)
270 /// are now all closed. The per-`UpgradeFromEntry` type carried
271 /// two axes: the scalar `Copy`-return
272 /// [`UpgradeFromEntry::prior_versao`] (75d27a8) on the
273 /// `:from` axis, and now the slice-return
274 /// [`UpgradeFromEntry::instructions`] on the peer
275 /// `:instructions` axis. Named `instructions()` to match the
276 /// storage field's name verbatim and the tatara-lisp
277 /// author-surface term (`:instructions`) the field's own
278 /// docstring already carries; the accessor's identity maps
279 /// onto the canonical OTP-appup vocabulary the
280 /// [`crate::upgrade`] module doc already reaches for ("runs
281 /// the instructions in order"). Returns `&[UpgradeInstruction]`
282 /// (not `&Vec<UpgradeInstruction>`) because every downstream
283 /// consumer of the instruction list treats it as a read-only
284 /// sequence — the slice-view is the narrowest borrow that
285 /// supports every present + roadmapped consumer (`.iter()`,
286 /// `.len()`, `.filter(...).count()`) without leaking the
287 /// backing `Vec`'s grow/push/reserve surface that no consumer
288 /// of the typed view reaches for (the storage-side `Vec`
289 /// remains reachable through the `pub instructions` field for
290 /// the mutation-carrying `Serialize`/`Deserialize` derive
291 /// round-trip and per-test fixture-mutation paths).
292 #[must_use]
293 pub const fn instructions(&self) -> &[UpgradeInstruction] {
294 self.instructions.as_slice()
295 }
296
297 /// Verify the `:from` field is a valid semver, every instruction's
298 /// typed shape, the within-entry `(:restart)`-exclusivity invariant
299 /// (an entry containing `(:restart)` must contain exactly one
300 /// `(:restart)` and nothing else — see
301 /// [`Self::validate_restart_exclusive`]), the within-entry
302 /// state-change-ordering invariant (every `(:state-change …)` must
303 /// be preceded by a `(:load-module …)` — see
304 /// [`Self::validate_state_change_ordering`]), the within-entry
305 /// purge-ordering invariant (every `(:soft-purge …)` / `(:purge …)`
306 /// must be preceded by a `(:load-module …)` — see
307 /// [`Self::validate_purge_ordering`]), the within-entry
308 /// state-change-before-cleanup ordering invariant (no
309 /// `(:state-change …)` may appear after any `(:soft-purge …)` /
310 /// `(:purge …)` — see
311 /// [`Self::validate_state_change_before_cleanup`]), the within-
312 /// entry load-singularity invariant (no module appears as the
313 /// target of `(:load-module …)` more than once — see
314 /// [`Self::validate_load_singularity`]), the within-entry
315 /// state-change-singularity invariant (no script appears as the
316 /// target of `(:state-change …)` more than once — see
317 /// [`Self::validate_state_change_singularity`]), and the within-
318 /// entry cleanup-singularity invariant (no module appears as the
319 /// target of `(:soft-purge …)` or `(:purge …)` more than once
320 /// total — see [`Self::validate_cleanup_singularity`]).
321 pub fn validate(&self) -> Result<(), UpgradeError> {
322 use semver::Version;
323 Version::parse(self.prior_versao())
324 .map_err(|e| UpgradeError::from_invalid(self.prior_versao(), &e.to_string()))?;
325 // Per-instruction typed shape: kind-tagged `:module` /
326 // `:script` value-shape gates fire here, *before* the
327 // within-entry restart-exclusivity gate below — so a
328 // malformed-shape diagnostic on a Module/Script-bearing
329 // instruction surfaces with its narrower self-locating
330 // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
331 // `AbsoluteScript`, `ParentEscapeScript`) rather than
332 // collapsing two unrelated authoring errors into a single
333 // exclusivity diagnostic. Same empty-first cascade discipline
334 // every peer DNS-1123 / path-shape gate inside this module
335 // uses (`validate_module`'s ModuleEmpty arm precedes the
336 // DNS-1123 predicate; `validate` on `StateChange` consults
337 // the lifted `is_sandboxed_relative_path` shape gate first).
338 // Route the per-instruction shape-check fan-out through the
339 // lifted [`Self::instructions`] slice-return accessor rather
340 // than the raw `self.instructions` field access — first of
341 // nine paired production consumers of the per-`:upgrade-from
342 // :instructions` OTP-appup migration-instruction-list surface
343 // that now key off exactly one typed dispatch on the substrate
344 // primitive.
345 for instr in self.instructions() {
346 instr.validate()?;
347 }
348 self.validate_restart_exclusive()?;
349 self.validate_state_change_ordering()?;
350 self.validate_purge_ordering()?;
351 self.validate_state_change_before_cleanup()?;
352 self.validate_load_singularity()?;
353 self.validate_state_change_singularity()?;
354 self.validate_cleanup_singularity()?;
355 Ok(())
356 }
357
358 /// Reject `:upgrade-from :instructions` lists that carry
359 /// `(:restart)` alongside any other instruction, or that carry
360 /// more than one `(:restart)`. The valid Restart-bearing shape is
361 /// exactly `((:restart))` — a single `Restart` as the entry's
362 /// whole instructions list.
363 ///
364 /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
365 /// is the *fallback* for an entry whose typed upgrade is
366 /// impossible (wasm component-model world incompatibility,
367 /// irreversible state shape change). The fallback is terminal by
368 /// construction: the operator restarts the pod and the new version
369 /// comes up fresh, so any other instructions in the same entry
370 /// are dead code in both directions — either the typed sequence
371 /// would have succeeded and `(:restart)` is unreached, or it
372 /// wouldn't and the typed instructions are dead because the
373 /// operator restarts anyway. Two canonical authoring footguns
374 /// close here:
375 ///
376 /// - `((:load-module …) (:state-change …) (:restart))` — the
377 /// "I'll try the typed path *then* restart anyway" footgun.
378 /// There is no coherent OTP-shaped semantic for this: if the
379 /// typed sequence succeeds, the trailing restart discards the
380 /// work that just succeeded (defeating the whole point of
381 /// declaring it); if it fails, the restart is never reached
382 /// because the entry already failed.
383 /// - `((:restart) (:restart))` — multiple `Restart` variants in
384 /// one entry. The fallback is a single semantic; repeating it
385 /// is at best redundant, at worst suggests the author thought
386 /// the second one would re-trigger after the first.
387 ///
388 /// Same within-entry exclusivity discipline OTP's `relup` enforces
389 /// at the `restart_new_emulator | restart_emulator` instruction
390 /// boundary — those instructions are terminal in the upgrade
391 /// script (`systools(3)` rejects sequences that continue past
392 /// them); pleme-io lifts the same shape to a build-time gate,
393 /// matching the CAIXA-SDLC §III "build errors, not runtime
394 /// surprises" frame.
395 ///
396 /// Same within-entry cross-instruction discipline the
397 /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
398 /// shard-key partition (934bc58) and
399 /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
400 /// precedence partition (de7ab1a) apply on cross-slot axes — now
401 /// extended onto the first within-list cross-instruction axis on
402 /// the `:upgrade-from` typed slot.
403 fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
404 // Route the paired restart-count / instructions-len / other-
405 // kind projections through the lifted [`Self::instructions`]
406 // slice-return accessor rather than the raw `self.instructions`
407 // field access — three raw-access sites in one gate collapse
408 // onto exactly one typed dispatch on the substrate primitive.
409 //
410 // The paired positive / negated `Self::Restart` arm-discriminator
411 // predicates route through the `gen_platform::IsVariant`
412 // derive-generated [`UpgradeInstruction::is_restart`] rather than
413 // the raw `matches!(i, UpgradeInstruction::Restart)` /
414 // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
415 // matches — same closed-set-typed-enum arm-discriminator dispatch
416 // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
417 // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
418 // / `!= CaixaKind::X` production sites in the substrate's own
419 // layout invariant verifier + typed-view projection gates,
420 // extended here onto the last unlifted `matches!`-based
421 // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
422 // typed enum. A future sixth `UpgradeInstruction` arm (an
423 // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
424 // wasm-operator's hot-upgrade runtime could adopt to bracket the
425 // typed instruction sequence against a per-cluster readiness
426 // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
427 // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
428 // materializer could resolve per-CR) migrates as a single
429 // enum-declaration edit — the derive auto-generates the paired
430 // `.is_<new_arm>()` predicate; every consumer inherits the new
431 // arm on the next re-derive, rather than the two `matches!` sites
432 // here having to be threaded through in lockstep.
433 let instructions = self.instructions();
434 let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
435 if restart_count == 0 {
436 return Ok(());
437 }
438 if restart_count == 1 && instructions.len() == 1 {
439 return Ok(());
440 }
441 let other_kinds: Vec<&'static str> = instructions
442 .iter()
443 .filter(|i| !i.is_restart())
444 .map(UpgradeInstruction::lisp_form)
445 .collect();
446 Err(UpgradeError::restart_not_exclusive(
447 self.prior_versao(),
448 restart_count,
449 other_kinds,
450 ))
451 }
452
453 /// Reject an entry whose `(:state-change …)` is not preceded by a
454 /// `(:load-module …)` in the same `:instructions` list.
455 ///
456 /// `StateChange` is the `gen_server:code_change/3` analog
457 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
458 /// it runs the migration script that folds the *old* state into the
459 /// shape the *new* code expects. In OTP, `code_change/3` is invoked
460 /// in the context of the newly-loaded code — `release_handler`
461 /// always loads the new module before running the advanced update
462 /// that triggers the callback. caixa decomposes that into two
463 /// explicit instructions (`LoadModule` brings the new version up
464 /// "alongside the current one"; `StateChange` migrates the state),
465 /// and the module doc pins that the operator "runs the instructions
466 /// in order" and only swaps traffic after all succeed. So a
467 /// `:state-change` with no preceding `:load-module` migrates state
468 /// into code that was never loaded — the migration script runs while
469 /// the only resident version is still the *old* one, which expects
470 /// the *old* state. Two authoring footguns close here:
471 ///
472 /// - `((:state-change "…"))` — the "I wrote the migration but
473 /// forgot to load the new module" footgun. The new code that
474 /// defines the new state representation (and that the migration
475 /// output is destined for) never comes up; the operator runs
476 /// the script against the old code and either no-ops or corrupts
477 /// live state.
478 /// - `((:state-change "…") (:load-module "…"))` — the
479 /// right-instructions-wrong-order footgun. Because the operator
480 /// executes in declared order, the migration runs *before* the
481 /// new code is resident, then the load brings up code expecting
482 /// already-migrated state that the just-run script produced
483 /// against the old version's shape. The canonical order is
484 /// `(:load-module …) (:state-change …) (:soft-purge …)`
485 /// (module doc example).
486 ///
487 /// Same within-entry cross-instruction discipline as
488 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
489 /// exclusivity gate it runs beside): both reject an
490 /// `:instructions` list whose instructions are individually
491 /// well-shaped but jointly incoherent, at the typed build surface
492 /// rather than as a runtime surprise. Runs *after*
493 /// `validate_restart_exclusive` so a `((:state-change …)
494 /// (:restart))` shape still surfaces the more-fundamental
495 /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
496 /// alone, so no Restart-bearing entry reaches this gate carrying a
497 /// `StateChange`).
498 fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
499 // Route the per-instruction load-family arm-discriminator through
500 // the `gen_platform::IsVariant`-derive-generated
501 // [`UpgradeInstruction::is_load_module`] predicate and the
502 // per-instruction migration-family `:script` scalar projection
503 // through the sibling lifted [`UpgradeInstruction::declared_path`]
504 // `Option<&PathBuf>` accessor rather than the raw two-arm
505 // `match instr { UpgradeInstruction::LoadModule { .. } =>
506 // loaded = true, UpgradeInstruction::StateChange { script } if
507 // !loaded => …, _ => {} }` open-coded pattern-match — closes the
508 // last unlifted `match`-shaped per-arm-hand-rolled load-family
509 // arm-discriminator + migration-family script-projection pair
510 // inside `impl UpgradeFromEntry`. Sibling of the peer
511 // [`Self::validate_purge_ordering`] (580d0f1) routing already
512 // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
513 // load → cleanup ordering axis, the peer
514 // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
515 // onto the [`UpgradeInstruction::is_load_module`] +
516 // [`UpgradeInstruction::declared_module`] pair on the singularity
517 // axis, and the peer [`Self::validate_state_change_singularity`]
518 // routing already lifted onto the sibling
519 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
520 // accessor on the migration-family script-projection axis — both
521 // ordering-gate load-family sticky-latch dispatches now key off
522 // exactly one typed dispatch on the substrate primitive for
523 // their load-family arm-discriminator, and both migration-family
524 // projection sites (this ordering gate + the peer singularity
525 // gate) now key off exactly one typed dispatch on the substrate
526 // primitive for the `:script`-carrying axis. A future sixth arm
527 // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
528 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
529 // `CanaryTraffic` split-traffic variant the M4 CR materializer
530 // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
531 // enum-declaration edit through the derive rather than a
532 // coordinated rewrite of every ordering / singularity gate's
533 // per-arm hand-rolled pattern-match. Byte-identity of this
534 // dispatch against the pre-lift `match` shape is pinned by
535 // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
536 let mut loaded = false;
537 for instr in self.instructions() {
538 if instr.is_load_module() {
539 loaded = true;
540 } else if !loaded && let Some(script) = instr.declared_path() {
541 return Err(UpgradeError::state_change_without_prior_load(
542 self.prior_versao(),
543 script,
544 ));
545 }
546 }
547 Ok(())
548 }
549
550 /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
551 /// preceded by a `(:load-module …)` in the same `:instructions` list.
552 ///
553 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
554 /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
555 /// *old* module from memory after the new one is resident. OTP's
556 /// two-phase code load is `code:load_module/1` *then*
557 /// `code:soft_purge/1` — load the new version alongside the old
558 /// (both in memory, new requests route to new), then purge the old
559 /// after in-flight callers drain. caixa decomposes that into two
560 /// explicit instructions (`LoadModule` brings the new version up
561 /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
562 /// doc; `SoftPurge` "waits for in-flight requests on a named module
563 /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
564 /// and the module doc pins that the operator "runs the instructions
565 /// in order". So a `:soft-purge` / `:purge` with no preceding
566 /// `:load-module` purges old code while the only resident version is
567 /// still the *same* old code, leaving the upgrade entry asking the
568 /// operator to drain or discard the live module with no replacement
569 /// resident. Two authoring footguns close here:
570 ///
571 /// - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
572 /// cleanup but forgot to load the new module" footgun. The new
573 /// code never comes up alongside; the operator either drains the
574 /// old version to nothing (`SoftPurge`) or discards it outright
575 /// mid-request (`Purge`), with no replacement to route in-flight
576 /// or future requests to.
577 /// - `((:soft-purge "…") (:load-module "…"))` /
578 /// `((:purge "…") (:load-module "…"))` — the right-instructions-
579 /// wrong-order footgun. Because the operator executes in declared
580 /// order, the cleanup runs *before* the new code is resident,
581 /// leaving a window during which neither version is available;
582 /// the canonical order is `(:load-module …) (:state-change …)
583 /// (:soft-purge …)` (module doc example).
584 ///
585 /// Same within-entry cross-instruction discipline as
586 /// [`Self::validate_state_change_ordering`] (the `:state-change`-
587 /// ordering gate it runs beside): both close the same load-before-X
588 /// post-condition on the OTP appup ordering contract, now extending
589 /// the typed coverage from "new code resident before its state
590 /// migration runs" to "new code resident before the old code is
591 /// drained or discarded" — the second half of OTP's two-phase code
592 /// load. Runs *after* `validate_state_change_ordering` so an entry
593 /// like `((:state-change …) (:soft-purge …))` surfaces the more-
594 /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
595 /// are load-less, but state-change is the load-bearing semantic — the
596 /// purge is meaningless either way without a preceding load, so the
597 /// author should see the migration-side diagnostic first).
598 fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
599 let mut loaded = false;
600 for instr in self.instructions() {
601 // Route the per-instruction cleanup-family arm-discriminator
602 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
603 // predicate rather than the raw
604 // `UpgradeInstruction::SoftPurge { module } |
605 // UpgradeInstruction::Purge { module }` open-coded per-arm
606 // union pattern-match — the first of three within-entry cross-
607 // instruction cleanup-facing gates now keys off exactly one
608 // typed dispatch on the substrate primitive, so any future
609 // fifth cleanup-shaped variant (a `Discard` variant the
610 // `code:delete/1` peer inspires) added to
611 // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
612 // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
613 // through the accessor's one body. The paired cleanup-arm
614 // `:module` scalar is routed through the sibling
615 // [`UpgradeInstruction::declared_module`] accessor rather than
616 // the raw pattern-bound `module` binding — same substrate-
617 // primitive-owns-the-scalar discipline every peer
618 // per-`UpgradeInstruction` scalar-value axis already routes
619 // through, with the `is_cleanup`-implies-`declared_module`-is-
620 // `Some` composition pin at
621 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
622 // making the `.expect(…)` structurally infallible at build
623 // time. Peer of the sibling
624 // [`UpgradeFromEntry::validate_restart_exclusive`]
625 // paired positive / negated
626 // [`UpgradeInstruction::is_restart`] routing (915a934) on the
627 // per-arm terminal-fallback partition — same closed-set-typed-
628 // enum arm-discriminator dispatch discipline extended from
629 // the single-arm terminal-fallback family onto the two-arm
630 // cleanup family here.
631 //
632 // Route the paired load-family arm-discriminator through the
633 // `gen_platform::IsVariant`-derive-generated
634 // [`UpgradeInstruction::is_load_module`] predicate rather than
635 // the raw `matches!(instr, UpgradeInstruction::LoadModule
636 // { .. })` open-coded pattern-match — closes the last
637 // unlifted `matches!`-based per-variant arm-discriminator
638 // axis on the [`UpgradeInstruction`] closed-set typed enum,
639 // sibling of the [`UpgradeInstruction::is_restart`] terminal-
640 // fallback routing (915a934) and the
641 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
642 // routing (0bc469f) that already lifted the paired
643 // arm-discriminator sites in this method. Every arm-family
644 // partition the gate keys off — load-family (`LoadModule`),
645 // cleanup-family (`SoftPurge | Purge`), terminal-fallback
646 // (`Restart`) — now consults exactly one typed dispatch on
647 // the substrate primitive, so a future sixth arm added to
648 // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
649 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
650 // a `CanaryTraffic` split-traffic variant the M4 CR
651 // materializer could resolve per-CR — INSPIRATIONS §II.4)
652 // migrates as a single enum-declaration edit through the
653 // derive rather than a scattered per-consumer rewrite. The
654 // partition invariant is pinned by
655 // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
656 // and the byte-identity of this dispatch against the pre-lift
657 // `matches!` pattern by
658 // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
659 if instr.is_load_module() {
660 loaded = true;
661 } else if instr.is_cleanup() && !loaded {
662 return Err(UpgradeError::purge_without_prior_load(
663 self.prior_versao(),
664 instr.lisp_form(),
665 instr
666 .declared_module()
667 .expect("is_cleanup() implies declared_module() is Some"),
668 ));
669 }
670 }
671 Ok(())
672 }
673
674 /// Reject an entry whose `(:state-change …)` appears after any
675 /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
676 /// list — completing the canonical OTP appup `code:load_module/1`
677 /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
678 /// chain on the typed `:upgrade-from` slot.
679 ///
680 /// `StateChange` is the `gen_server:code_change/3` analog
681 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
682 /// verbatim: "State migration uses `gen_server:code_change/3` …
683 /// migrate state from v0.1.0 shape to current shape"). The
684 /// callback's input is the *prior* version's state shape, which
685 /// only exists while the prior code is still resident — the running
686 /// `gen_server` processes hold the v0.1.0 state, and the operator's
687 /// dispatch invokes `code_change/3` to fold that state into the
688 /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
689 /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
690 /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
691 /// *old* module after the new one is resident. The operator runs
692 /// instructions in declared order (module doc), so a cleanup ahead
693 /// of a state-change discards the prior code before the migration
694 /// fold runs against the state it held — the canonical OTP error
695 /// mode "`code_change/3` invoked on a purged module" the
696 /// `release_handler` enforces by always emitting the migration
697 /// callback before the soft-purge step.
698 ///
699 /// `systools`-generated `.relup` files always emit `code_change`
700 /// before `soft_purge` for this reason; the appup cookbook's
701 /// canonical pattern (`[{load_module, m}, {update, m, soft},
702 /// {soft_purge, m}]`) places the migration-triggering `update`
703 /// strictly between the load and the cleanup. The caixa module
704 /// doc pins the same canonical order verbatim — `(:load-module
705 /// …) (:state-change …) (:soft-purge …)` — and this gate makes
706 /// that ordering a structural property at build time. Three
707 /// authoring footguns close here:
708 ///
709 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
710 /// "lib/m.lisp"))` — the right-instructions-wrong-order
711 /// footgun on the migrate ↔ cleanup axis. Because the operator
712 /// executes in declared order, the cleanup drains the v0.1.0
713 /// module to nothing before the migration callback runs, and
714 /// the script either no-ops (no v0.1.0 state left to fold) or
715 /// crashes (`code_change/3` invoked on an unloaded version).
716 /// The canonical order is `(:load-module …) (:state-change
717 /// …) (:soft-purge …)` (module doc example).
718 /// - `((:load-module "x") (:purge "x-old") (:state-change
719 /// "lib/m.lisp"))` — same shape on the more catastrophic
720 /// `:purge` variant. The immediate-discard semantic destroys
721 /// v0.1.0 state mid-request; the trailing migration script
722 /// has nothing to fold from and the `gen_server` processes that
723 /// held v0.1.0 state were killed by the `:purge`.
724 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
725 /// "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
726 /// sandwiched between two cleanups" footgun. The first
727 /// cleanup discards v0.1.0; the migration runs against
728 /// drained state; the second cleanup is irrelevant. The first
729 /// cleanup → state-change boundary is the load-bearing defect
730 /// surfaced.
731 ///
732 /// Same within-entry cross-instruction discipline as
733 /// [`Self::validate_state_change_ordering`] (the load → state-
734 /// change ordering gate it runs after) and
735 /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
736 /// gate it runs after): all three close one boundary of the OTP
737 /// canonical sequence `code:load_module/1` →
738 /// `gen_server:code_change/3` → `code:soft_purge/1`. The
739 /// state-change-ordering gate closes the load → migrate boundary;
740 /// the purge-ordering gate closes the load → cleanup boundary;
741 /// this gate closes the migrate → cleanup boundary, completing
742 /// the typed coverage of the canonical sequence. Runs *after*
743 /// [`Self::validate_purge_ordering`] (and therefore after
744 /// [`Self::validate_state_change_ordering`]) so an entry like
745 /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
746 /// which violates *both* the purge-without-load gate and this
747 /// state-change-after-cleanup gate — surfaces the more-
748 /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
749 /// defect is load-bearing; once a coherent `(:load-module …)`
750 /// precedes both, the migrate ↔ cleanup ordering becomes the
751 /// next live defect). Runs *before* the per-instruction-class
752 /// singularity gates ([`Self::validate_load_singularity`],
753 /// [`Self::validate_state_change_singularity`],
754 /// [`Self::validate_cleanup_singularity`]) so an entry like
755 /// `((:load-module "x") (:soft-purge "x-old") (:state-change
756 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
757 /// *both* this ordering gate and the state-change-singularity
758 /// gate — surfaces the ordering defect first; the canonical
759 /// "ordering before singularity" precedence the peer
760 /// `validate_state_change_ordering` / `validate_purge_ordering`
761 /// gates already establish.
762 ///
763 /// Detection: linear scan of the instructions list with a
764 /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
765 /// recording the first cleanup encountered; on any subsequent
766 /// `StateChange` the gate fires with the script + the prior
767 /// cleanup's kind/module. Diagnostic-order pin: the first
768 /// colliding state-change-after-cleanup pair surfaces, not the
769 /// last — mirrors every peer ordering gate's first-collision
770 /// posture ([`Self::validate_state_change_ordering`] returns on
771 /// the first `StateChange` without prior load,
772 /// [`Self::validate_purge_ordering`] on the first cleanup
773 /// without prior load).
774 fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
775 let mut prior_cleanup: Option<(&str, &'static str)> = None;
776 for instr in self.instructions() {
777 // Route the per-instruction cleanup-family arm-discriminator
778 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
779 // predicate rather than the raw
780 // `UpgradeInstruction::SoftPurge { module } |
781 // UpgradeInstruction::Purge { module }` open-coded per-arm
782 // union pattern-match — the second of three within-entry
783 // cross-instruction cleanup-facing gates the peer
784 // [`Self::validate_purge_ordering`] routing already lifted;
785 // both now key off exactly one typed dispatch on the substrate
786 // primitive so the "which arms belong to the cleanup family"
787 // question resolves at exactly one caixa-core edit. The
788 // sticky-once latch's `:module` scalar is routed through the
789 // sibling [`UpgradeInstruction::declared_module`] accessor
790 // rather than the raw pattern-bound `module.as_str()`
791 // projection, with the `is_cleanup`-implies-`declared_module`-
792 // is-`Some` composition pin at
793 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
794 // making the `.expect(…)` structurally infallible at build
795 // time.
796 if instr.is_cleanup() && prior_cleanup.is_none() {
797 prior_cleanup = Some((
798 instr
799 .declared_module()
800 .expect("is_cleanup() implies declared_module() is Some"),
801 instr.lisp_form(),
802 ));
803 } else if let Some(script) = instr.declared_path()
804 && let Some((prior_module, prior_kind)) = prior_cleanup
805 {
806 // Route the per-instruction `StateChange`-arm script-path
807 // projection through the sibling lifted
808 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
809 // accessor rather than the raw
810 // `if let UpgradeInstruction::StateChange { script } = instr`
811 // open-coded pattern-match — the last unlifted per-
812 // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
813 // inside `impl UpgradeFromEntry`, sibling to the four peer
814 // per-`UpgradeInstruction` consumers already routed through
815 // the accessor: [`UpgradeInstruction::validate`]'s per-
816 // `StateChange` sandbox-path fan-out, the layout-side per-
817 // `StateChange` script-existence fan-out at
818 // [`crate::layout::StandardLayout::verify`]
819 // (caixa-core/src/layout.rs:1058), the within-entry
820 // [`UpgradeFromEntry::validate_state_change_singularity`]
821 // per-`StateChange` script-projection fan-out, and the
822 // cross-slot
823 // [`validate_upgrade_from_against_behavior`]
824 // per-`StateChange` detection loop. Byte-equal today
825 // (`declared_path` returns `Some(script)` iff the
826 // instruction is [`UpgradeInstruction::StateChange`], per
827 // the sibling `declared_path_only_for_state_change` pin),
828 // so a state-change-after-cleanup surfaces
829 // `StateChangeAfterCleanup` byte-identical to the pattern-
830 // match shape. Any future accessor extension that promotes
831 // an additional variant onto the `PathBuf`-carrying axis
832 // reaches this gate through one caixa-core edit rather
833 // than a coordinated rewrite of five call sites — the
834 // migrate→cleanup ordering discipline extends to the
835 // promoted variant by construction. Same "one typed
836 // dispatch on the substrate primitive, thin projections at
837 // each consumer" trajectory the sibling
838 // [`UpgradeInstruction::declared_module`] `String`-axis
839 // per-variant unifier already established.
840 return Err(UpgradeError::state_change_after_cleanup(
841 self.prior_versao(),
842 script,
843 prior_kind,
844 prior_module,
845 ));
846 }
847 }
848 Ok(())
849 }
850
851 /// Reject an entry whose `:instructions` list names the same module
852 /// as the target of more than one cleanup instruction (`:soft-purge`
853 /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
854 /// module) axis, narrowed to the cleanup class.
855 ///
856 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
857 /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
858 /// `code:load_module/1` — load v2 alongside v1 … 2.
859 /// `code:soft_purge/1` — wait until no process is running v1, then
860 /// discard. (`code:purge/1` kills v1 immediately if you don't
861 /// care.)"). The author picks *one* cleanup semantic per old
862 /// module — `:soft-purge` (preferred: waits for in-flight callers
863 /// to drain) or `:purge` (when the drain isn't possible) — and the
864 /// operator runs that one in declared order alongside any other
865 /// distinct-module cleanups. systools-generated `.relup` files
866 /// always emit at most one purge per module for this reason; any
867 /// retry / fallback decision is the operator's job on
868 /// instruction failure, not authored into the entry. Three
869 /// authoring footguns close here:
870 ///
871 /// - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
872 /// — the "I copy-pasted the cleanup line twice" footgun. The
873 /// second `:soft-purge` is a no-op (the module is already gone
874 /// after the first drain-and-discard) or undefined depending
875 /// on the operator's handling of a non-resident-module purge
876 /// request; either way the second instruction carries no
877 /// observable semantic, far from the source caixa.lisp.
878 /// - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
879 /// — the "soft-then-hard fallback" footgun. The author wrote
880 /// "drain, and if drain didn't clean it up, force-discard",
881 /// but the operator runs instructions unconditionally in
882 /// declared order — the `:purge` fires whether the
883 /// `:soft-purge` already discarded the module or not, so the
884 /// fallback semantic the author imagined is missing; the
885 /// pair is incoherent (drain *and* force-discard semantics
886 /// on one module is two contradictory dispositions). The
887 /// operator's failure-handling surface is its own
888 /// responsibility: if `:soft-purge` doesn't drain within its
889 /// cooldown the operator escalates, not the author's entry.
890 /// - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
891 /// — same shape on the reversed ordering. The `:purge`
892 /// discards immediately; the trailing `:soft-purge` has no
893 /// module to drain.
894 ///
895 /// Same within-entry exclusivity discipline as
896 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
897 /// exclusivity gate it joins on the per-module cleanup axis): both
898 /// reject an `:instructions` list whose instructions are
899 /// individually well-shaped but jointly incoherent on a chosen
900 /// semantic axis (restart-fallback for the whole entry there;
901 /// cleanup-semantic for one module here), at the typed build
902 /// surface rather than as a runtime surprise. Runs *after*
903 /// [`Self::validate_purge_ordering`] (the load-before-cleanup
904 /// ordering gate) so an entry like `((:soft-purge "x-old")
905 /// (:soft-purge "x-old"))` surfaces the more-fundamental
906 /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
907 /// the missing-load defect is the load-bearing one — the duplicate
908 /// is meaningless either way without the preceding load).
909 ///
910 /// Same set-not-multiset discipline applied to every peer
911 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
912 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
913 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
914 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
915 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
916 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
917 /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
918 /// Each closes the same authoring footgun: a Vec authoring surface
919 /// that silently accepts duplicate entries and renders the "second
920 /// wins" (or "operator processes both, second is a no-op or
921 /// errors") shape downstream, far from the source caixa.lisp.
922 /// This gate extends the discipline onto the within-entry
923 /// instruction-target axis — duplicate cleanup targets *within*
924 /// one `:upgrade-from` entry — the peer of the cross-entry
925 /// duplicate-`:from` axis at one level of nesting deeper.
926 ///
927 /// Detection: linear scan of the instructions list collecting
928 /// the (module, kind) pair from every `SoftPurge` / `Purge`
929 /// encountered; on the second occurrence of any module the gate
930 /// fires with the prior kind and the colliding kind in declaration
931 /// order. Diagnostic-order pin: the first colliding pair surfaces,
932 /// not the last — mirrors
933 /// [`validate_upgrade_from`]'s
934 /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
935 /// posture (the first detected collision wins) and every peer
936 /// duplicate gate's first-collision discipline.
937 fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
938 let mut seen: Vec<(&str, &'static str)> = Vec::new();
939 for instr in self.instructions() {
940 // Route the per-instruction cleanup-family arm-discriminator
941 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
942 // predicate rather than the raw two-arm
943 // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
944 // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
945 // `UpgradeInstruction::Purge { module } => (module.as_str(),
946 // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
947 // per-arm dispatch — the third of three within-entry cross-
948 // instruction cleanup-facing gates the peer
949 // [`Self::validate_purge_ordering`] +
950 // [`Self::validate_state_change_before_cleanup`] routing
951 // already lifted; all three now key off exactly one typed
952 // dispatch on the substrate primitive, structurally. The
953 // cleanup-target `(module, kind)` pair is projected through
954 // the peer [`UpgradeInstruction::declared_module`] /
955 // [`UpgradeInstruction::lisp_form`] accessors rather than
956 // the per-arm-hand-rolled scalar-value + kind-const pair,
957 // with the `is_cleanup`-implies-`declared_module`-is-`Some`
958 // composition pin at
959 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
960 // making the `.expect(…)` structurally infallible at build
961 // time. Any future fifth cleanup-shaped variant added under
962 // the `is_cleanup` predicate + registered through the peer
963 // `lisp_form` per-arm kebab-case-const dispatch reaches this
964 // dedup gate through the accessor's one body rather than a
965 // fourth per-arm-hand-rolled scalar/kind projection here.
966 if !instr.is_cleanup() {
967 continue;
968 }
969 let module = instr
970 .declared_module()
971 .expect("is_cleanup() implies declared_module() is Some");
972 let kind = instr.lisp_form();
973 if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
974 let prior_kind = seen[prior_idx].1;
975 return Err(UpgradeError::duplicate_cleanup(
976 self.prior_versao(),
977 module,
978 vec![prior_kind, kind],
979 ));
980 }
981 seen.push((module, kind));
982 }
983 Ok(())
984 }
985
986 /// Reject an entry whose `:instructions` list names the same module
987 /// as the target of more than one `(:load-module …)` instruction —
988 /// set-not-multiset on the `LoadModule` axis.
989 ///
990 /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
991 /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
992 /// new code is 'current', old code is 'old'."). The instruction
993 /// brings the new wasm component up resident alongside the old
994 /// one so the operator can route new traffic to the new code
995 /// while in-flight callers drain on the old — and the operator's
996 /// dispatch table reads the module *name* (a caixa name) to bind
997 /// the component, so two `(:load-module "x")` instructions in one
998 /// entry ask the operator to re-bind the same component twice.
999 /// `systools`-generated `.relup` files emit at most one
1000 /// `load_module` per module per upgrade step for this reason; the
1001 /// second load has no observable semantic relative to the first
1002 /// (the component is already resident). Three authoring footguns
1003 /// close here:
1004 ///
1005 /// - `((:load-module "x") (:load-module "x"))` — the "I
1006 /// copy-pasted the load line twice" footgun. The second
1007 /// `:load-module` re-reads the same module name and re-binds
1008 /// the same wasm component — a no-op in both directions
1009 /// (no new code becomes resident; no old code is purged) —
1010 /// and any cleanup / migration the author intended for a
1011 /// *distinct* module is silently absent from the entry.
1012 /// - `((:load-module "x") (:load-module "x") (:state-change …))`
1013 /// — the "I meant to load two distinct modules" typo. The
1014 /// author intended `((:load-module "x") (:load-module "y"))`
1015 /// but renamed both to "x" (or copied the first line and
1016 /// forgot to change the module). The migration runs against
1017 /// code that's resident only on one module name, and the
1018 /// second module the author imagined was being loaded never
1019 /// comes up at all — far from the source caixa.lisp.
1020 /// - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1021 /// — same shape with a trailing cleanup. The duplicate load
1022 /// is dead code; the cleanup still fires correctly, masking
1023 /// the load-side duplication as a silently-passing entry.
1024 ///
1025 /// Same within-entry exclusivity discipline as
1026 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1027 /// singularity gate this runs beside) on the sibling
1028 /// `LoadModule` axis: both reject an `:instructions` list whose
1029 /// instructions are individually well-shaped but jointly
1030 /// incoherent on a per-module-per-class basis (load-once for the
1031 /// load axis here; cleanup-once for the cleanup axis there), at
1032 /// the typed build surface rather than as a runtime surprise.
1033 /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1034 /// before-cleanup ordering gate) so an entry like
1035 /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1036 /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1037 /// first (the missing-load defect is load-bearing — the migration
1038 /// runs against unloaded code; the duplicate is meaningless either
1039 /// way without the preceding load). Runs *before*
1040 /// [`Self::validate_cleanup_singularity`] so an entry like
1041 /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1042 /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1043 /// the load axis precedes the cleanup axis in the canonical OTP
1044 /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1045 /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1046 /// before `SoftPurge`/`Purge`), so the load-side singularity is
1047 /// the load-bearing diagnostic when both fire.
1048 ///
1049 /// Same set-not-multiset discipline applied to every peer
1050 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1051 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1052 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1053 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1054 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1055 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1056 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1057 /// the per-module cleanup-target axis (9cedd8b —
1058 /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1059 /// discipline onto the within-entry `LoadModule` instruction-target
1060 /// axis — the third within-entry per-module singularity completing
1061 /// the load+cleanup pair across the OTP two-phase code-load
1062 /// contract.
1063 ///
1064 /// Detection: linear scan of the instructions list collecting the
1065 /// module name from every `LoadModule` encountered; on the second
1066 /// occurrence of any module the gate fires. Diagnostic-order pin:
1067 /// the first colliding occurrence surfaces, not the last — mirrors
1068 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1069 /// and every peer duplicate gate's first-collision discipline.
1070 fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1071 let mut seen: Vec<&str> = Vec::new();
1072 for instr in self.instructions() {
1073 // Route the per-instruction load-family arm-discriminator
1074 // through the `gen_platform::IsVariant`-derive-generated
1075 // [`UpgradeInstruction::is_load_module`] predicate rather
1076 // than the raw single-arm `match instr {
1077 // UpgradeInstruction::LoadModule { module } =>
1078 // module.as_str(), _ => continue }` open-coded pattern-
1079 // match — closes the last unlifted `matches!`-shaped
1080 // per-arm-hand-rolled scalar-value + arm-discriminator
1081 // pair inside `impl UpgradeFromEntry`, sibling of the
1082 // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1083 // routing already lifted onto the two-arm cleanup-family
1084 // axis's per-arm arm-discriminator + `:module` projection
1085 // dispatch. The load-target `:module` scalar is projected
1086 // through the sibling [`UpgradeInstruction::declared_module`]
1087 // accessor rather than the per-arm-hand-rolled scalar-
1088 // value binding, with the
1089 // `is_load_module`-implies-`declared_module`-is-`Some`
1090 // composition pin at
1091 // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1092 // making the `.expect(…)` structurally infallible at
1093 // build time. Every arm-family partition the three
1094 // within-entry per-instruction-class singularity gates
1095 // key off — load-family
1096 // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1097 // ([`UpgradeInstruction::SoftPurge`] |
1098 // [`UpgradeInstruction::Purge`]), migration-family
1099 // ([`UpgradeInstruction::StateChange`]) — now consults
1100 // exactly one typed dispatch on the substrate primitive
1101 // (`is_load_module()` here, `is_cleanup()` at
1102 // [`Self::validate_cleanup_singularity`],
1103 // `declared_path()` at
1104 // [`Self::validate_state_change_singularity`]), so a
1105 // future sixth arm added to [`UpgradeInstruction`] (an
1106 // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1107 // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1108 // split-traffic variant the M4 CR materializer could
1109 // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1110 // single enum-declaration edit through the derive rather
1111 // than a scattered per-consumer rewrite. Byte-identity of
1112 // this dispatch against the pre-lift match-pattern is
1113 // pinned by
1114 // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1115 if !instr.is_load_module() {
1116 continue;
1117 }
1118 let module = instr
1119 .declared_module()
1120 .expect("is_load_module() implies declared_module() is Some");
1121 if seen.contains(&module) {
1122 return Err(UpgradeError::duplicate_load_module(
1123 self.prior_versao(),
1124 module,
1125 ));
1126 }
1127 seen.push(module);
1128 }
1129 Ok(())
1130 }
1131
1132 /// Reject an entry whose `:instructions` list names the same script
1133 /// as the target of more than one `(:state-change …)` instruction —
1134 /// set-not-multiset on the `StateChange` axis.
1135 ///
1136 /// `StateChange` is the `gen_server:code_change/3` analog
1137 /// (INSPIRATIONS §II.4: "State migration uses
1138 /// `gen_server:code_change/3`"). The instruction folds the *old*
1139 /// state into the shape the *new* code expects — a one-shot
1140 /// transition from one declared state representation to another.
1141 /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1142 /// exactly once per upgrade per `gen_server`; `systools`-generated
1143 /// `.relup` files emit at most one `code_change` per `gen_server` per
1144 /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1145 /// instruction targeting the same script in one entry re-runs the
1146 /// migration fold — at best a no-op (idempotent script masking a
1147 /// typo where the author intended two distinct scripts) and at
1148 /// worst silent state corruption (non-idempotent fold double-
1149 /// applied: an `add column` migration that runs twice, an
1150 /// `increment counter` that double-bumps, a `rename field` that
1151 /// renames-then-fails the second time). Three authoring footguns
1152 /// close here:
1153 ///
1154 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1155 /// (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1156 /// migration line twice" footgun. The second `:state-change`
1157 /// re-runs the same fold on the already-migrated state — a
1158 /// no-op if the script is idempotent (dead code masking the
1159 /// duplication) or state corruption if not (the migration's
1160 /// pre-condition no longer holds because the post-condition is
1161 /// already in place).
1162 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1163 /// (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1164 /// "duplicate migrate masked by trailing cleanup" footgun. The
1165 /// cleanup still fires correctly, masking the migration-side
1166 /// duplication as a silently-passing entry.
1167 /// - `((:load-module "x") (:state-change "lib/m1.lisp")
1168 /// (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1169 /// two distinct modules" typo. The author intended
1170 /// `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1171 /// but renamed both to `m1.lisp` (or copy-pasted the first line
1172 /// and forgot to change the script). The migration that should
1173 /// have folded the second module's state never runs, far from
1174 /// the source caixa.lisp.
1175 ///
1176 /// Same within-entry exclusivity discipline as
1177 /// [`Self::validate_load_singularity`] (the per-module load-
1178 /// singularity gate it runs after) and
1179 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1180 /// singularity gate it runs before) on the sibling `StateChange`
1181 /// axis: each rejects an `:instructions` list whose instructions
1182 /// are individually well-shaped but jointly incoherent on a per-
1183 /// instruction-class basis (load-once per module for the load
1184 /// axis; migrate-once per script for the migration axis here;
1185 /// cleanup-once per module for the cleanup axis), at the typed
1186 /// build surface rather than as a runtime surprise. Runs *after*
1187 /// [`Self::validate_load_singularity`] so an entry like
1188 /// `((:load-module "x") (:load-module "x") (:state-change
1189 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1190 /// `DuplicateLoadModule` first — the load axis precedes the
1191 /// migration axis in the canonical OTP sequence
1192 /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1193 /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1194 /// `StateChange`), so the load-side singularity is the load-
1195 /// bearing diagnostic when both fire. Runs *before*
1196 /// [`Self::validate_cleanup_singularity`] so an entry like
1197 /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1198 /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1199 /// surfaces `DuplicateStateChange` first — the migration axis
1200 /// precedes the cleanup axis in the canonical OTP sequence
1201 /// (`code:code_change/3` then `code:soft_purge/1`) and in
1202 /// [`UpgradeInstruction`] declaration order (`StateChange` before
1203 /// `SoftPurge`/`Purge`).
1204 ///
1205 /// Same set-not-multiset discipline applied to every peer
1206 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1207 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1208 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1209 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1210 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1211 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1212 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1213 /// per-module cleanup-target axis (9cedd8b —
1214 /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1215 /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1216 /// This gate extends the discipline onto the within-entry
1217 /// `StateChange` instruction-target axis — the third within-entry
1218 /// per-instruction-class singularity, completing the OTP two-phase
1219 /// code-load + state-migration coverage triad
1220 /// (`code:load_module/1` → `gen_server:code_change/3` →
1221 /// `code:soft_purge/1`).
1222 ///
1223 /// Detection: linear scan of the instructions list collecting the
1224 /// script path from every `StateChange` encountered; on the second
1225 /// occurrence of any script the gate fires. Diagnostic-order pin:
1226 /// the first colliding occurrence surfaces, not the last — mirrors
1227 /// [`Self::validate_load_singularity`]'s and
1228 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1229 /// and every peer duplicate gate's first-collision discipline.
1230 fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1231 // Route the per-instruction `StateChange`-arm script-path
1232 // projection through the sibling lifted
1233 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1234 // accessor rather than the raw
1235 // `match instr { UpgradeInstruction::StateChange { script } =>
1236 // script.as_path(), _ => continue }` open-coded pattern-match —
1237 // the third within-entry singularity gate's per-instruction
1238 // script-projection site now keys off exactly one typed
1239 // dispatch on the substrate primitive's `PathBuf`-carrying
1240 // axis, sibling to the four peer per-`UpgradeInstruction`
1241 // consumers ([`Self::validate`]'s per-`StateChange`
1242 // sandbox-path fan-out, the layout-side per-`StateChange`
1243 // script-existence fan-out at
1244 // `caixa-core/src/layout.rs:1017`, the cross-slot
1245 // [`validate_upgrade_from_against_behavior`] gate's
1246 // per-`StateChange` detection loop, the future wasm-operator's
1247 // per-`StateChange` runtime hook-dispatch) that already route
1248 // through `declared_path` / `declared_module`. Byte-equal
1249 // today (`declared_path` returns `Some(script)` iff the
1250 // instruction is [`UpgradeInstruction::StateChange`], per the
1251 // sibling `declared_path_only_for_state_change` pin), so a
1252 // duplicate `:state-change` script surfaces
1253 // `DuplicateStateChange` byte-identical to the pattern-match
1254 // shape. Same "one typed dispatch on the substrate primitive,
1255 // thin projections at each consumer" discipline the sibling
1256 // [`UpgradeInstruction::declared_module`] accessor established
1257 // (b13c4f9) on the peer `String`-carrying axis's per-variant
1258 // consumers, extended here onto the last unlifted
1259 // pattern-match on the `PathBuf`-carrying axis inside
1260 // `impl UpgradeFromEntry`.
1261 let mut seen: Vec<&std::path::Path> = Vec::new();
1262 for instr in self.instructions() {
1263 let Some(script) = instr.declared_path() else {
1264 continue;
1265 };
1266 let script = script.as_path();
1267 if seen.contains(&script) {
1268 return Err(UpgradeError::duplicate_state_change(
1269 self.prior_versao(),
1270 script,
1271 ));
1272 }
1273 seen.push(script);
1274 }
1275 Ok(())
1276 }
1277}
1278
1279/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1280/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1281/// invariant — at most one `(:from <prior>)` block per parsed semver.
1282///
1283/// OTP's appup picks at most one matching block to apply to the running
1284/// release (`release_handler:install_release/1` matches the loaded
1285/// `:from` against the currently-running version and executes the
1286/// associated instruction sequence; the wasm-operator picks the matching
1287/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1288/// the same parsed-semver `:from` are an ambiguous edge in the typed
1289/// upgrade graph — the operator can pick either set deterministically,
1290/// but each set may carry different `LoadModule | StateChange |
1291/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1292/// non-deterministic relative to the source caixa.lisp. The author's
1293/// intent is one path per prior version; the typed graph must enforce
1294/// that shape.
1295///
1296/// Same set-not-multiset discipline already applied to every peer
1297/// typed-graph axis: `:children :caixa` (dbf50a9 —
1298/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1299/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1300/// `AplicacaoError::MembroDuplicate`), `:contratos`
1301/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1302/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1303/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1304/// Each closes the same authoring footgun: a Vec authoring surface that
1305/// silently accepts duplicate entries and renders the "second wins"
1306/// (or "operator picks arbitrarily") shape downstream, far from the
1307/// source caixa.lisp.
1308///
1309/// Duplicates are detected by [`semver::Version`] equality (the
1310/// crate's `PartialEq` compares the full identity — major.minor.patch +
1311/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1312/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1313/// The conservative choice mirrors what the wasm-operator's
1314/// `:from`-match dispatch can see; collapsing build metadata to catch
1315/// a wider net of duplicates is a future tightening that requires
1316/// coordinating with the operator's match step.
1317///
1318/// Per-entry shape errors fire before the duplicate gate so the
1319/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1320/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1321/// errors into a single duplicate diagnostic. Mirrors the
1322/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1323/// axis ([`crate::SupervisorSpec::validate`],
1324/// [`crate::AplicacaoSpec::validate_membros`],
1325/// [`crate::AplicacaoSpec::validate_placement`]).
1326pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1327 use semver::Version;
1328 let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1329 for entry in entries {
1330 entry.validate()?;
1331 // `entry.validate()` accepted this `:from`, so parse cannot
1332 // fail here — the FromInvalid arm above is the only gate
1333 // and both call `Version::parse(entry.prior_versao())`.
1334 let parsed = Version::parse(entry.prior_versao()).expect(
1335 "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1336 two gates aligned",
1337 );
1338 if seen.contains(&parsed) {
1339 return Err(UpgradeError::duplicate_from(entry));
1340 }
1341 seen.push(parsed);
1342 }
1343 Ok(())
1344}
1345
1346/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1347/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1348/// same ordering [`semver::Version::cmp`] implements, with build
1349/// metadata ignored per [SemVer §11][semver-11]).
1350///
1351/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1352/// declarative answer to "given the wasm-operator is loading a node
1353/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1354/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1355/// semantic). The operator's `:from`-match dispatch loads the
1356/// current `:versao` and matches the *running* version against each
1357/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1358/// unreachable — the operator never runs a version greater than or
1359/// equal to the current `:versao` that it could then "upgrade *to*"
1360/// the current `:versao`. Two authoring footguns close here:
1361///
1362/// - `:from > :versao` (downgrade-shaped) — the canonical
1363/// "I copy-pasted from the next minor version and forgot to bump
1364/// `:versao`" / "I bumped `:versao` then reverted but left the
1365/// `:upgrade-from` entry behind" footgun. Until this gate landed
1366/// `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1367/// silently passed `feira build` and the wasm-operator's
1368/// `:from`-match dispatch would never fire on the entry — the
1369/// instructions sat dormant in the caixa.lisp forever, the
1370/// author's intent ("upgrade users coming from 0.2.0") permanently
1371/// unreached because they actually meant to bump `:versao`.
1372///
1373/// - `:from == :versao` (precedence-equal self-upgrade) — the
1374/// "I declared an upgrade from myself to myself" no-op the
1375/// operator's dispatch would either skip silently (no semantic
1376/// transition) or attempt and trivially "succeed" with no
1377/// observable state change. Includes the build-metadata-only
1378/// difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1379/// SemVer-2 precedence ignores build metadata so they compare
1380/// equal under [`semver::Version::cmp`] — the gate rejects this
1381/// even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1382/// gate uses derived `PartialEq` which keeps them distinct;
1383/// they're distinct dispatch keys but the same "from" version
1384/// for our purposes here).
1385///
1386/// Same cross-slot value-shape discipline as
1387/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1388/// partition (934bc58 — the typed partition between two declared
1389/// slots): one slot's value constrains the valid set of another's,
1390/// and the constraint is a structural property visible at validate
1391/// time. The validated set after this gate satisfies
1392/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1393/// for every entry, so the future operator-side hot-upgrade dispatch
1394/// step can reach for `entry.from` knowing the precedence relation
1395/// holds without re-deriving it from inline checks.
1396///
1397/// Silent-pass semantics on malformed inputs:
1398///
1399/// - When `versao` itself doesn't parse as semver, this gate
1400/// returns `Ok(())` silently — the narrower
1401/// [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1402/// diagnostics are the load-bearing surfaces for those failure
1403/// modes, and surfacing a `FromNotBeforeVersao` over an
1404/// unparseable `:versao` would mask the more actionable root
1405/// cause.
1406/// - Likewise, an entry whose `:from` itself doesn't parse falls
1407/// through to its narrower diagnostic surface
1408/// ([`UpgradeError::FromInvalid`]), which is expected to fire
1409/// via [`validate_upgrade_from`] *before* this gate runs at the
1410/// [`crate::LayoutInvariants`] call site.
1411///
1412/// [semver-11]: https://semver.org/#spec-item-11
1413pub fn validate_upgrade_from_against_versao(
1414 entries: &[UpgradeFromEntry],
1415 versao: &str,
1416) -> Result<(), UpgradeError> {
1417 use semver::Version;
1418 let Ok(current) = Version::parse(versao) else {
1419 // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1420 // surfacing a precedence-relation diagnostic over an unparseable
1421 // top-level version would mask the more actionable root cause.
1422 return Ok(());
1423 };
1424 for entry in entries {
1425 // Per-entry shape — including a malformed `:from` — is gated
1426 // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1427 // upstream at the LayoutInvariants call site; an unparseable
1428 // `:from` here falls through silently to keep the
1429 // FromInvalid diagnostic load-bearing. Same fall-through
1430 // posture as the `versao` arm above.
1431 let Ok(prior) = Version::parse(entry.prior_versao()) else {
1432 continue;
1433 };
1434 if prior >= current {
1435 return Err(UpgradeError::from_not_before_versao(
1436 entry.prior_versao(),
1437 versao,
1438 ));
1439 }
1440 }
1441 Ok(())
1442}
1443
1444/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1445/// `(:state-change <script>)` instruction unless the caixa also declares
1446/// `:behavior :on-state-change` — the runtime callback the per-version
1447/// migration script is delivered through during hot upgrade.
1448///
1449/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1450/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1451/// callback to deliver state migration during hot upgrades." The peer
1452/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1453/// promise from the callback side: the slot is the
1454/// `gen_server:code_change/3` analog — "receives old state + version,
1455/// returns new state. Composes with the `:upgrade-from` slot declared at
1456/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1457/// the composition by invoking the running `gen_server`'s
1458/// `code_change/3` callback during the appup's `code_change` /
1459/// `update, m, soft` step — the appup's instruction triggers the
1460/// callback, the callback folds the prior-version state shape into the
1461/// current-version shape, and the operator advances to the next
1462/// instruction only after the callback returns successfully. caixa
1463/// decomposes the same composition into two typed slots: the per-version
1464/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1465/// instruction's `:script` (the `:upgrade-from` author surface), and the
1466/// runtime hook the operator dispatches the migration through lives in
1467/// the `:behavior :on-state-change` callback (the `:behavior` author
1468/// surface). A `:state-change` instruction declared without the callback
1469/// is half the composition: the per-version script the author wrote has
1470/// no runtime delivery path, and the operator's hot-upgrade dispatch
1471/// reaches for `caixa.behavior.on_state_change` at the migration step,
1472/// finds `None`, and either fails the upgrade mid-flight (the
1473/// transactional rollback the module doc names — "On any failure, the
1474/// current version stays load-bearing — a typed atomic upgrade") or
1475/// silently skips the migration depending on the operator's handling of
1476/// a missing callback, both far from the source caixa.lisp.
1477///
1478/// Two authoring footguns close here:
1479///
1480/// - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1481/// :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1482/// (:soft-purge "x-old")))))` — the "I declared the migration script
1483/// but forgot the callback" footgun. The author wrote the per-version
1484/// fold against the prior state shape, the typed `:upgrade-from`
1485/// slot validated every per-instruction shape + ordering + singularity
1486/// gate, and the missing callback only surfaces at upgrade time as
1487/// either a transactional rollback to the prior version (no progress
1488/// across the upgrade) or as a silently-skipped migration that leaves
1489/// v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1490/// state shape).
1491/// - `:behavior` absent entirely + `:upgrade-from` carrying any
1492/// `:state-change` — the "I added the upgrade path but never declared
1493/// `:behavior`" footgun. `:behavior` is optional at the typed root
1494/// ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1495/// `:upgrade-from` slot validates on its own merits, but a `Caixa`
1496/// with `behavior: None` and a `:state-change` instruction is the
1497/// same missing-callback shape — the operator's dispatch can't reach
1498/// a callback that doesn't exist.
1499///
1500/// Same cross-slot composition discipline as
1501/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1502/// precedence gate at the peer wire-up site): one slot's value
1503/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1504/// the valid set of another's (the entry must be dispatchable there; the
1505/// callback must be declared here), and the constraint is a structural
1506/// property visible at validate time. The validated set after this gate
1507/// satisfies the documented composition: every `:state-change`
1508/// instruction the operator iterates at hot-upgrade time has a
1509/// corresponding `:on-state-change` callback declared on the same caixa,
1510/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1511/// `release_handler` canonical-sequence loop) can reach for
1512/// `behavior.on_state_change` at the migration step knowing the
1513/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1514/// from inline checks.
1515///
1516/// Diagnostic-precedence:
1517///
1518/// - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1519/// shape + the within-entry ordering / singularity gates) and
1520/// [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1521/// gate), so a malformed `:state-change` (`EmptyScript`,
1522/// `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1523/// (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1524/// duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1525/// self-locating diagnostic first — the canonical "per-instr-shape +
1526/// within-entry ordering + cross-entry uniqueness before
1527/// cross-slot composition" precedence the peer
1528/// `validate_upgrade_from_against_versao` gate establishes at the
1529/// same wire-up site. Without this precedence pin a malformed
1530/// `:state-change` instruction would surface this gate's
1531/// missing-callback diagnostic over the narrower
1532/// `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1533/// load-bearing per-instruction defect with a cross-slot composition
1534/// diagnostic.
1535/// - Within the entries, walks the list in declaration order and
1536/// surfaces the *first* `:state-change` instruction encountered —
1537/// mirrors every peer first-collision diagnostic posture on this
1538/// module (`validate_state_change_ordering` returns on the first
1539/// `StateChange` without prior load,
1540/// `validate_load_singularity` returns on the second matching
1541/// module, etc.). A future entry's later `:state-change` doesn't
1542/// surface a different diagnostic — the missing callback is the same
1543/// defect regardless of which entry's `:state-change` exposes it.
1544///
1545/// Silent-pass semantics:
1546///
1547/// - Entries carrying no `:state-change` instruction (load-only,
1548/// cleanup-only, restart-only, or empty `:instructions`) leave the
1549/// gate vacuous — no per-version migration means no callback to
1550/// dispatch through, so the absence of `:on-state-change` is
1551/// coherent. Pins the gate's identity element on the empty-set side
1552/// of the composition.
1553/// - `behavior: None` is *not* a free pass when a `:state-change`
1554/// instruction is present — the same missing-callback shape as
1555/// `behavior: Some(_)` with `on_state_change: None`. The gate reads
1556/// `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1557/// surface the same diagnostic.
1558pub fn validate_upgrade_from_against_behavior(
1559 entries: &[UpgradeFromEntry],
1560 behavior: Option<&crate::BehaviorSpec>,
1561) -> Result<(), UpgradeError> {
1562 if behavior
1563 .and_then(crate::BehaviorSpec::on_state_change)
1564 .is_some()
1565 {
1566 return Ok(());
1567 }
1568 for entry in entries {
1569 // Route the per-instruction `StateChange`-arm script-path
1570 // projection through the sibling lifted
1571 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1572 // accessor rather than the raw
1573 // `if let UpgradeInstruction::StateChange { script } = instr`
1574 // open-coded pattern-match — the cross-slot
1575 // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1576 // script-projection site now keys off exactly one typed dispatch
1577 // on the substrate primitive's `PathBuf`-carrying axis, sibling
1578 // to the four peer per-`UpgradeInstruction` consumers
1579 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1580 // sandbox-path fan-out, the layout-side per-`StateChange`
1581 // script-existence fan-out at
1582 // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1583 // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1584 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1585 // peer [`UpgradeInstruction::declared_module`] `String`-axis
1586 // per-variant unifier) that already route through
1587 // `declared_path` / `declared_module`. Byte-equal today
1588 // (`declared_path` returns `Some(script)` iff the instruction is
1589 // [`UpgradeInstruction::StateChange`], per the sibling
1590 // `declared_path_only_for_state_change` pin), so a
1591 // `:state-change`-without-`:on-state-change`-callback
1592 // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1593 // byte-identical to the pattern-match shape. Fourth (and last)
1594 // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1595 // axis now routed through the accessor — closes the last
1596 // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1597 // site outside `impl UpgradeFromEntry`, so the peer four
1598 // consumer set named in the sibling
1599 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1600 // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1601 // closed.
1602 for instr in entry.instructions() {
1603 if let Some(script) = instr.declared_path() {
1604 return Err(UpgradeError::state_change_without_on_state_change_callback(
1605 entry.prior_versao(),
1606 script,
1607 ));
1608 }
1609 }
1610 }
1611 Ok(())
1612}
1613
1614impl UpgradeInstruction {
1615 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup kind-tag
1616 /// projection every consumer that renders / classifies / grepping-
1617 /// projects an instruction's lisp form keys off — returns the
1618 /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1619 /// straight through the paired
1620 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1621 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1622 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1623 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1624 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1625 /// roster the substrate already carries at the wire-form axis.
1626 ///
1627 /// Consumers today: [`Self::validate`] threads the label through the
1628 /// per-variant [`UpgradeError::ModuleEmpty`] /
1629 /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1630 /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1631 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1632 /// `(:purge …)` and fix it in one edit; every within-entry cross-
1633 /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1634 /// same accessor's `&'static str` return in place of hand-rolling
1635 /// the per-arm match.
1636 ///
1637 /// Promoted from `pub(self)` to `pub`: every future consumer that
1638 /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1639 /// by its OTP-appup lisp form outside caixa-core — a deferred
1640 /// wasm-operator `install_release/1` per-instruction dispatch
1641 /// logger tagging each executed instruction under its kebab-case
1642 /// kind, a `feira lint --upgrade-from` per-instruction author-time
1643 /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1644 /// webhook naming the offending instruction's kind in its rejection
1645 /// body, a future `caixa-actions` renderer that surfaces the
1646 /// declared appup instruction list in a workflow annotation, an
1647 /// LSP hover projecting the per-instruction kind onto a text-
1648 /// document diagnostic — reaches this projection through one call
1649 /// on the substrate primitive rather than open-coding the same
1650 /// five-arm match plus per-arm const imports at every consumer.
1651 /// A future variant addition (a `Discard` peer the `code:delete/1`
1652 /// analog inspires, an M4 `SoftPurge` split into
1653 /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1654 /// policy grows a two-arm shape) reaches every consumer at one edit
1655 /// — this method's match — rather than fanning out through hand-
1656 /// rolled per-arm dispatch across every downstream site.
1657 ///
1658 /// Peer of the sibling substrate-canonical arm-family accessors on
1659 /// the same closed-set enum: [`Self::declared_module`] on the
1660 /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1661 /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1662 /// [`Self::StateChange`] / [`Self::Restart`]),
1663 /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1664 /// (`Some(_)` for [`Self::StateChange`]), and
1665 /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1666 /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1667 /// generated per-variant `is_*` predicate family — every downstream
1668 /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1669 /// one typed dispatch on the substrate primitive rather than open-
1670 /// coding a per-arm match.
1671 ///
1672 /// `const fn` preserves the zero-runtime-work property of the pre-
1673 /// promotion body verbatim, and the `&'static str` return (not
1674 /// `&str` tied to `&self`'s lifetime) matches the paired
1675 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1676 /// program-lifetime discipline so callers can stash the returned
1677 /// label in `&'static`-bounded positions (a static logger's format
1678 /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1679 /// slice-of-`&'static str` accept-set) without re-borrowing through
1680 /// the instruction reference. Named `lisp_form` (not `kind_label` /
1681 /// `discriminant_label`) to name the axis the substrate already
1682 /// reaches for in the paired
1683 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1684 /// in every per-arm `UpgradeError` diagnostic that carries the
1685 /// kebab-case tag verbatim — the lisp author-surface term, not the
1686 /// Rust discriminant name.
1687 #[must_use]
1688 pub const fn lisp_form(&self) -> &'static str {
1689 match self {
1690 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1691 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1692 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1693 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1694 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1695 }
1696 }
1697
1698 /// Validate the instruction's typed shape. Path existence is
1699 /// checked separately by [`crate::layout::StandardLayout`].
1700 ///
1701 /// The per-variant scalar the value-shape gates fire against is
1702 /// read through this method's two sibling accessors — the
1703 /// `String`-carrying axis via [`Self::declared_module`] (the
1704 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1705 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1706 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1707 /// variant's tatara-lisp `:script`) — rather than the per-arm
1708 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1709 /// Self::Purge { module }` pattern the module-axis previously
1710 /// open-coded and the per-arm `Self::StateChange { script }` the
1711 /// script-axis previously open-coded. Every scalar this enum
1712 /// carries now flows through one of the two `Option<&…>`
1713 /// accessors, so a future extension of either axis (a fifth
1714 /// module-bearing variant, an operator-side pre-parsed scalar
1715 /// cache the accessors materialize behind the same return
1716 /// contract, an M4 typed sub-slot the accessors could route
1717 /// alongside the existing scalar) migrates as a single edit on
1718 /// the accessor rather than a coordinated rewrite of every
1719 /// downstream value-shape gate. `Restart` (the only variant that
1720 /// carries neither scalar) falls through both `Option` checks and
1721 /// returns `Ok(())` — the terminal-fallback shape the
1722 /// [`Self::Restart`] variant doc pins.
1723 pub fn validate(&self) -> Result<(), UpgradeError> {
1724 if let Some(module) = self.declared_module() {
1725 return validate_module(self.lisp_form(), module);
1726 }
1727 if let Some(script) = self.declared_path() {
1728 // Delegate the four-arm cascade (empty / absolute /
1729 // parent-escape / non-`.lisp`-extension) to the lifted
1730 // [`crate::render::require_sandboxed_lisp_path`] helper —
1731 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1732 // arm-ordering this method previously inlined verbatim,
1733 // now shared with [`crate::BehaviorSpec::validate`]'s
1734 // per-`:on-*`-callback gate so every author-supplied
1735 // tatara-lisp source path on every M2 typed slot consults
1736 // one gate, not two-and-counting verbatim copies of the
1737 // same four-arm cascade. Each closure wraps the tag in
1738 // the same `*Script` variant the original inline code
1739 // raised, so the diagnostic shape every caller depends
1740 // on (the `:state-change :script` self-locating error)
1741 // is preserved by construction. See
1742 // [`crate::render::require_sandboxed_lisp_path`] for the
1743 // smallest-scope-arm-fires-last ordering rationale.
1744 crate::render::require_sandboxed_lisp_path(
1745 script,
1746 || UpgradeError::EmptyScript,
1747 || UpgradeError::absolute_script(script),
1748 || UpgradeError::parent_escape_script(script),
1749 || UpgradeError::non_lisp_extension_script(script),
1750 )?;
1751 }
1752 // `Restart` (the only variant with no `Option<&…>`-carrying
1753 // scalar) falls through both accessor gates and returns
1754 // `Ok(())` — the terminal-fallback shape.
1755 Ok(())
1756 }
1757
1758 /// The `:module` scalar carried by this instruction — the
1759 /// K8s DNS-1123-label OTP-appup caixa-name reference every
1760 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1761 /// variant declares against, and every author expects `feira lint`
1762 /// to name verbatim in per-instruction diagnostics. Returns `None`
1763 /// on [`Self::StateChange`] (which carries a `:script` — closed by
1764 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1765 /// (which carries no data at all, the OTP terminal-fallback
1766 /// shape).
1767 ///
1768 /// Sibling in shape to [`Self::declared_path`] on the second and
1769 /// final scalar-carrying axis of [`UpgradeInstruction`]:
1770 /// `declared_path` closes the `PathBuf`-carrying arm
1771 /// (`StateChange`); `declared_module` closes the `String`-carrying
1772 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1773 /// enum carries now routes through one of the two `Option<&…>`
1774 /// accessors — a caller that doesn't care which variant declared
1775 /// the scalar reads through one `if let Some(…)` rather than a
1776 /// per-variant pattern match. The pair is the enum-variant-
1777 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1778 /// on the M3 side ([`crate::WitContract::source`] /
1779 /// [`crate::WitContract::destination`] /
1780 /// [`crate::WitContract::world_ref`] closing `:contratos`;
1781 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1782 /// closing `:entrada`; [`crate::Membro::nome`] /
1783 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1784 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1785 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1786 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1787 /// closed OTP-shape supervisor family) — those peer accessors
1788 /// return a struct field verbatim; this pair unifies enum-
1789 /// variant-carried scalars into one accessor per typed axis.
1790 ///
1791 /// Byte-for-byte from the typed variant's own `String` storage;
1792 /// no cloning, no re-parsing. A future extension of the axis (an
1793 /// M4 typed sub-slot the module string is derived from, an
1794 /// operator-side pre-parsed caixa-name cache the accessor could
1795 /// materialize behind the same `&str` return contract, a fifth
1796 /// module-bearing OTP-appup variant the enum grows) migrates as
1797 /// a single caixa-core edit rather than a coordinated rewrite
1798 /// of every downstream module-axis consumer (currently
1799 /// [`Self::validate`]'s DNS-1123-label gate through
1800 /// [`validate_module`]; extensible to future consumers on the
1801 /// same axis without further per-variant match sites).
1802 #[must_use]
1803 pub const fn declared_module(&self) -> Option<&str> {
1804 match self {
1805 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1806 Some(module.as_str())
1807 }
1808 Self::StateChange { .. } | Self::Restart => None,
1809 }
1810 }
1811
1812 /// If the instruction references an on-disk path, return it —
1813 /// used by the layout checker to verify the path resolves.
1814 ///
1815 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1816 /// on the `String`-carrying axis: `declared_path` closes the
1817 /// `StateChange` arm's `:script`; `declared_module` closes the
1818 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1819 /// they route every scalar this enum carries through one of two
1820 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1821 /// gates dispatch on the accessor return rather than a per-variant
1822 /// pattern match on the enum shape itself.
1823 ///
1824 /// Four per-`UpgradeInstruction` consumers now key off this
1825 /// accessor's `PathBuf`-carrying axis:
1826 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1827 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1828 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1829 /// within-entry
1830 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1831 /// per-`StateChange` script-projection fan-out, and the cross-slot
1832 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1833 /// :behavior` composition gate's per-`StateChange` detection loop
1834 /// — every downstream consumer of the `PathBuf`-carrying axis
1835 /// reaches through this one dispatch, so a future accessor
1836 /// extension (an M4 typed sub-slot the script path is derived from,
1837 /// an operator-side pre-resolved-path cache the accessor
1838 /// materializes behind the same `Option<&PathBuf>` return contract,
1839 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1840 /// migrates as a single caixa-core edit rather than a coordinated
1841 /// rewrite of four call sites.
1842 #[must_use]
1843 pub const fn declared_path(&self) -> Option<&PathBuf> {
1844 match self {
1845 Self::StateChange { script } => Some(script),
1846 _ => None,
1847 }
1848 }
1849
1850 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1851 /// family arm-discriminator predicate every within-entry cross-
1852 /// instruction cleanup-facing gate keys off — true iff `self` is
1853 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1854 /// named module until no process is running it, then GC) or
1855 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1856 /// module immediately, without waiting for drain), the two OTP
1857 /// two-phase-code-load cleanup arms the closed-set enum's
1858 /// non-terminal / non-migration / non-load variants exhaust.
1859 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1860 /// two-phase-load half, [`Self::StateChange`] on the
1861 /// `gen_server:code_change/3`-analog migration axis,
1862 /// [`Self::Restart`] on the OTP terminal-fallback shape)
1863 /// returns `false`.
1864 ///
1865 /// Prior to this lift the `Self::SoftPurge { module } |
1866 /// Self::Purge { module }` two-arm cleanup-family pattern-
1867 /// match sat inline at three within-entry cross-instruction
1868 /// gate sites, each hand-rolling its own copy of the union
1869 /// with no compile-time link back to the substrate primitive's
1870 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1871 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1872 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1873 /// arriving before a preceding [`Self::LoadModule`]),
1874 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1875 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1876 /// recording the first-encountered cleanup so a subsequent
1877 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1878 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1879 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1880 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1881 /// second cleanup targeting the same `:module`). Three open-
1882 /// coded per-arm-union pattern-matches that expressed no
1883 /// compile-time link back to the substrate primitive. A future
1884 /// fifth cleanup-shaped variant (a `Discard` variant the
1885 /// `code:delete/1` peer inspires that folds under the same
1886 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1887 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1888 /// cool-down policy grows a two-arm shape, an operator-side
1889 /// pre-resolved cleanup-decision cache the predicate could
1890 /// route through the same `bool` return contract) would have
1891 /// had to be threaded through every open-coded per-arm-union
1892 /// pattern-match in lockstep or one gate would silently
1893 /// classify the new arm outside the cleanup family while the
1894 /// peer gates classified it in (or vice versa) — a
1895 /// classification split across the three within-entry cross-
1896 /// instruction gates at build time that lands far from the
1897 /// source [`UpgradeInstruction`] declaration with no field
1898 /// naming which gate carries the drifted arm-set. Lifting the
1899 /// resolution to a typed predicate on the substrate primitive
1900 /// means every downstream cleanup-facing consumer of the
1901 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1902 /// one typed dispatch — the resolver's arm-set migrates as a
1903 /// unit on any future arm addition composing under this
1904 /// predicate's `||` chain.
1905 ///
1906 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1907 /// derive-generated [`Self::is_restart`] terminal-fallback
1908 /// arm-discriminator predicate on the same closed-set
1909 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1910 /// family partition as one typed dispatch on the substrate
1911 /// primitive; `is_restart` on the single-arm terminal-
1912 /// fallback family, `is_cleanup` on the two-arm cleanup
1913 /// family), extended here from the single-arm case onto the
1914 /// two-arm arm-family union case. Composes through the
1915 /// [`gen_platform::IsVariant`]-derive-generated
1916 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1917 /// predicates rather than an open-coded raw `matches!`
1918 /// pattern-match, so a future rebrand on either underlying
1919 /// per-arm classifier flows through this predicate's one
1920 /// body without a coordinated per-consumer rewrite across
1921 /// the three within-entry cross-instruction gates that route
1922 /// through it. Peer of the sibling per-`:contratos`
1923 /// shape-family union predicates [`crate::WitContract::is_http`] /
1924 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
1925 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
1926 /// per-shape WIT-prefix rule the substrate primitive's arm-
1927 /// family partition names as one typed dispatch) — the same
1928 /// "one typed dispatch on the substrate primitive, thin
1929 /// projections at each consumer" discipline extended onto the
1930 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
1931 /// cleanup-family axis.
1932 ///
1933 /// The name `is_cleanup` maps directly onto the canonical
1934 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
1935 /// `code:soft_purge/1` — wait until no process is running v1,
1936 /// then discard. (`code:purge/1` kills v1 immediately if you
1937 /// don't care.)" — the two `code:*_purge/1` operations are
1938 /// the two-phase-load contract's cleanup half, paired under
1939 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
1940 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1941 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
1942 /// reaches for the same "cleanup" vocabulary in identifier +
1943 /// diagnostic form.
1944 #[must_use]
1945 pub const fn is_cleanup(&self) -> bool {
1946 self.is_soft_purge() || self.is_purge()
1947 }
1948}
1949
1950/// Reject upgrade instruction `:module` values that aren't K8s
1951/// DNS-1123 labels. Thin wrapper around
1952/// [`crate::render::is_dns_1123_label`] that maps the shared
1953/// parser-shaped reason into the kind-tagged
1954/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
1955/// diagnostics, so the author can grep their caixa.lisp for the
1956/// offending `(:<kind> <module>)` form and fix it in one edit.
1957///
1958/// The contract — the same DNS-1123 label rule the K8s apiserver
1959/// enforces on every `metadata.name` / Service name / label value the
1960/// module name lands in. Each upgrade instruction's `:module` is a
1961/// reference to a caixa name (the wasm-engine resolves it through the
1962/// same `ComputeUnit` registry the operator manages), so the value must
1963/// match every downstream apiserver-side schema: the per-Servico
1964/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
1965/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
1966/// against the loaded-module table at hot-upgrade dispatch, and the
1967/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
1968/// per-module reference axis. Same trajectory as `:children :caixa`
1969/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
1970/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
1971/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
1972///
1973/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
1974/// variant before this predicate is consulted, mirroring
1975/// `validate_membro_caixa`'s empty-first cascade.
1976fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
1977 // Routes through the shared
1978 // [`crate::render::require_valid_dns_1123_label`] gate the peer
1979 // name axes each land on. The `kind: &'static str` field flows
1980 // through both error variants so the diagnostic names which
1981 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
1982 // offending value came from.
1983 crate::render::require_valid_dns_1123_label(
1984 module,
1985 || UpgradeError::module_empty(kind),
1986 |reason| UpgradeError::module_invalid(kind, module, reason),
1987 )
1988}
1989
1990#[derive(Debug, Error, PartialEq, Eq)]
1991pub enum UpgradeError {
1992 #[error(
1993 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
1994 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
1995 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
1996 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
1997 the running version through `semver::Version::parse` and matches it against each entry's \
1998 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
1999 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2000 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2001 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2002 )]
2003 FromInvalid { from: String, reason: String },
2004 #[error(
2005 "upgrade instruction `{kind}` :module is empty (every appup module reference \
2006 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2007 the instruction entirely)"
2008 )]
2009 ModuleEmpty { kind: &'static str },
2010 #[error(
2011 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2012 {reason} (every appup module reference resolves to a caixa name, which lands \
2013 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2014 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2015 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2016 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2017 `\"cache-v2\"`)"
2018 )]
2019 ModuleInvalid {
2020 kind: &'static str,
2021 module: String,
2022 reason: String,
2023 },
2024 #[error("instruction's :script is empty")]
2025 EmptyScript,
2026 #[error(
2027 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2028 root (Path::join would otherwise escape the project sandbox)",
2029 script.display()
2030 )]
2031 AbsoluteScript { script: PathBuf },
2032 #[error(
2033 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2034 above the caixa root",
2035 script.display()
2036 )]
2037 ParentEscapeScript { script: PathBuf },
2038 #[error(
2039 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2040 wasm-engine instantiator reads every migration script as tatara-lisp source through \
2041 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2042 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2043 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2044 parser error far from the source caixa.lisp, with no field naming the offending \
2045 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2046 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2047 `\"lib/migrations/v01-to-v02.lisp\"`).",
2048 script.display()
2049 )]
2050 NonLispExtensionScript { script: PathBuf },
2051 #[error(
2052 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2053 one matching block per running version (`release_handler:install_release/1` dispatches \
2054 on the loaded `:from` against the currently-running release), so two entries with the \
2055 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2056 pick either set non-deterministically). Author one path per prior version; if two \
2057 distinct instruction sequences are needed, fold them into one ordered list under the \
2058 single matching `(:from {from:?} :instructions (…))` block."
2059 )]
2060 DuplicateFrom { from: String },
2061 #[error(
2062 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2063 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2064 greater than or equal to the caixa's own version is structurally unreachable \
2065 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2066 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2067 is never reached because the operator never runs a version greater than or equal to \
2068 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2069 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2070 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2071 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2072 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2073 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2074 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2075 here as a self-upgrade no-op."
2076 )]
2077 FromNotBeforeVersao { from: String, versao: String },
2078 #[error(
2079 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2080 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2081 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2082 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2083 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2084 component-model world incompatibility, irreversible state shape change), and the \
2085 fallback is terminal by construction (the operator restarts the pod and the new \
2086 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2087 in both directions: if the typed instructions would succeed, `(:restart)` is \
2088 unreached; if they wouldn't, the typed instructions are dead because the operator \
2089 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2090 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2091 never repeated. If two distinct upgrade strategies are needed for the same prior \
2092 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2093 dispatch picks exactly one block per running version) — keep the typed sequence; \
2094 the fallback restart is what the operator does on any typed-sequence failure \
2095 already."
2096 )]
2097 RestartNotExclusive {
2098 from: String,
2099 restart_count: usize,
2100 other_kinds: Vec<&'static str>,
2101 },
2102 #[error(
2103 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2104 `(:load-module …)` in its :instructions list — a state migration is the \
2105 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2106 code, but the operator executes instructions in declared order, so this migration \
2107 runs while the only resident version is still the prior one (which expects the \
2108 pre-migration state shape). Load the new module first: author the canonical \
2109 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2110 resident before its state migration runs.",
2111 script.display(),
2112 script.display()
2113 )]
2114 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2115 #[error(
2116 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2117 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2118 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2119 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2120 then `code:soft_purge/1`), but the operator executes instructions in declared \
2121 order, so this cleanup runs while the only resident version is still the same \
2122 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2123 mid-request), leaving no replacement to route in-flight or future requests \
2124 to. Load the new module first: author the canonical `(:load-module …) \
2125 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2126 before the old code is drained or discarded."
2127 )]
2128 PurgeWithoutPriorLoad {
2129 from: String,
2130 kind: &'static str,
2131 module: String,
2132 },
2133 #[error(
2134 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2135 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2136 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2137 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2138 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2139 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2140 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2141 module for this reason. A second cleanup on the same module is at best redundant (the \
2142 module is already gone after the first cleanup, so the second is a no-op or undefined \
2143 depending on the operator's handling of a non-resident-module purge request) and at \
2144 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2145 wanted a fallback, but the operator runs declared instructions unconditionally — \
2146 fallback on cleanup failure is the operator's job, not authored into the entry). \
2147 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2148 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2149 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2150 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2151 )]
2152 DuplicateCleanup {
2153 from: String,
2154 module: String,
2155 kinds: Vec<&'static str>,
2156 },
2157 #[error(
2158 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2159 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2160 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2161 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2162 dispatch table reads the module name and brings up the corresponding component \
2163 alongside the running version. systools-generated `.relup` files emit at most one \
2164 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2165 {module:?})` instruction has no observable semantic relative to the first (the \
2166 component is already resident) — either dead code (copy-pasted load line) or a typo \
2167 masking a distinct module the author intended to load alongside (renamed both to \
2168 {module:?} by mistake), leaving the second module silently absent from the entry. \
2169 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2170 versions need loading alongside the running one, name them distinctly (e.g. \
2171 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2172 )]
2173 DuplicateLoadModule { from: String, module: String },
2174 #[error(
2175 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2176 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2177 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2178 state shape into the current-version shape: a one-shot transition, not a step that \
2179 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2180 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2181 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2182 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2183 author intended two distinct migration scripts) and at worst silent state corruption \
2184 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2185 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2186 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2187 transitions are needed (e.g. one module's schema *and* another module's projection), \
2188 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2189 script.display(),
2190 script.display(),
2191 script.display(),
2192 script.display()
2193 )]
2194 DuplicateStateChange { from: String, script: PathBuf },
2195 #[error(
2196 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2197 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2198 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2199 current shape, but the prior version's state only exists while the prior code is \
2200 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2201 analogs and drain or discard that prior code. The operator executes instructions in \
2202 declared order, so a cleanup ahead of a state-change has already drained the prior \
2203 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2204 the migration script runs, leaving the script either no-op (no prior-version state \
2205 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2206 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2207 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2208 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2209 strictly between load and cleanup. Author the canonical `(:load-module …) \
2210 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2211 migration runs against the prior-version state before the cleanup drains it.",
2212 script.display(),
2213 script.display()
2214 )]
2215 StateChangeAfterCleanup {
2216 from: String,
2217 script: PathBuf,
2218 prior_cleanup_kind: &'static str,
2219 prior_cleanup_module: String,
2220 },
2221 #[error(
2222 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2223 declare `:behavior :on-state-change` — the per-version migration script is the \
2224 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2225 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2226 realizes the composition by invoking the running gen_server's code_change/3 callback \
2227 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2228 composition into two typed slots, the per-version migration logic in this \
2229 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2230 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2231 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2232 migration during hot upgrades\"). The missing callback leaves the per-version script \
2233 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2234 callback at the migration step, finds it absent, and either fails the upgrade \
2235 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2236 current version stays load-bearing\") or silently skips the migration leaving the \
2237 new code running against unmigrated prior-version state. Add the callback: \
2238 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2239 path) alongside the existing `(:state-change {})` instruction (the per-version \
2240 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2241 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2242 migration — is the canonical shape).",
2243 script.display(),
2244 script.display()
2245 )]
2246 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2247}
2248
2249// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2250// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2251// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2252// two-slot struct-variant wire-up sites at
2253// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2254// / `script` from `instr.declared_path()`),
2255// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2256// (`self.prior_versao()` / `script.as_path()` from
2257// `instr.declared_path()`), and
2258// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2259// / `script` from `instr.declared_path()`) onto one substrate primitive
2260// per typed variant — the paired `{ from: String, script: PathBuf }`
2261// two-slot sibling on [`UpgradeError`] of the peer
2262// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2263// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2264// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2265// (8580068, 4 variants on `{ de, para }`),
2266// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2267// `{ de, para, wit, expected }`),
2268// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2269// variants on `{ <field>: String, reason: String }`), and
2270// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2271// variants on `{ de, para, <field>: String, reason: String }`) on the
2272// sibling `AplicacaoError` envelopes, and the peer
2273// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2274// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2275// (0419438, 4 variants on `{ caixa, kind, slots }`),
2276// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2277// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2278// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2279// `LayoutError` envelopes, plus the peer
2280// [`crate::limits::limits_codec_value_only_ctors!`] /
2281// [`crate::limits::limits_codec_value_byte_ctors!`] /
2282// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2283// wire-ups) on the sibling `LimitsError` envelopes.
2284//
2285// Each of the three wire-up sites on this shape opens the identical
2286// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2287// script: <script>.to_path_buf() }` struct-literal against a local
2288// `prior_versao()` and `declared_path()` accessor pair — the exact
2289// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2290// names as a bug, on the same altitude the peer `SupervisorError` /
2291// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2292// closed on their sibling envelopes. The three variants share one
2293// `{ from: String, script: PathBuf }` shape, so the fold routes each
2294// wire-up site through one dispatch per typed variant.
2295//
2296// The macro below generates one `#[must_use]` inherent constructor per
2297// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2298// Self`, so every wire-up site collapses onto one dispatch:
2299// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2300// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2301// uniform two-field construction (`from.to_string()` /
2302// `script.to_path_buf()`) is spelled once — inside the macro — rather
2303// than at every wire-up site. The `&Path` parameter accepts both
2304// `&Path` (from `script.as_path()` at the uniqueness gate) and
2305// `&PathBuf` (from `instr.declared_path()` at the ordering /
2306// callback-declaration gates, via Deref coercion), so every existing
2307// wire-up threads through the ctor without a pre-conversion.
2308//
2309// Every future consumer that wants to construct one of these three
2310// variants outside the three in-crate `UpgradeFromEntry` /
2311// `validate_state_change_on_state_change_callback` gates (a deferred
2312// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2313// re-checker at hot-upgrade dispatch time, a future
2314// `feira validate --upgrade-from` per-caixa admission verb re-checking
2315// the three axes, a per-`Caixa` overlay resolver rejecting an
2316// ordering / uniqueness / callback-declaration invariant against a
2317// cluster-local snapshot) now reaches each variant through one call
2318// rather than re-inlining the three-line struct-literal in lockstep
2319// with the three in-crate wire-up sites.
2320macro_rules! upgrade_from_script_ctors {
2321 ($($ctor:ident => $variant:ident),* $(,)?) => {
2322 impl UpgradeError {
2323 $(
2324 #[doc = concat!(
2325 "Construct an [`UpgradeError::",
2326 stringify!($variant),
2327 "`] naming the offending `(:from <prior-versao>)` and ",
2328 "`(:state-change <script>)` pair. Folds the uniform ",
2329 "`Self::",
2330 stringify!($variant),
2331 " { from: from.to_string(), script: script.to_path_buf() }` ",
2332 "two-field struct-literal onto one substrate primitive so ",
2333 "every wire-up on this variant reads through one dispatch ",
2334 "rather than the pre-lift three-line open-coded block. The ",
2335 "`from` string threads verbatim from ",
2336 "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2337 "from [`UpgradeInstruction::declared_path`] at the call site."
2338 )]
2339 #[must_use]
2340 pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2341 Self::$variant {
2342 from: from.to_string(),
2343 script: script.to_path_buf(),
2344 }
2345 }
2346 )*
2347 }
2348 };
2349}
2350
2351upgrade_from_script_ctors! {
2352 state_change_without_prior_load => StateChangeWithoutPriorLoad,
2353 duplicate_state_change => DuplicateStateChange,
2354 state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2355}
2356
2357// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2358// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2359// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2360// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2361// onto one substrate primitive per typed variant — the paired
2362// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2363// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2364// `{ from: String, script: PathBuf }`) two-slot family on the same
2365// envelope, and of the peer
2366// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2367// variants on `{ caixa: String }`) and
2368// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2369// `{ nome: String }`) single-slot families on the sibling
2370// `SupervisorError` / `DepError` envelopes, and of the peer
2371// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2372// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2373// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2374// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2375// variants on `{ <field>: String, reason: String }`), and
2376// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2377// variants on `{ de, para, <field>: String, reason: String }`) on the
2378// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2379// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2380// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2381// [`crate::LayoutError::missing_entry`] 1b09f9d;
2382// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2383// `LimitsError` codec families (81c856c), and the sibling
2384// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2385// `{ nome, caminho }`) two-slot family.
2386//
2387// The three wire-up sites this fold closes are the three closures
2388// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2389// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2390// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2391// passed to [`crate::render::require_sandboxed_lisp_path`] at
2392// [`UpgradeInstruction::validate`] — each opens the identical
2393// `UpgradeError::<Variant> { script: script.clone() }` three-line
2394// struct-literal against the same `script: &PathBuf` local threaded
2395// from [`UpgradeInstruction::declared_path`], the exact "same block
2396// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2397// bug. The three variants share one `{ script: PathBuf }` shape, so
2398// the fold routes each closure through one dispatch per typed variant.
2399// The sibling `EmptyScript` unit-variant on the same envelope stays on
2400// its pre-lift open-coded shape — it carries no `script` field (the
2401// offending `:script` value *is* the empty path this variant catches),
2402// so the uniform `fn(script: &Path) -> Self` signature this macro
2403// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2404// closure is already a one-liner. This is the second fold family on
2405// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2406// two-slot family established in 8e67041, which explicitly named this
2407// `{ script: PathBuf }` single-slot family as the next fold to land
2408// on the envelope; per that commit's coverage roster, both of the two
2409// most-populated shapes on `UpgradeError` — the two-slot
2410// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2411//
2412// The macro below generates one `#[must_use]` inherent constructor per
2413// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2414// every closure collapses onto one dispatch:
2415// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2416// struct-literal on the same `&Path` fixture. The uniform one-field
2417// construction (`script.to_path_buf()`) is spelled once — inside the
2418// macro — rather than at every wire-up site. The `&Path` parameter
2419// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2420// `instr.declared_path()` at the three closures, via Deref coercion),
2421// so every existing closure threads through the ctor without a
2422// pre-conversion.
2423//
2424// Every future consumer that wants to construct one of these three
2425// variants outside the three in-crate closures (a deferred
2426// wasm-operator's `install_release/1` per-instruction script-shape
2427// re-checker at hot-upgrade dispatch time, a future
2428// `feira validate --upgrade-from` per-caixa admission verb re-checking
2429// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2430// an author-supplied `:state-change :script` against a cluster-local
2431// snapshot) now reaches each variant through one call rather than
2432// re-inlining the three-line struct-literal in lockstep with the three
2433// in-crate closure sites.
2434macro_rules! upgrade_script_only_ctors {
2435 ($($ctor:ident => $variant:ident),* $(,)?) => {
2436 impl UpgradeError {
2437 $(
2438 #[doc = concat!(
2439 "Construct an [`UpgradeError::",
2440 stringify!($variant),
2441 "`] naming the offending `(:state-change <script>)`. ",
2442 "Folds the uniform `Self::",
2443 stringify!($variant),
2444 " { script: script.to_path_buf() }` one-field ",
2445 "struct-literal onto one substrate primitive so every ",
2446 "closure passed to ",
2447 "[`crate::render::require_sandboxed_lisp_path`] at ",
2448 "[`UpgradeInstruction::validate`] on this variant reads ",
2449 "through one dispatch rather than the pre-lift three-line ",
2450 "open-coded block. The `script` path threads verbatim ",
2451 "from [`UpgradeInstruction::declared_path`] at the call ",
2452 "site."
2453 )]
2454 #[must_use]
2455 pub fn $ctor(script: &std::path::Path) -> Self {
2456 Self::$variant {
2457 script: script.to_path_buf(),
2458 }
2459 }
2460 )*
2461 }
2462 };
2463}
2464
2465upgrade_script_only_ctors! {
2466 absolute_script => AbsoluteScript,
2467 parent_escape_script => ParentEscapeScript,
2468 non_lisp_extension_script => NonLispExtensionScript,
2469}
2470
2471// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2472// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2473// <value>.to_string() }` two-slot struct-variant wire-up sites at
2474// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2475// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2476// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2477// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2478// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2479// self.prior_versao().to_string(), module: module.to_string() });`),
2480// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2481// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2482// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2483// });`) onto one substrate-primitive family per typed variant — the
2484// missing paired two-slot rung on the `UpgradeError`-side four-family
2485// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2486// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2487// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2488// script: PathBuf }`), and mirror-symmetric sibling of the peer
2489// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2490// String, <axis>: String }` fold on the `DepError` envelope — same
2491// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2492// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2493// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2494// carries the offending prior-version `:from` verbatim so the author
2495// can grep their caixa.lisp for the offending `(:from "<value>")` /
2496// `(:load-module …)` / `:versao` block in one edit). The three
2497// variants share the same `{ from: String, <axis>: String }` two-slot
2498// shape: the `from` field names the offending per-`:upgrade-from` block's
2499// prior-version tag the diagnostic points the author back at, and the
2500// middle `<axis>: String` field carries the offending per-envelope axis
2501// value verbatim (`reason` on `FromInvalid` carries the wrapped
2502// `semver::Version::parse` error message that pinpoints why the tag
2503// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2504// own current-`:versao` the entry's `:from` failed to precede; `module`
2505// on `DuplicateLoadModule` carries the caixa name the second
2506// `(:load-module …)` instruction re-loaded within the same entry).
2507// The middle axis-field name differs across variants (`reason` /
2508// `versao` / `module`) so the ctor family below takes the axis field
2509// name as a macro parameter (`$axis:ident`) alongside the ctor +
2510// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2511// -> Self` inherent constructor per typed variant that spells the
2512// uniform two-field construction (`from.to_string()` /
2513// `<axis>.to_string()`) exactly once.
2514//
2515// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2516// variants on `{ from: String, script: PathBuf }`) two-slot family on
2517// the same envelope — both key off the same `from: String` axis at the
2518// same per-`:upgrade-from :from`-owned altitude; this family carries the
2519// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2520// carrier) where the script-slot family carries the owned-`PathBuf`
2521// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2522// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2523// same envelope, of the sibling
2524// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2525// variants on `{ caixa: String }`) and
2526// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2527// `{ nome: String }`) single-slot families on the sibling
2528// `SupervisorError` / `DepError` envelopes, and of the peer
2529// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2530// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2531// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2532// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2533// variants on `{ <field>: String, reason: String }`),
2534// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2535// variants on `{ caixa: String }`),
2536// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2537// on `{ path: String }`), and
2538// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2539// variants on `{ de, para, <field>: String, reason: String }`) on the
2540// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2541// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2542// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2543// [`crate::LayoutError::missing_entry`] 1b09f9d;
2544// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2545// `LimitsError` codec families (81c856c), the sibling
2546// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2547// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2548// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2549// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2550// `{ nome, list: &'static str }`), and
2551// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2552// `{ nome, <axis>: String, reason: String }`) families.
2553//
2554// Each of the three wire-up sites on this shape opens the identical
2555// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2556// <value>.to_string() }` four-line struct-literal against a local
2557// `(prior_versao(), <axis-value>)` pair threaded from
2558// [`UpgradeFromEntry::prior_versao`] (or, at the
2559// [`validate_upgrade_from_against_versao`] site, directly from the
2560// caller-supplied `versao: &str` argument) — the exact "same block
2561// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2562// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2563// / `upgrade_script_only_ctors!` families closed on the sibling
2564// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2565// axis-field discriminators are the only things that vary between them;
2566// the rest of the struct-literal is a byte-for-byte re-inline.
2567//
2568// The macro below generates one `#[must_use]` inherent constructor per
2569// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2570// every wire-up site collapses onto one dispatch:
2571// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2572// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2573// parameters accept `&str` literals and `&String` (via Deref coercion)
2574// so every existing wire-up threads through the ctor without a
2575// pre-conversion.
2576//
2577// Every future consumer that wants to construct one of these three
2578// variants outside the three in-crate `UpgradeFromEntry::validate` /
2579// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2580// gates (a deferred wasm-operator's `install_release/1` per-entry
2581// `:from`-parse / per-`:load-module` singularity / per-entry
2582// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2583// `feira validate --upgrade-from` per-caixa admission verb re-checking
2584// the three axes, a per-`Caixa` overlay resolver rejecting a
2585// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2586// invariant against a cluster-local snapshot) now reaches each variant
2587// through one call rather than re-inlining the four-line struct-literal
2588// in lockstep with the three in-crate wire-up sites.
2589macro_rules! upgrade_from_axis_ctors {
2590 ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2591 impl UpgradeError {
2592 $(
2593 #[doc = concat!(
2594 "Construct an [`UpgradeError::",
2595 stringify!($variant),
2596 "`] naming the offending `(:from <prior-versao>)` and ",
2597 "the offending `:", stringify!($axis), "` axis value. ",
2598 "Folds the uniform `Self::",
2599 stringify!($variant),
2600 " { from: from.to_string(), ",
2601 stringify!($axis),
2602 ": ",
2603 stringify!($axis),
2604 ".to_string() }` two-field struct-literal onto one ",
2605 "substrate primitive so every in-crate wire-up on ",
2606 "this variant reads through one dispatch rather than ",
2607 "the pre-lift four-line open-coded block. Both `from: ",
2608 "&str` and `",
2609 stringify!($axis),
2610 ": &str` parameters accept `&str` literals and ",
2611 "`&String` (via Deref coercion) so every existing ",
2612 "wire-up threads through the ctor without a pre-",
2613 "conversion."
2614 )]
2615 #[must_use]
2616 pub fn $ctor(from: &str, $axis: &str) -> Self {
2617 Self::$variant {
2618 from: from.to_string(),
2619 $axis: $axis.to_string(),
2620 }
2621 }
2622 )*
2623 }
2624 };
2625}
2626
2627upgrade_from_axis_ctors! {
2628 from_invalid => FromInvalid { reason },
2629 from_not_before_versao => FromNotBeforeVersao { versao },
2630 duplicate_load_module => DuplicateLoadModule { module },
2631}
2632
2633// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2634// entry.prior_versao().to_string() }` one-slot struct-literal inside
2635// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2636// one substrate primitive on the [`UpgradeError`] envelope, projecting
2637// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2638// on the substrate primitive. The `DuplicateFrom` variant is the last
2639// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2640// every peer envelope shape (`{ script: PathBuf }` one-slot via
2641// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2642// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2643// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2644// 8e67041) already reads through one substrate-primitive dispatch, so
2645// this fold closes the last one-off single-slot on the envelope.
2646//
2647// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2648// (b30edfe) on the paired [`WitContract`] projection — same
2649// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2650// through the substrate primitive's own scalar accessor rather than
2651// re-inlining the `.to_string()` at the call site. Extended here onto
2652// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2653// M2 companion of the M3 mesh-slot accessors (see
2654// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2655// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2656// 4a32abf, and the [`crate::WitContract::{source, destination,
2657// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2658// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2659//
2660// The one wire-up site this fold closes opens the identical
2661// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2662// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2663// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2664// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2665// names as a bug, on the same altitude the peer `contrato_self_loop`
2666// closed on the sibling `{ caixa: String, wit: String }` two-slot
2667// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2668// parameter accepts the borrowed entry verbatim so the wire-up site
2669// threads through the ctor without a pre-projection — the ctor body
2670// spells the paired `prior_versao().to_string()` projection once.
2671//
2672// Every future consumer that wants to construct this variant outside
2673// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2674// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2675// re-checker at hot-upgrade dispatch time rejecting a second entry
2676// with the same prior-versao tag, a future `feira validate --upgrade-
2677// from` per-caixa admission verb re-running the cross-entry duplicate
2678// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2679// supplied duplicate `(:from "<value>")` against a cluster-local
2680// snapshot — now reaches the variant through one call rather than
2681// re-inlining the three-line struct-literal in lockstep with the one
2682// in-crate wire-up site.
2683impl UpgradeError {
2684 /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2685 /// duplicate `(:from <prior-versao>)` entry, projecting through the
2686 /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2687 /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2688 /// from: entry.prior_versao().to_string() }` one-field struct-literal
2689 /// onto one substrate primitive so every wire-up on this variant
2690 /// reads through one dispatch, matching the sibling
2691 /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2692 /// substrate-primitive-projection ctor's shape on the peer
2693 /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2694 /// parameter accepts the borrowed entry verbatim so the paired
2695 /// `prior_versao().to_string()` projection is spelled once — inside
2696 /// the ctor body — rather than at every wire-up site.
2697 #[must_use]
2698 pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2699 Self::DuplicateFrom {
2700 from: entry.prior_versao().to_string(),
2701 }
2702 }
2703
2704 /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2705 /// offending `(:from <prior-versao>)` entry, the offending cleanup
2706 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2707 /// its `:module` target. Folds the uniform
2708 /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2709 /// module: module.to_string() }` three-field struct-literal onto one
2710 /// substrate primitive so every wire-up on this sole-variant
2711 /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2712 /// through one dispatch rather than the pre-lift seven-line
2713 /// open-coded block.
2714 ///
2715 /// The `from: &str` parameter accepts `&str` literals and `&String`
2716 /// via Deref coercion so the sole in-crate wire-up site threads
2717 /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2718 /// pre-conversion. The `kind: &'static str` parameter accepts the
2719 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2720 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2721 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2722 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2723 /// re-projection at the ctor path. The `module: &str` parameter
2724 /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
2725 /// via `.expect("is_cleanup() implies declared_module() is Some")`
2726 /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
2727 /// `Some` composition pin at
2728 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2729 /// makes the `.expect(…)` structurally infallible at build time.
2730 ///
2731 /// Peer of the sibling one-off standalone-ctor
2732 /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
2733 /// String }` envelope on the same `UpgradeError` envelope, and of
2734 /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
2735 /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
2736 /// variant standalone ctor on the peer `AplicacaoError` envelope.
2737 /// Closes the last unlifted `{ from: String, kind: &'static str,
2738 /// module: String }` three-slot open-coded struct-literal wire-up
2739 /// on the OTP-appup load-before-cleanup ordering axis, sibling of
2740 /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
2741 /// (41d08db, three variants on `{ from: String, <axis>: String }`)
2742 /// on the paired ordering / uniqueness / callback-declaration axes,
2743 /// and of the peer standalone [`UpgradeError::duplicate_from`]
2744 /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
2745 /// `:from` gate. Every future consumer that raises this refusal
2746 /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
2747 /// wasm-operator's `install_release/1` per-entry load-before-cleanup
2748 /// re-checker at hot-upgrade dispatch time, a future
2749 /// `feira validate --upgrade-from` per-caixa admission verb
2750 /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
2751 /// overlay resolver rejecting a cluster-local `:soft-purge` /
2752 /// `:purge` overlay lacking a preceding `:load-module` — reaches
2753 /// the variant through one call rather than re-inlining the
2754 /// seven-line struct-literal in lockstep with the sole in-crate
2755 /// wire-up site.
2756 #[must_use]
2757 pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
2758 Self::PurgeWithoutPriorLoad {
2759 from: from.to_string(),
2760 kind,
2761 module: module.to_string(),
2762 }
2763 }
2764
2765 /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
2766 /// offending `(:from <prior-versao>)` entry, the offending
2767 /// `(:state-change …)` `:script` path, and the prior cleanup
2768 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
2769 /// `:module` target. Folds the uniform
2770 /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
2771 /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
2772 /// prior_cleanup_module.to_string() }` four-field struct-literal
2773 /// onto one substrate primitive so every wire-up on this sole-
2774 /// variant migrate-after-cleanup ordering-refusal envelope reads
2775 /// through one dispatch rather than the pre-lift seven-line open-
2776 /// coded block. Closes the last unlifted `{ from: String, script:
2777 /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
2778 /// String }` four-slot open-coded struct-literal wire-up on the
2779 /// OTP-appup migrate-before-cleanup ordering axis, filling the
2780 /// missing four-slot rung on the `UpgradeError`-side ctor-family
2781 /// ladder alongside the sibling one-slot
2782 /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
2783 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2784 /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
2785 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
2786 /// families, and the one-slot [`upgrade_script_only_ctors!`]
2787 /// (7468ca9) family. Sole in-crate wire-up site is inside
2788 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
2789 /// migrate-family sticky-latch dispatch — the third of three
2790 /// within-entry cross-instruction OTP-appup ordering gates the
2791 /// module doc pins (`validate_state_change_ordering` on the load →
2792 /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
2793 /// `state_change_without_prior_load`; `validate_purge_ordering` on
2794 /// the load → cleanup boundary via `purge_without_prior_load`;
2795 /// `validate_state_change_before_cleanup` on the migrate → cleanup
2796 /// boundary via this ctor — now).
2797 ///
2798 /// The `from: &str` parameter accepts `&str` literals and `&String`
2799 /// via Deref coercion so the sole in-crate wire-up site threads
2800 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2801 /// without a pre-conversion. The `script: &std::path::Path`
2802 /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
2803 /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
2804 /// via Deref coercion) so the wire-up threads the sticky-latch
2805 /// script projection through the ctor without a pre-conversion; the
2806 /// uniform `script.to_path_buf()` one-field construction is spelled
2807 /// once — inside the ctor body — rather than at every wire-up site.
2808 /// The `prior_cleanup_kind: &'static str` parameter accepts the
2809 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2810 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2811 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2812 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2813 /// re-projection at the ctor path. The `prior_cleanup_module: &str`
2814 /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
2815 /// returns via `.expect("is_cleanup() implies declared_module() is
2816 /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
2817 /// is-`Some` composition pin at
2818 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2819 /// makes the `.expect(…)` structurally infallible at build time.
2820 ///
2821 /// Every future consumer that raises this refusal outside
2822 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
2823 /// deferred wasm-operator's `install_release/1` per-entry
2824 /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
2825 /// a future `feira validate --upgrade-from` per-caixa admission verb
2826 /// re-running the migrate-before-cleanup gate on demand, a
2827 /// per-`Caixa` overlay resolver rejecting a cluster-local
2828 /// `:state-change` overlay authored after a `:soft-purge` /
2829 /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
2830 /// webhook re-checking a per-`:upgrade-from`-patched candidate
2831 /// before the migrate-before-cleanup gate re-fires — reaches the
2832 /// variant through one call rather than re-inlining the seven-line
2833 /// struct-literal in lockstep with the sole in-crate wire-up site.
2834 #[must_use]
2835 pub fn state_change_after_cleanup(
2836 from: &str,
2837 script: &std::path::Path,
2838 prior_cleanup_kind: &'static str,
2839 prior_cleanup_module: &str,
2840 ) -> Self {
2841 Self::StateChangeAfterCleanup {
2842 from: from.to_string(),
2843 script: script.to_path_buf(),
2844 prior_cleanup_kind,
2845 prior_cleanup_module: prior_cleanup_module.to_string(),
2846 }
2847 }
2848
2849 /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
2850 /// offending `(:from <prior-versao>)` entry, the colliding `:module`
2851 /// target, and the ordered pair of colliding cleanup `:kind` lisp-
2852 /// forms (`:soft-purge` / `:purge`). Folds the uniform
2853 /// `Self::DuplicateCleanup { from: from.to_string(), module:
2854 /// module.to_string(), kinds }` three-field struct-literal onto one
2855 /// substrate primitive so every wire-up on this sole-variant within-
2856 /// entry per-module cleanup-singularity refusal envelope reads
2857 /// through one dispatch rather than the pre-lift five-line open-coded
2858 /// block. Closes the last unlifted `{ from: String, module: String,
2859 /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
2860 /// wire-up on the OTP-appup per-module cleanup-singularity axis,
2861 /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
2862 /// family ladder alongside the sibling three-slot
2863 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2864 /// ctor on the paired within-entry load → cleanup ordering axis, the
2865 /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
2866 /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
2867 /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
2868 /// standalone ctor on the migrate → cleanup boundary, the two-slot
2869 /// [`upgrade_from_axis_ctors!`] (41d08db) /
2870 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
2871 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
2872 /// Sole in-crate wire-up site is inside
2873 /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
2874 /// cleanup-family dedup arm.
2875 ///
2876 /// The `from: &str` parameter accepts `&str` literals and `&String`
2877 /// via Deref coercion so the sole in-crate wire-up threads
2878 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2879 /// without a pre-conversion. The `module: &str` parameter takes the
2880 /// `&str` [`UpgradeInstruction::declared_module`] returns via
2881 /// `.expect("is_cleanup() implies declared_module() is Some")` at the
2882 /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
2883 /// composition pin at
2884 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2885 /// makes the `.expect(…)` structurally infallible at build time. The
2886 /// `kinds: Vec<&'static str>` parameter takes the ordered pair
2887 /// `vec![prior_kind, kind]` built at the caller from the two
2888 /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
2889 /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2890 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
2891 /// primitive `&'static str` projection the paired three-slot
2892 /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
2893 /// sibling load → cleanup ordering axis.
2894 ///
2895 /// Every future consumer that raises this refusal outside
2896 /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
2897 /// wasm-operator's `install_release/1` per-entry per-module
2898 /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
2899 /// future `feira validate --upgrade-from` per-caixa admission verb
2900 /// re-running the singularity pass on demand, a per-`Caixa` overlay
2901 /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
2902 /// overlay that collides with a base-entry cleanup on the same
2903 /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
2904 /// re-checking a per-`:upgrade-from`-patched candidate before the
2905 /// singularity gate re-fires — reaches the variant through one call
2906 /// rather than re-inlining the five-line struct-literal in lockstep
2907 /// with the sole in-crate wire-up site.
2908 #[must_use]
2909 pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
2910 Self::DuplicateCleanup {
2911 from: from.to_string(),
2912 module: module.to_string(),
2913 kinds,
2914 }
2915 }
2916
2917 /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
2918 /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
2919 /// instruction count, and the ordered list of non-`:restart`
2920 /// instruction lisp-forms the entry mixed with the terminal fallback.
2921 /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
2922 /// restart_count, other_kinds }` three-field struct-literal onto one
2923 /// substrate primitive so every wire-up on this sole-variant within-
2924 /// entry `(:restart)`-exclusivity refusal envelope reads through one
2925 /// dispatch rather than the pre-lift five-line open-coded block. Closes
2926 /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
2927 /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
2928 /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
2929 /// the last-remaining open-coded emission site the sibling
2930 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
2931 /// the natural next lift on the `UpgradeError` envelope. Fills a peer
2932 /// three-slot rung on the `UpgradeError`-side ctor-family ladder
2933 /// alongside the sibling three-slot
2934 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2935 /// ctor on the paired within-entry load → cleanup ordering axis and
2936 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
2937 /// the per-module cleanup-singularity axis, the one-slot
2938 /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
2939 /// cross-entry duplicate-`:from` gate, the four-slot
2940 /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
2941 /// ctor on the migrate → cleanup boundary, the two-slot
2942 /// [`upgrade_from_axis_ctors!`] (41d08db) /
2943 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
2944 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
2945 /// Sole in-crate wire-up site is inside
2946 /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
2947 /// arm.
2948 ///
2949 /// The `from: &str` parameter accepts `&str` literals and `&String`
2950 /// via Deref coercion so the sole in-crate wire-up threads
2951 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2952 /// without a pre-conversion. The `restart_count: usize` parameter
2953 /// takes the observed `(:restart)` occurrence count built at the
2954 /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
2955 /// — the same `IsVariant`-derived arm-discriminator dispatch the
2956 /// paired `other_kinds` projection routes through — so the diagnostic
2957 /// surfaces the duplication mode unambiguously even when `other_kinds`
2958 /// is empty (the `((:restart) (:restart))` shape the sibling
2959 /// `validate_rejects_restart_duplicated` test pins with
2960 /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
2961 /// Vec<&'static str>` parameter takes the ordered list of non-
2962 /// `:restart` instruction lisp-forms built at the caller from
2963 /// `instructions.iter().filter(|i| !i.is_restart()).map(
2964 /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
2965 /// primitive `&'static str` projection the peer three-slot
2966 /// [`UpgradeError::purge_without_prior_load`] /
2967 /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
2968 /// within-entry cleanup axes.
2969 ///
2970 /// Every future consumer that raises this refusal outside
2971 /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
2972 /// wasm-operator's `install_release/1` per-entry `(:restart)`-
2973 /// exclusivity re-checker at hot-upgrade dispatch time, a future
2974 /// `feira validate --upgrade-from` per-caixa admission verb re-running
2975 /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
2976 /// rejecting a cluster-local `(:restart)` overlay that mixes with a
2977 /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
2978 /// CR admission webhook re-checking a per-`:upgrade-from`-patched
2979 /// candidate before the exclusivity gate re-fires — reaches the
2980 /// variant through one call rather than re-inlining the five-line
2981 /// struct-literal in lockstep with the sole in-crate wire-up site.
2982 #[must_use]
2983 pub fn restart_not_exclusive(
2984 from: &str,
2985 restart_count: usize,
2986 other_kinds: Vec<&'static str>,
2987 ) -> Self {
2988 Self::RestartNotExclusive {
2989 from: from.to_string(),
2990 restart_count,
2991 other_kinds,
2992 }
2993 }
2994
2995 /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
2996 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
2997 /// `:purge`), the malformed `:module` value, and the parser-shaped
2998 /// `reason` from
2999 /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3000 /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3001 /// three-field struct-literal onto one substrate primitive so every
3002 /// wire-up on this variant reads through one dispatch rather than the
3003 /// pre-lift open-coded closure block inside [`validate_module`]'s
3004 /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3005 ///
3006 /// The `kind: &'static str` parameter accepts the lisp-form
3007 /// [`UpgradeInstruction::lisp_form`] returns for the three
3008 /// [`UpgradeInstruction::declared_module`]-bearing arms —
3009 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3010 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3011 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3012 /// without a per-arm re-projection at the ctor path. The `module: &str`
3013 /// parameter threads the offending author-supplied `:module` value
3014 /// verbatim from [`UpgradeInstruction::declared_module`]. The
3015 /// `reason: impl Into<String>` bound accepts both `&str` literals and
3016 /// the `String` [`crate::render::is_dns_1123_label`] returns via
3017 /// `.into()`, matching the peer
3018 /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3019 /// [`crate::SupervisorError::child_caixa_invalid`] /
3020 /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3021 /// three-slot invalid-arm ctor discipline on the sibling
3022 /// DNS-1123-label per-envelope shape.
3023 ///
3024 /// Peer of the sibling standalone-ctor
3025 /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3026 /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3027 /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3028 /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3029 /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3030 /// [`validate_module`]'s cascade now reads through one substrate
3031 /// primitive on the invalid-arm rather than an open-coded four-line
3032 /// struct-literal in lockstep with the sole in-crate wire-up site.
3033 ///
3034 /// Every future consumer that raises this refusal outside
3035 /// [`validate_module`] — a deferred wasm-operator's
3036 /// `install_release/1` per-instruction `:module` re-validator at
3037 /// hot-upgrade dispatch time re-running the same DNS-1123-label
3038 /// floor against a candidate module reference, a future
3039 /// `feira validate --upgrade-from` per-caixa admission verb
3040 /// re-running the module-shape gate on demand, an M4
3041 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3042 /// per-`:upgrade-from`-patched candidate before the module-shape
3043 /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3044 /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3045 /// overlay against a cluster-local snapshot — now reaches this
3046 /// variant through one call rather than re-inlining the four-line
3047 /// struct-literal in lockstep with the [`validate_module`]
3048 /// closure-form wire-up.
3049 #[must_use]
3050 pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3051 Self::ModuleInvalid {
3052 kind,
3053 module: module.to_string(),
3054 reason: reason.into(),
3055 }
3056 }
3057
3058 /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3059 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3060 /// `:purge`) at which the appup module reference is the empty
3061 /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3062 /// struct-literal onto one substrate primitive so the sole in-crate
3063 /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3064 /// at [`validate_module`] on this variant reads through one dispatch
3065 /// rather than the pre-lift open-coded block. The `kind` label
3066 /// threads verbatim from the caller-side
3067 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3068 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3069 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3070 /// roster the wire-up feeds through [`validate_module`]'s
3071 /// `kind: &'static str` parameter.
3072 ///
3073 /// Sibling of the paired three-slot [`Self::module_invalid`]
3074 /// (3d0d64a) substrate primitive on the same
3075 /// [`crate::render::require_valid_dns_1123_label`] two-closure
3076 /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3077 /// now both reach the `UpgradeError` envelope through one substrate
3078 /// primitive per typed variant, closing the pair on the OTP-appup
3079 /// per-instruction `:module` caixa-reference axis. Same shape
3080 /// discipline as the peer
3081 /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3082 /// one-slot `{ slot: &'static str }` sibling that closed the peer
3083 /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3084 /// cascade at the `:contratos <slot>` per-edge axis
3085 /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3086 /// "one substrate primitive per typed arm on both sides of a
3087 /// `require_valid_dns_1123_label` two-closure cascade, projecting
3088 /// through the caller-supplied axis-tag" discipline now extended
3089 /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3090 /// of the pair the M3 (`:contratos <slot>`) side already carries.
3091 ///
3092 /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3093 /// :instructions <kind>` tag comes from the
3094 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3095 /// carrying program-lifetime storage, matching the enum-field type
3096 /// and the [`validate_module`] wire-up's per-arm dispatch. A
3097 /// runtime-borrowed `&str` would silently downgrade the label
3098 /// lifetime and let a caller stash a non-`'static` borrow into the
3099 /// returned error. `#[must_use]` fires a compile warning at any
3100 /// wire-up that mistakenly discards the constructed error rather
3101 /// than routing it through `return Err(…)` / `.map_err(…)` / a
3102 /// closure return. `pub const fn` matches the peer per-envelope
3103 /// one-slot `Copy`-scalar ctor family discipline
3104 /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3105 /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3106 /// ctor is usable in `const` position at every wire-up site.
3107 ///
3108 /// Every future consumer that constructs `ModuleEmpty` outside
3109 /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3110 /// closure — a deferred wasm-operator's `install_release/1`
3111 /// per-instruction `:module` re-validator at hot-upgrade dispatch
3112 /// time re-running the same empty-arm floor against a candidate
3113 /// module reference, a future `feira validate --upgrade-from`
3114 /// per-caixa admission verb re-running the empty-module gate on
3115 /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3116 /// re-checking a per-`:upgrade-from`-patched candidate before the
3117 /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3118 /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3119 /// overlay against a cluster-local snapshot — now reaches this
3120 /// variant through one call rather than re-inlining the one-line
3121 /// struct-literal in lockstep with the sole in-crate wire-up site.
3122 #[must_use]
3123 pub const fn module_empty(kind: &'static str) -> Self {
3124 Self::ModuleEmpty { kind }
3125 }
3126}
3127
3128#[cfg(test)]
3129mod tests {
3130 use std::path::Path;
3131
3132 use super::*;
3133
3134 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3135 UpgradeFromEntry {
3136 from: from.into(),
3137 instructions: instrs,
3138 }
3139 }
3140
3141 #[test]
3142 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3143 // Fail-before-pass-after pin on
3144 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3145 // posture. The accessor projects the per-`:upgrade-from :from`
3146 // [`String`] storage through the `pub const fn`
3147 // [`String::as_str`] (const-stable since Rust 1.87, well within
3148 // the workspace MSRV) — any future accidental downgrade to
3149 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3150 // build time with E0015 (`cannot call non-const method`),
3151 // strictly stronger than a runtime `assert!`. Sibling of the
3152 // peer M2/M3 slot family pins on the sibling `const`-eval-
3153 // surface passes ([`crate::Caixa::nome`] /
3154 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3155 // [`crate::aplicacao::Membro::nome`] /
3156 // [`crate::aplicacao::Membro::versao_requirement`],
3157 // [`crate::aplicacao::Entrada::hostname`] /
3158 // [`crate::aplicacao::Entrada::destination`],
3159 // [`crate::supervisor::ChildSpec::nome`] /
3160 // [`crate::supervisor::ChildSpec::versao_requirement`],
3161 // [`crate::dep::Dep::nome`] /
3162 // [`crate::dep::Dep::versao_requirement`], and the
3163 // per-`:contratos`
3164 // [`crate::aplicacao::WitContract::source`] /
3165 // [`crate::aplicacao::WitContract::destination`] /
3166 // [`crate::aplicacao::WitContract::world_ref`] trio the
3167 // sibling pin at 279823b already anchors).
3168 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3169 e.prior_versao()
3170 }
3171 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3172 let e = entry(from, vec![]);
3173 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3174 assert_eq!(e.prior_versao(), from);
3175 }
3176 }
3177
3178 #[test]
3179 fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3180 // Fail-before-pass-after pin on
3181 // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3182 // posture. The accessor destructures the per-`:upgrade-from
3183 // :instructions` `Vec<UpgradeInstruction>` storage through the
3184 // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3185 // 1.66, well within the workspace MSRV) — any future
3186 // accidental downgrade to non-`const` fails
3187 // `instructions_via_const_fn` at caixa-core build time with
3188 // E0015 (`cannot call non-const method`), strictly stronger
3189 // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3190 // slot `Vec → &[T]` slice-return accessor family pin
3191 // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3192 // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3193 // per-`:membros` / per-`:contratos` slice-return axes, and of
3194 // the peer M2 supervisor-tree axis pin
3195 // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3196 // on the per-`:children` slice-return axis.
3197 const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3198 e.instructions()
3199 }
3200 // Sweep both the empty-instructions arm (author-declared
3201 // per-`:from` entry with no migration steps — the degenerate
3202 // shape the appup `restart`-only path folds through) and the
3203 // populated-instructions arm (the canonical OTP-appup shape
3204 // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3205 // chain) so the accessor carries a const-dispatch pin on
3206 // both arms.
3207 let e_empty = entry("0.1.0", vec![]);
3208 assert!(instructions_via_const_fn(&e_empty).is_empty());
3209 assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3210 let e_full = entry(
3211 "0.1.0",
3212 vec![
3213 UpgradeInstruction::LoadModule {
3214 module: "hello-rio".into(),
3215 },
3216 UpgradeInstruction::StateChange {
3217 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3218 },
3219 UpgradeInstruction::SoftPurge {
3220 module: "hello-rio-old".into(),
3221 },
3222 ],
3223 );
3224 assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3225 assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3226 }
3227
3228 #[test]
3229 fn round_trip_load_module() {
3230 let i = UpgradeInstruction::LoadModule {
3231 module: "hello-rio".into(),
3232 };
3233 let json = serde_json::to_string(&i).unwrap();
3234 assert!(json.contains("\"kind\":\"load-module\""));
3235 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3236 assert_eq!(i, back);
3237 }
3238
3239 #[test]
3240 fn round_trip_all_variants() {
3241 let cases = vec![
3242 UpgradeInstruction::LoadModule { module: "x".into() },
3243 UpgradeInstruction::StateChange {
3244 script: PathBuf::from("lib/migrations.lisp"),
3245 },
3246 UpgradeInstruction::SoftPurge {
3247 module: "x-old".into(),
3248 },
3249 UpgradeInstruction::Purge {
3250 module: "x-old".into(),
3251 },
3252 UpgradeInstruction::Restart,
3253 ];
3254 for c in cases {
3255 let json = serde_json::to_string(&c).unwrap();
3256 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3257 assert_eq!(c, back);
3258 }
3259 }
3260
3261 #[test]
3262 fn validate_accepts_well_formed() {
3263 let e = entry(
3264 "0.1.0",
3265 vec![
3266 UpgradeInstruction::LoadModule {
3267 module: "hello-rio".into(),
3268 },
3269 UpgradeInstruction::StateChange {
3270 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3271 },
3272 UpgradeInstruction::SoftPurge {
3273 module: "hello-rio-old".into(),
3274 },
3275 ],
3276 );
3277 e.validate().unwrap();
3278 }
3279
3280 #[test]
3281 fn validate_rejects_non_semver_from() {
3282 let e = entry("not-a-semver", vec![]);
3283 let err = e.validate().unwrap_err();
3284 assert!(
3285 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3286 );
3287 }
3288
3289 #[test]
3290 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3291 // Diagnostic-shape pin: the error names the offending
3292 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3293 // reason, so a `feira lint` run can render the diagnostic
3294 // without re-parsing — the author can grep their caixa.lisp for
3295 // `:from "<value>"` and fix it in one edit. Mirrors the peer
3296 // `versao_invalid_diagnostic_carries_offending_versao` pin on
3297 // the sibling SemVer-2 axis (the top-level `:versao`), the
3298 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3299 // pin on `:membros :versao`, and the peer
3300 // `deps_invalid_diagnostic_carries_offending_value` pin on
3301 // `:deps :versao` — every SemVer-2-parsing slot's invalid
3302 // diagnostic is now structurally equivalent.
3303 let e = entry("v0.1.0", vec![]);
3304 let err = e.validate().unwrap_err();
3305 let UpgradeError::FromInvalid { from, reason } = err else {
3306 panic!("expected FromInvalid variant, got {err:?}");
3307 };
3308 assert_eq!(from, "v0.1.0");
3309 assert!(
3310 !reason.is_empty(),
3311 "FromInvalid `reason` must carry the parser's wording verbatim"
3312 );
3313 }
3314
3315 #[test]
3316 fn prior_versao_returns_from_byte_equal_across_permutations() {
3317 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3318 // accessor across the SemVer-2 shape lattice every consumer
3319 // reaches through it — the numeric-triad canonical shape, a
3320 // pre-release build with a dotted identifier chain, a full-
3321 // metadata build, a large-magnitude triad, and the empty
3322 // string (which reaches this accessor unchanged before any
3323 // validate gate rejects it). Sibling to the peer
3324 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3325 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3326 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3327 // family — extended here onto the first M2 slot scalar-value
3328 // axis. Any silent detour on the accessor (a `.to_string()`
3329 // + retained ownership shape, a canonicalization pass, a
3330 // trim-whitespace on the return path) surfaces as a byte-
3331 // inequality failure here rather than as a downstream error-
3332 // diagnostic drift.
3333 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3334 for from in cases {
3335 let e = entry(from, vec![]);
3336 assert_eq!(
3337 e.prior_versao(),
3338 from,
3339 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3340 );
3341 assert_eq!(
3342 e.prior_versao().len(),
3343 from.len(),
3344 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3345 );
3346 }
3347 }
3348
3349 #[test]
3350 fn prior_versao_borrows_from_from_storage() {
3351 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3352 // a borrow into `self.from`'s heap allocation, never a fresh
3353 // owned copy. Guards against a future silent detour where
3354 // the accessor materializes a `Cow<'_, str>` / `String` /
3355 // `Rc<str>` intermediate — the return path stays zero-cost
3356 // even under a refactor that reshapes the storage. Sibling
3357 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3358 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3359 // (4a32abf) pins — extended onto the M2 slot's first
3360 // scalar-value axis.
3361 let e = entry("0.1.0", vec![]);
3362 assert!(
3363 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3364 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3365 );
3366 }
3367
3368 #[test]
3369 fn validate_parses_prior_versao_through_lifted_accessor() {
3370 // Coherence pin between the accessor and the SemVer-2 parse
3371 // gate: every `:upgrade-from :from` value the validator
3372 // accepts (resp. rejects) must be identical to what
3373 // `Version::parse(entry.prior_versao())` accepts (resp.
3374 // rejects) — the two must remain in lockstep across the
3375 // shape lattice so `validate_upgrade_from`'s
3376 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3377 // assertion holds by construction. If a future extension of
3378 // `prior_versao` reshapes the return (a canonicalization
3379 // pass, a leading/trailing whitespace trim, an empty-to-
3380 // "0.0.0" fallback) it would either loosen the validator
3381 // (silently accepting shapes the parser rejects) or
3382 // tighten the parser's re-parse (silently panicking on
3383 // shapes the validator accepts) — this pin catches either
3384 // shift at caixa-core build time.
3385 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3386 for from in accepted {
3387 let e = entry(from, vec![]);
3388 e.validate().unwrap_or_else(|err| {
3389 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3390 });
3391 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3392 panic!(
3393 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3394 got {err:?}",
3395 );
3396 });
3397 }
3398 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3399 for from in rejected {
3400 let e = entry(from, vec![]);
3401 assert!(
3402 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3403 "validate() must reject {from:?} that Version::parse rejects",
3404 );
3405 assert!(
3406 semver::Version::parse(e.prior_versao()).is_err(),
3407 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3408 );
3409 }
3410 }
3411
3412 #[test]
3413 fn validate_rejects_empty_module() {
3414 // Per-arm coverage: every Module-bearing variant surfaces the
3415 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3416 // so the author can grep their caixa.lisp for `(:load-module
3417 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3418 // edit — same self-locating shape `BehaviorError::EmptyPath`
3419 // (b0c8389) carries on the peer M2 typed slot.
3420 let cases: &[(UpgradeInstruction, &'static str)] = &[
3421 (
3422 UpgradeInstruction::LoadModule {
3423 module: String::new(),
3424 },
3425 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3426 ),
3427 (
3428 UpgradeInstruction::SoftPurge {
3429 module: String::new(),
3430 },
3431 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3432 ),
3433 (
3434 UpgradeInstruction::Purge {
3435 module: String::new(),
3436 },
3437 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3438 ),
3439 ];
3440 for (instr, expected_kind) in cases {
3441 assert_eq!(
3442 instr.validate().unwrap_err(),
3443 UpgradeError::ModuleEmpty {
3444 kind: expected_kind
3445 },
3446 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3447 );
3448 }
3449 }
3450
3451 #[test]
3452 fn validate_rejects_non_dns_1123_module() {
3453 // Every appup `:module` reference is a caixa name (the
3454 // wasm-engine resolves it through the same ComputeUnit
3455 // registry the operator manages), so the value-shape gate
3456 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3457 // the canonical authoring footguns — uppercase letters, `_`
3458 // separator, embedded `.`, leading/trailing `-`, an embedded
3459 // whitespace byte, the >63-byte UUID-shaped slug — across
3460 // every Module-bearing variant; each must surface as
3461 // `ModuleInvalid { kind, module, reason }` carrying the
3462 // offending value verbatim and the parser-shaped reason.
3463 type Build = fn(String) -> UpgradeInstruction;
3464 let footguns: &[&str] = &[
3465 "Hello-Rio",
3466 "hello_rio",
3467 "hello.rio",
3468 "-hello",
3469 "hello-",
3470 "hello rio",
3471 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3472 ];
3473 let variants: &[(Build, &'static str)] = &[
3474 (
3475 |m| UpgradeInstruction::LoadModule { module: m },
3476 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3477 ),
3478 (
3479 |m| UpgradeInstruction::SoftPurge { module: m },
3480 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3481 ),
3482 (
3483 |m| UpgradeInstruction::Purge { module: m },
3484 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3485 ),
3486 ];
3487 for (build, expected_kind) in variants {
3488 for module in footguns {
3489 let instr = build((*module).to_string());
3490 let err = instr.validate().unwrap_err();
3491 match err {
3492 UpgradeError::ModuleInvalid {
3493 kind,
3494 module: m,
3495 reason,
3496 } => {
3497 assert_eq!(
3498 kind, *expected_kind,
3499 ":module footgun on {instr:?} must tag the lisp-form"
3500 );
3501 assert_eq!(
3502 m, *module,
3503 "ModuleInvalid must carry the offending value verbatim"
3504 );
3505 assert!(
3506 !reason.is_empty(),
3507 "ModuleInvalid reason must name the specific violation \
3508 (the predicate's parser-shaped wording from \
3509 `is_dns_1123_label`), got empty"
3510 );
3511 }
3512 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3513 }
3514 }
3515 }
3516 }
3517
3518 #[test]
3519 fn validate_accepts_canonical_module_names() {
3520 // Positive control: every documented authoring shape — bare
3521 // identifier, with hyphens, with digits, the
3522 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3523 // references — passes the gate. Drift here = a future
3524 // tighten that rejects any of these surfaces as a
3525 // test-failure at the predicate boundary, not piecemeal
3526 // across per-instruction call sites.
3527 let canonical: &[&str] = &[
3528 "hello-rio",
3529 "hello-rio-old",
3530 "cache",
3531 "cache-v2",
3532 "x",
3533 "a1",
3534 "0a",
3535 "abc-123-def",
3536 ];
3537 for module in canonical {
3538 UpgradeInstruction::LoadModule {
3539 module: (*module).to_string(),
3540 }
3541 .validate()
3542 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3543 UpgradeInstruction::SoftPurge {
3544 module: (*module).to_string(),
3545 }
3546 .validate()
3547 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3548 UpgradeInstruction::Purge {
3549 module: (*module).to_string(),
3550 }
3551 .validate()
3552 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3553 }
3554 }
3555
3556 #[test]
3557 fn validate_empty_takes_precedence_over_invalid() {
3558 // Empty input is rejected via the narrower `ModuleEmpty`
3559 // diagnostic before the DNS-1123 predicate is consulted, so
3560 // a future tighten that adds another stage between the two
3561 // doesn't accidentally reorder the diagnostic precedence.
3562 // Mirrors the empty-first cascade on every peer DNS-1123
3563 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3564 // `SupervisorSpec::validate`'s child-name arm).
3565 let err = UpgradeInstruction::LoadModule {
3566 module: String::new(),
3567 }
3568 .validate()
3569 .unwrap_err();
3570 assert_eq!(
3571 err,
3572 UpgradeError::ModuleEmpty {
3573 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3574 }
3575 );
3576 }
3577
3578 #[test]
3579 fn validate_rejects_empty_script() {
3580 let i = UpgradeInstruction::StateChange {
3581 script: PathBuf::new(),
3582 };
3583 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3584 }
3585
3586 #[test]
3587 fn validate_rejects_absolute_script() {
3588 let i = UpgradeInstruction::StateChange {
3589 script: PathBuf::from("/etc/migrations.lisp"),
3590 };
3591 assert!(matches!(
3592 i.validate().unwrap_err(),
3593 UpgradeError::AbsoluteScript { .. }
3594 ));
3595 }
3596
3597 #[test]
3598 fn validate_rejects_parent_escape_script() {
3599 let i = UpgradeInstruction::StateChange {
3600 script: PathBuf::from("../sibling/migrations.lisp"),
3601 };
3602 assert!(matches!(
3603 i.validate().unwrap_err(),
3604 UpgradeError::ParentEscapeScript { .. }
3605 ));
3606 // mid-path `..` is also caught
3607 let i2 = UpgradeInstruction::StateChange {
3608 script: PathBuf::from("lib/../../escaped.lisp"),
3609 };
3610 assert!(matches!(
3611 i2.validate().unwrap_err(),
3612 UpgradeError::ParentEscapeScript { .. }
3613 ));
3614 }
3615
3616 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3617 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3618 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3619 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3620 // consumer; the file-type contract is identical, so the per-axis
3621 // test grid is mirrored leg-for-leg.
3622
3623 #[test]
3624 fn validate_rejects_no_extension_script() {
3625 // Fail-before-pass-after: the canonical "I declared the
3626 // migration script but forgot the `.lisp` extension"
3627 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3628 // The wasm-engine's `tatara_lisp::read` consumer needs a
3629 // file-type contract beyond the structural-shape gate; a
3630 // no-extension path past `is_sandboxed_relative_path` would
3631 // surface a parser-shaped diagnostic at hot-upgrade migration
3632 // time far from the source caixa.lisp.
3633 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3634 let i = UpgradeInstruction::StateChange {
3635 script: PathBuf::from(relpath),
3636 };
3637 let err = i.validate().unwrap_err();
3638 assert!(
3639 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3640 if s == Path::new(relpath)),
3641 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3642 carrying the offending path verbatim, got {err:?}"
3643 );
3644 }
3645 }
3646
3647 #[test]
3648 fn validate_rejects_non_lisp_extension_script() {
3649 // Wrong-extension sweep across common authoring footguns: the
3650 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3651 // drag in from the workspace tree, the `.rs` shape that an
3652 // IDE auto-complete might propose, the `.lisp.bak` shape an
3653 // editor might leave behind, and the `.lispx` near-miss that
3654 // a typo would produce. Each must surface as
3655 // `NonLispExtensionScript` carrying the offending path
3656 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3657 // rejects all of these at hot-upgrade migration time, and
3658 // the gate lifts that contract to validate time. Mirrors the
3659 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3660 // the `:behavior :on-*` axis leg-for-leg — same downstream
3661 // consumer, same accepted set, same per-axis test grid.
3662 let footguns: &[&str] = &[
3663 "lib/migrations.rs",
3664 "lib/migrations.txt",
3665 "lib/migrations.md",
3666 "lib/migrations.json",
3667 "lib/migrations.yaml",
3668 "lib/migrations.toml",
3669 "lib/migrations.lisp.bak",
3670 "lib/migrations.lispx",
3671 "lib/migrations.lis",
3672 ];
3673 for relpath in footguns {
3674 let i = UpgradeInstruction::StateChange {
3675 script: PathBuf::from(relpath),
3676 };
3677 let err = i.validate().unwrap_err();
3678 assert!(
3679 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3680 if s == Path::new(relpath)),
3681 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3682 carrying the offending path verbatim, got {err:?}"
3683 );
3684 }
3685 }
3686
3687 #[test]
3688 fn validate_rejects_uppercase_lisp_extension_script() {
3689 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3690 // case-folded shapes a case-insensitive volume's existence
3691 // check would match the on-disk file — but the
3692 // canonical-form codec emits lowercase `.lisp` verbatim, so
3693 // a case-folded shape mismatches the round-trip-stable
3694 // canonical form (THEORY.md §V.2.7 render-determinism).
3695 // Same case-sensitive discipline the byte-size / duration
3696 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3697 // and every other shape-gate predicate in `render.rs` (label
3698 // / scheme / unit boundaries). Mirrors the peer
3699 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3700 for relpath in [
3701 "lib/migrations.LISP",
3702 "lib/migrations.Lisp",
3703 "lib/migrations.LiSp",
3704 "lib/migrations.lISP",
3705 ] {
3706 let i = UpgradeInstruction::StateChange {
3707 script: PathBuf::from(relpath),
3708 };
3709 let err = i.validate().unwrap_err();
3710 assert!(
3711 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3712 if s == Path::new(relpath)),
3713 "case-folded `.lisp` extension {relpath:?} must surface as \
3714 NonLispExtensionScript (strict lowercase, canonical-form \
3715 round-trip pin), got {err:?}"
3716 );
3717 }
3718 }
3719
3720 #[test]
3721 fn validate_accepts_canonical_lisp_extension_scripts() {
3722 // Positive-control sweep across every canonical in-tree
3723 // authoring shape: bare filename, standard `lib/`
3724 // subdirectory, deeply-nested migrations subdirectory,
3725 // explicit current-dir-relative prefix, mid-path `./`
3726 // segment, multi-dot stem (the version-suffix shape
3727 // `lib/migrations/v.0.1.lisp` an author might use to encode
3728 // the migration's `:from` version into the filename). Drift
3729 // here = a future tightening that rejects any of these
3730 // surfaces as a test-failure at the per-axis validator
3731 // boundary, not piecemeal across renderer / layout-checker
3732 // call sites. Mirrors the peer `BehaviorSpec` positive-set
3733 // sweep (c97815a).
3734 let canonical: &[&str] = &[
3735 "lib/migrations.lisp",
3736 "lib/migrations/v01-to-v02.lisp",
3737 "migrations.lisp",
3738 "a.lisp",
3739 "./lib/migrations.lisp",
3740 "lib/./migrations.lisp",
3741 "lib/migrations/v.0.1.lisp",
3742 ];
3743 for relpath in canonical {
3744 UpgradeInstruction::StateChange {
3745 script: PathBuf::from(relpath),
3746 }
3747 .validate()
3748 .unwrap_or_else(|e| {
3749 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3750 });
3751 }
3752 }
3753
3754 #[test]
3755 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3756 // Cross-arm precedence pin: a script that is *both*
3757 // sandbox-escaping (Empty / Absolute / ParentEscape) and
3758 // non-`.lisp` must surface the more-fundamental
3759 // sandbox-shape diagnostic first — the canonical fix
3760 // collapses both into "pin a relative `.lisp` path under the
3761 // caixa root", and the `.lisp` remediation would be
3762 // misleading when the offending path can never resolve under
3763 // the caixa root anyway. Mirrors the peer
3764 // `BehaviorError` cross-arm precedence (c97815a) and the
3765 // sibling `LimitsError`
3766 // (`MemoryZero` → `MemoryBelowWasm32Page` →
3767 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3768 // smallest-scope-arm-fires-last posture.
3769 let i_empty = UpgradeInstruction::StateChange {
3770 script: PathBuf::new(),
3771 };
3772 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3773 let i_abs = UpgradeInstruction::StateChange {
3774 script: PathBuf::from("/etc/migrations.txt"),
3775 };
3776 assert!(
3777 matches!(
3778 i_abs.validate().unwrap_err(),
3779 UpgradeError::AbsoluteScript { .. }
3780 ),
3781 "absolute + non-`.lisp` must surface AbsoluteScript first"
3782 );
3783 let i_esc = UpgradeInstruction::StateChange {
3784 script: PathBuf::from("../sibling/migrations.rs"),
3785 };
3786 assert!(
3787 matches!(
3788 i_esc.validate().unwrap_err(),
3789 UpgradeError::ParentEscapeScript { .. }
3790 ),
3791 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3792 );
3793 }
3794
3795 #[test]
3796 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3797 // Diagnostic-shape pin: the surfaced error message names the
3798 // offending path verbatim (so the author can grep their
3799 // caixa.lisp for the literal value), the `.lisp` extension
3800 // is named in the remediation, and the downstream consumer
3801 // (`tatara_lisp::read` at hot-upgrade migration time) is
3802 // named so the author can trace the contract back to its
3803 // source. Same self-locating shape every per-axis variant
3804 // carries (`BehaviorError::NonLispExtension`, c97815a;
3805 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3806 let bad = PathBuf::from("lib/migrations.txt");
3807 let err = UpgradeInstruction::StateChange {
3808 script: bad.clone(),
3809 }
3810 .validate()
3811 .unwrap_err();
3812 let msg = err.to_string();
3813 assert!(
3814 msg.contains("lib/migrations.txt"),
3815 "diagnostic must name the offending path verbatim, got {msg:?}"
3816 );
3817 assert!(
3818 msg.contains(".lisp"),
3819 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
3820 );
3821 assert!(
3822 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
3823 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
3824 );
3825 match err {
3826 UpgradeError::NonLispExtensionScript { script } => {
3827 assert_eq!(
3828 script, bad,
3829 "variant must carry the offending path verbatim"
3830 );
3831 }
3832 other => panic!("expected NonLispExtensionScript, got {other:?}"),
3833 }
3834 }
3835
3836 #[test]
3837 fn declared_path_only_for_state_change() {
3838 let load = UpgradeInstruction::LoadModule { module: "x".into() };
3839 assert!(load.declared_path().is_none());
3840 let mig = UpgradeInstruction::StateChange {
3841 script: PathBuf::from("lib/m.lisp"),
3842 };
3843 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
3844 }
3845
3846 #[test]
3847 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
3848 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3849 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
3850 // predicate: [`UpgradeInstruction::Restart`] is the only variant
3851 // that satisfies `.is_restart()`; every module-bearing arm
3852 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
3853 // `StateChange` arm all return `false`. This pin makes the
3854 // partition invariant load-bearing at caixa-core test time so a
3855 // future derive regression (a hole that returns `false` for
3856 // `Restart` too, or a byte-collision that flips a second variant
3857 // to `true`) trips here rather than laundering the arm at
3858 // [`Self::validate_restart_exclusive`]'s paired positive /
3859 // negated filter sites (a hole flips restart-count to 0 →
3860 // vacuous OK; a collision flips restart-count > 1 → false
3861 // `RestartNotExclusive` on an entry the author declared without
3862 // any `(:restart)`). Peer of the sibling
3863 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
3864 // pin on the M0 `CaixaKind` axis.
3865 let cases: &[(UpgradeInstruction, bool)] = &[
3866 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3867 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3868 (UpgradeInstruction::Purge { module: "c".into() }, false),
3869 (
3870 UpgradeInstruction::StateChange {
3871 script: PathBuf::from("lib/m.lisp"),
3872 },
3873 false,
3874 ),
3875 (UpgradeInstruction::Restart, true),
3876 ];
3877 for (variant, expected) in cases {
3878 assert_eq!(
3879 variant.is_restart(),
3880 *expected,
3881 "UpgradeInstruction::{variant:?}.is_restart() must \
3882 return {expected} (partition invariant on the \
3883 IsVariant-derived arm-discriminator predicate)"
3884 );
3885 }
3886 }
3887
3888 #[test]
3889 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
3890 // Byte-identity pin on the paired positive / negated
3891 // `.is_restart()` filters at
3892 // [`Self::validate_restart_exclusive`] against the pre-lift
3893 // `matches!(i, UpgradeInstruction::Restart)` /
3894 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
3895 // consumer of the gate previously coupled to inline. Asserts
3896 // the two projections agree byte-for-byte on every arm of the
3897 // enum, so a future derive regression that flipped either
3898 // predicate's arm-set would surface here at caixa-core test
3899 // time rather than at
3900 // [`Self::validate_restart_exclusive`]'s per-entry restart-
3901 // count / other-kinds tabulation far from the derive site.
3902 // Same peer-shape pin every sibling
3903 // `IsVariant`-derive-routed gate carries on the substrate's
3904 // closed-set typed-enum surface.
3905 let cases: Vec<UpgradeInstruction> = vec![
3906 UpgradeInstruction::LoadModule { module: "a".into() },
3907 UpgradeInstruction::SoftPurge { module: "b".into() },
3908 UpgradeInstruction::Purge { module: "c".into() },
3909 UpgradeInstruction::StateChange {
3910 script: PathBuf::from("lib/m.lisp"),
3911 },
3912 UpgradeInstruction::Restart,
3913 ];
3914 for instr in &cases {
3915 let via_predicate = instr.is_restart();
3916 let via_matches = matches!(instr, UpgradeInstruction::Restart);
3917 assert_eq!(
3918 via_predicate, via_matches,
3919 "UpgradeInstruction::{instr:?}: is_restart() must \
3920 byte-equal matches!(_, UpgradeInstruction::Restart) — \
3921 the pre-lift open-coded pattern and the \
3922 IsVariant-derived predicate are the same axis, \
3923 one typed dispatch"
3924 );
3925 }
3926 }
3927
3928 #[test]
3929 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
3930 // The fail-before-pass-after pin on the lifted
3931 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
3932 // arm-discriminator predicate:
3933 // [`UpgradeInstruction::SoftPurge`] and
3934 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
3935 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
3936 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
3937 // on the paired two-phase-load half,
3938 // [`UpgradeInstruction::StateChange`] on the
3939 // `gen_server:code_change/3`-analog migration axis,
3940 // [`UpgradeInstruction::Restart`] on the OTP terminal-
3941 // fallback shape) returns `false`. This pin makes the
3942 // partition invariant load-bearing at caixa-core test time
3943 // so a future accessor regression (a hole that returns
3944 // `false` for `SoftPurge` or `Purge`, or a byte-collision
3945 // that flips `LoadModule` / `StateChange` / `Restart` to
3946 // `true`) trips here rather than laundering the arm at the
3947 // three within-entry cross-instruction cleanup-facing gates
3948 // ([`UpgradeFromEntry::validate_purge_ordering`],
3949 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
3950 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
3951 // hole would silently accept a cleanup-shaped entry the
3952 // three gates should refuse; a collision would fire a
3953 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
3954 // `DuplicateCleanup` refusal on a well-shaped
3955 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
3956 // `Restart` arm the three gates should pass through. Peer
3957 // of the sibling
3958 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3959 // pin on the single-arm terminal-fallback partition —
3960 // extended here from the single-arm case onto the two-arm
3961 // cleanup-family union case.
3962 let cases: &[(UpgradeInstruction, bool)] = &[
3963 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3964 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
3965 (UpgradeInstruction::Purge { module: "c".into() }, true),
3966 (
3967 UpgradeInstruction::StateChange {
3968 script: PathBuf::from("lib/m.lisp"),
3969 },
3970 false,
3971 ),
3972 (UpgradeInstruction::Restart, false),
3973 ];
3974 for (variant, expected) in cases {
3975 assert_eq!(
3976 variant.is_cleanup(),
3977 *expected,
3978 "UpgradeInstruction::{variant:?}.is_cleanup() must \
3979 return {expected} (partition invariant on the \
3980 lifted OTP-appup two-arm cleanup-family arm-\
3981 discriminator predicate)"
3982 );
3983 }
3984 }
3985
3986 #[test]
3987 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
3988 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
3989 // composition against the two [`gen_platform::IsVariant`]-
3990 // derive-generated per-variant classifiers it routes through
3991 // — the accessor's one body must byte-equal
3992 // `self.is_soft_purge() || self.is_purge()` across every arm
3993 // of the closed-set enum, so a future silent detour that
3994 // reintroduced a raw `matches!` pattern or that stopped
3995 // composing through the derive-generated per-variant
3996 // predicates (an accidental `self.is_soft_purge()` on its
3997 // own — silently dropping the `Purge` arm; an accidental
3998 // `self.is_purge() || self.is_state_change()` — silently
3999 // folding the migration arm into the cleanup family; a
4000 // typo `&&` for the union `||` — silently classifying no
4001 // arm as cleanup) trips here at caixa-core test time
4002 // rather than laundering the arm at the three within-entry
4003 // cross-instruction cleanup-facing gates. Same peer-shape
4004 // pin the sibling
4005 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4006 // carries on the paired terminal-fallback axis.
4007 let cases: Vec<UpgradeInstruction> = vec![
4008 UpgradeInstruction::LoadModule { module: "a".into() },
4009 UpgradeInstruction::SoftPurge { module: "b".into() },
4010 UpgradeInstruction::Purge { module: "c".into() },
4011 UpgradeInstruction::StateChange {
4012 script: PathBuf::from("lib/m.lisp"),
4013 },
4014 UpgradeInstruction::Restart,
4015 ];
4016 for instr in &cases {
4017 let via_predicate = instr.is_cleanup();
4018 let via_composition = instr.is_soft_purge() || instr.is_purge();
4019 assert_eq!(
4020 via_predicate, via_composition,
4021 "UpgradeInstruction::{instr:?}: is_cleanup() must \
4022 byte-equal is_soft_purge() || is_purge() — the \
4023 lifted union predicate and its per-variant \
4024 composition are the same axis, one typed dispatch"
4025 );
4026 }
4027 }
4028
4029 #[test]
4030 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4031 // Composition-pin the load-bearing invariant every consumer
4032 // that routes through `is_cleanup()` + `declared_module()`
4033 // relies on: any [`UpgradeInstruction`] value whose
4034 // `.is_cleanup()` returns `true` must have a `Some(_)`
4035 // `.declared_module()`. This makes the three within-entry
4036 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4037 // implies declared_module() is Some")` structurally
4038 // infallible at build time — a future refactor that added
4039 // a cleanup-shaped variant carrying no `:module` would trip
4040 // here rather than panic at
4041 // [`UpgradeFromEntry::validate_purge_ordering`] /
4042 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4043 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4044 // runtime on the offending author's caixa.lisp.
4045 let cases: Vec<UpgradeInstruction> = vec![
4046 UpgradeInstruction::LoadModule { module: "a".into() },
4047 UpgradeInstruction::SoftPurge { module: "b".into() },
4048 UpgradeInstruction::Purge { module: "c".into() },
4049 UpgradeInstruction::StateChange {
4050 script: PathBuf::from("lib/m.lisp"),
4051 },
4052 UpgradeInstruction::Restart,
4053 ];
4054 for instr in &cases {
4055 if instr.is_cleanup() {
4056 assert!(
4057 instr.declared_module().is_some(),
4058 "UpgradeInstruction::{instr:?}: is_cleanup() \
4059 must imply declared_module().is_some() — the \
4060 three within-entry cross-instruction cleanup-\
4061 facing gates rely on this invariant to route \
4062 the cleanup-target :module scalar through the \
4063 sibling declared_module accessor without a \
4064 pattern-bound `module` binding"
4065 );
4066 }
4067 }
4068 }
4069
4070 #[test]
4071 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4072 // Composition-pin the load-bearing invariant
4073 // [`UpgradeFromEntry::validate_load_singularity`] relies on
4074 // when routing the per-instruction load-family arm-discriminator
4075 // through the sibling
4076 // [`UpgradeInstruction::is_load_module`] +
4077 // [`UpgradeInstruction::declared_module`] accessor pair: any
4078 // [`UpgradeInstruction`] value whose `.is_load_module()`
4079 // returns `true` must have a `Some(_)` `.declared_module()`.
4080 // This makes the gate's `.expect("is_load_module() implies
4081 // declared_module() is Some")` structurally infallible at
4082 // build time — a future refactor that added a load-shaped
4083 // variant carrying no `:module` would trip here rather than
4084 // panic at [`UpgradeFromEntry::validate_load_singularity`]
4085 // at runtime on the offending author's caixa.lisp. Sibling
4086 // of the peer
4087 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4088 // composition pin on the two-arm cleanup-family axis — same
4089 // "predicate implies accessor" discipline extended onto the
4090 // single-arm load-family axis, closes the load-vs-cleanup
4091 // pair on the substrate primitive's typed dispatch discipline.
4092 let cases: Vec<UpgradeInstruction> = vec![
4093 UpgradeInstruction::LoadModule { module: "a".into() },
4094 UpgradeInstruction::SoftPurge { module: "b".into() },
4095 UpgradeInstruction::Purge { module: "c".into() },
4096 UpgradeInstruction::StateChange {
4097 script: PathBuf::from("lib/m.lisp"),
4098 },
4099 UpgradeInstruction::Restart,
4100 ];
4101 for instr in &cases {
4102 if instr.is_load_module() {
4103 assert!(
4104 instr.declared_module().is_some(),
4105 "UpgradeInstruction::{instr:?}: is_load_module() \
4106 must imply declared_module().is_some() — the \
4107 within-entry load-singularity gate relies on this \
4108 invariant to route the load-target :module scalar \
4109 through the sibling declared_module accessor \
4110 without a pattern-bound `module` binding"
4111 );
4112 }
4113 }
4114 }
4115
4116 #[test]
4117 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4118 {
4119 // Byte-identity pin on the
4120 // [`UpgradeFromEntry::validate_load_singularity`] load-family
4121 // dispatch against the pre-lift
4122 // `match instr { UpgradeInstruction::LoadModule { module } =>
4123 // module.as_str(), _ => continue }` open-coded pattern-match
4124 // the site previously carried. Asserts the two projections
4125 // agree byte-for-byte on every arm of the enum — the
4126 // arm-discriminator via `is_load_module()` and the `:module`
4127 // scalar via `declared_module()` — so a future derive
4128 // regression that flipped the predicate's arm-set (a hole
4129 // returning `false` for [`UpgradeInstruction::LoadModule`], a
4130 // byte-collision flipping a second variant to `true`) or an
4131 // accessor extension that promoted an additional variant onto
4132 // the `String`-carrying axis would trip here at caixa-core
4133 // test time rather than laundering the arm at the gate's
4134 // per-entry load-singularity scan far from the derive site.
4135 // Peer of the sibling
4136 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4137 // byte-identity pin on the paired ordering-side load-family
4138 // sticky-latch dispatch (both consumers now agree on one
4139 // typed dispatch for the load-family axis) and the peer
4140 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4141 // pin on the migration-family script-projection axis — the
4142 // three within-entry per-instruction-class singularity gates
4143 // now share one byte-identity pin apiece against their
4144 // respective substrate-primitive typed dispatches.
4145 //
4146 // Three-arm projective coverage:
4147 // (a) `LoadModule` modules project through
4148 // `declared_module()` byte-equal to the raw
4149 // `module.as_str()` field access;
4150 // (b) a duplicate-`LoadModule` input trips the gate on the
4151 // second occurrence with `DuplicateLoadModule` carrying
4152 // the offending module verbatim;
4153 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4154 // `StateChange` / `Restart`) leaves the gate vacuous
4155 // with `Ok(())` — the `!instr.is_load_module()`
4156 // `continue` fall-through pins.
4157 //
4158 // Fail-before-pass-after verified locally: swapping the
4159 // production `if !instr.is_load_module() { continue; } let
4160 // module = instr.declared_module().expect(…);` back to `let
4161 // module = match instr { UpgradeInstruction::LoadModule
4162 // { module } => module.as_str(), _ => continue, };` keeps
4163 // arms (a)-(c) passing but silently detaches the gate from
4164 // the accessor's typed dispatch — any future
4165 // `is_load_module` / `declared_module` extension (a hole in
4166 // either predicate, a promotion of an additional variant
4167 // onto the `String`-carrying axis, an operator-side
4168 // pre-parsed caixa-name cache the accessor materializes)
4169 // would then silently disagree between this gate's raw
4170 // pattern-match and the peer per-`UpgradeInstruction`
4171 // consumers that route through the accessor pair.
4172
4173 // (a) LoadModule projection byte-equal via
4174 // is_load_module() + declared_module().
4175 let lm = UpgradeInstruction::LoadModule {
4176 module: "hello-rio".into(),
4177 };
4178 assert!(
4179 lm.is_load_module(),
4180 "LoadModule must satisfy is_load_module() — the gate's \
4181 load-family arm-discriminator relies on this partition"
4182 );
4183 assert_eq!(
4184 lm.declared_module(),
4185 Some("hello-rio"),
4186 "declared_module() must project the LoadModule :module \
4187 byte-equal to the raw field access — accessor divergence \
4188 would silently detach the gate from the projection every \
4189 peer per-`UpgradeInstruction` consumer routes through"
4190 );
4191
4192 // (b) Duplicate-LoadModule input trips the gate.
4193 let dup = entry(
4194 "0.1.0",
4195 vec![
4196 UpgradeInstruction::LoadModule { module: "x".into() },
4197 UpgradeInstruction::LoadModule { module: "x".into() },
4198 ],
4199 );
4200 assert_eq!(
4201 dup.validate_load_singularity(),
4202 Err(UpgradeError::DuplicateLoadModule {
4203 from: "0.1.0".into(),
4204 module: "x".into(),
4205 }),
4206 "duplicate LoadModule modules within one entry must fire \
4207 DuplicateLoadModule byte-identical to the pre-lift \
4208 pattern-match shape"
4209 );
4210
4211 // (c) Non-LoadModule-only input leaves the gate vacuous.
4212 let no_load = entry(
4213 "0.1.0",
4214 vec![
4215 UpgradeInstruction::StateChange {
4216 script: PathBuf::from("lib/m.lisp"),
4217 },
4218 UpgradeInstruction::Restart,
4219 ],
4220 );
4221 assert_eq!(
4222 no_load.validate_load_singularity(),
4223 Ok(()),
4224 "non-LoadModule-only entries must leave the load-\
4225 singularity gate vacuous — the `!is_load_module()` \
4226 continue fall-through pins"
4227 );
4228 }
4229
4230 #[test]
4231 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4232 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4233 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4234 // predicate: [`UpgradeInstruction::LoadModule`] is the only
4235 // variant that satisfies `.is_load_module()`; every cleanup arm
4236 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4237 // and the terminal-fallback arm (`Restart`) all return `false`.
4238 // This pin makes the partition invariant load-bearing at
4239 // caixa-core test time so a future derive regression (a hole
4240 // that returns `false` for `LoadModule` too, or a byte-collision
4241 // that flips a second variant to `true`) trips here rather than
4242 // laundering the arm at
4243 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4244 // dispatch — a hole would silently keep `loaded = false` through
4245 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4246 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4247 // a collision would flip `loaded = true` on a well-shaped
4248 // cleanup-only entry and silently swallow the load-less
4249 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4250 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4251 // and
4252 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4253 // pins on the paired terminal-fallback and cleanup-family
4254 // arm-discriminator axes — closes the last unlifted `matches!`-
4255 // based arm-discriminator axis on the OTP-appup closed-set
4256 // typed enum.
4257 let cases: &[(UpgradeInstruction, bool)] = &[
4258 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4259 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4260 (UpgradeInstruction::Purge { module: "c".into() }, false),
4261 (
4262 UpgradeInstruction::StateChange {
4263 script: PathBuf::from("lib/m.lisp"),
4264 },
4265 false,
4266 ),
4267 (UpgradeInstruction::Restart, false),
4268 ];
4269 for (variant, expected) in cases {
4270 assert_eq!(
4271 variant.is_load_module(),
4272 *expected,
4273 "UpgradeInstruction::{variant:?}.is_load_module() must \
4274 return {expected} (partition invariant on the \
4275 IsVariant-derived arm-discriminator predicate)"
4276 );
4277 }
4278 }
4279
4280 #[test]
4281 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4282 // Byte-identity pin on the [`Self::validate_purge_ordering`]
4283 // load-family sticky-latch dispatch against the pre-lift
4284 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4285 // predicate the site previously open-coded. Asserts the two
4286 // projections agree byte-for-byte on every arm of the enum, so
4287 // a future derive regression that flipped the predicate's
4288 // arm-set would surface here at caixa-core test time rather
4289 // than at [`Self::validate_purge_ordering`]'s per-entry
4290 // load-before-cleanup ordering scan far from the derive site.
4291 // Same peer-shape pin the sibling
4292 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4293 // carries on the paired terminal-fallback axis and the
4294 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4295 // carries on the two-arm cleanup-family axis — the third and
4296 // final byte-identity pin closes the substrate primitive's
4297 // arm-discriminator dispatch discipline on the OTP-appup
4298 // closed-set typed enum.
4299 let cases: Vec<UpgradeInstruction> = vec![
4300 UpgradeInstruction::LoadModule { module: "a".into() },
4301 UpgradeInstruction::SoftPurge { module: "b".into() },
4302 UpgradeInstruction::Purge { module: "c".into() },
4303 UpgradeInstruction::StateChange {
4304 script: PathBuf::from("lib/m.lisp"),
4305 },
4306 UpgradeInstruction::Restart,
4307 ];
4308 for instr in &cases {
4309 let via_predicate = instr.is_load_module();
4310 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4311 assert_eq!(
4312 via_predicate, via_matches,
4313 "UpgradeInstruction::{instr:?}: is_load_module() must \
4314 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4315 {{ .. }}) — the pre-lift open-coded pattern and the \
4316 IsVariant-derived predicate are the same axis, one \
4317 typed dispatch"
4318 );
4319 }
4320 }
4321
4322 #[test]
4323 fn declared_module_only_for_module_bearing_variants() {
4324 // Pinned partition of the `UpgradeInstruction` closed-set
4325 // variant space against the sibling of the peer
4326 // `declared_path` accessor: every OTP-appup module-bearing
4327 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4328 // `:module` string byte-for-byte through the lifted
4329 // `declared_module` accessor; every non-module-bearing variant
4330 // (`StateChange` on the peer `:script`-carrying axis;
4331 // `Restart` on the OTP terminal-fallback data-less axis)
4332 // returns `None`. Mirrors the peer
4333 // `declared_path_only_for_state_change` pin — the pair now
4334 // closes both scalar-carrying axes on the enum on one lifted
4335 // `Option<&…>` accessor apiece.
4336 let load = UpgradeInstruction::LoadModule {
4337 module: "hello-rio".into(),
4338 };
4339 assert_eq!(load.declared_module(), Some("hello-rio"));
4340 let soft = UpgradeInstruction::SoftPurge {
4341 module: "hello-rio-old".into(),
4342 };
4343 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4344 let hard = UpgradeInstruction::Purge {
4345 module: "hello-rio-ancient".into(),
4346 };
4347 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4348 let mig = UpgradeInstruction::StateChange {
4349 script: PathBuf::from("lib/m.lisp"),
4350 };
4351 assert!(mig.declared_module().is_none());
4352 assert!(UpgradeInstruction::Restart.declared_module().is_none());
4353 }
4354
4355 #[test]
4356 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4357 // Byte-identity pin on the two-accessor partition: every
4358 // `UpgradeInstruction` variant returns `Some` from *exactly
4359 // one* of {`declared_module`, `declared_path`} (the two
4360 // module-bearing / script-carrying axes) or from *neither*
4361 // (the OTP terminal-fallback `Restart` shape). No variant
4362 // returns `Some` from both — the two axes are disjoint by
4363 // construction, and this pin closes the disjointness at the
4364 // test surface so a future variant that leaks a scalar across
4365 // both axes fails at build time. Mirrors the peer
4366 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4367 // discipline on the `BehaviorSpec` per-slot family.
4368 let cases: Vec<UpgradeInstruction> = vec![
4369 UpgradeInstruction::LoadModule { module: "a".into() },
4370 UpgradeInstruction::SoftPurge { module: "b".into() },
4371 UpgradeInstruction::Purge { module: "c".into() },
4372 UpgradeInstruction::StateChange {
4373 script: PathBuf::from("lib/m.lisp"),
4374 },
4375 UpgradeInstruction::Restart,
4376 ];
4377 for instr in &cases {
4378 let has_module = instr.declared_module().is_some();
4379 let has_path = instr.declared_path().is_some();
4380 assert!(
4381 !(has_module && has_path),
4382 "no variant may declare both a module and a path — offending: {instr:?}"
4383 );
4384 match instr {
4385 UpgradeInstruction::LoadModule { .. }
4386 | UpgradeInstruction::SoftPurge { .. }
4387 | UpgradeInstruction::Purge { .. } => {
4388 assert!(has_module && !has_path, "module axis: {instr:?}");
4389 }
4390 UpgradeInstruction::StateChange { .. } => {
4391 assert!(!has_module && has_path, "script axis: {instr:?}");
4392 }
4393 UpgradeInstruction::Restart => {
4394 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4395 }
4396 }
4397 }
4398 }
4399
4400 #[test]
4401 fn entry_with_chain_of_versions() {
4402 // Middle entry pairs a `:load-module` with the trailing
4403 // `:soft-purge` so it satisfies the within-entry purge-ordering
4404 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4405 // preceding `:load-module`, mirroring the state-change-ordering
4406 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4407 // test is *cross-entry* `:from` values; the within-entry shape
4408 // is incidental — keeping it canonical (`:load-module` before
4409 // `:soft-purge`) leaves the chain assertion load-bearing.
4410 let entries = vec![
4411 entry(
4412 "0.1.0",
4413 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4414 ),
4415 entry(
4416 "0.1.5",
4417 vec![
4418 UpgradeInstruction::LoadModule { module: "x".into() },
4419 UpgradeInstruction::SoftPurge {
4420 module: "x-old".into(),
4421 },
4422 ],
4423 ),
4424 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4425 ];
4426 for e in &entries {
4427 e.validate().unwrap();
4428 }
4429 let json = serde_json::to_string(&entries).unwrap();
4430 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4431 assert_eq!(entries, back);
4432 }
4433
4434 #[test]
4435 fn empty_instructions_list_is_valid() {
4436 let e = entry("0.1.0", vec![]);
4437 e.validate().unwrap();
4438 }
4439
4440 #[test]
4441 fn json_uses_kebab_case_kind_tags() {
4442 let i = UpgradeInstruction::SoftPurge {
4443 module: "x-old".into(),
4444 };
4445 let json = serde_json::to_string(&i).unwrap();
4446 assert!(json.contains("\"kind\":\"soft-purge\""));
4447 let i2 = UpgradeInstruction::StateChange {
4448 script: PathBuf::from("m.lisp"),
4449 };
4450 let json2 = serde_json::to_string(&i2).unwrap();
4451 assert!(json2.contains("\"kind\":\"state-change\""));
4452 }
4453
4454 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4455
4456 #[test]
4457 fn validate_upgrade_from_accepts_disjoint_versions() {
4458 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4459 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4460 // (and `entry_with_chain_of_versions` above) passes the cross-
4461 // entry gate. Different `:from` per entry is the intended
4462 // shape; the gate must not regress this baseline. Middle entry
4463 // pairs `:load-module` with `:soft-purge` to satisfy the
4464 // within-entry purge-ordering gate (see
4465 // `entry_with_chain_of_versions` for the same shape).
4466 let entries = vec![
4467 entry(
4468 "0.1.0",
4469 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4470 ),
4471 entry(
4472 "0.1.5",
4473 vec![
4474 UpgradeInstruction::LoadModule { module: "x".into() },
4475 UpgradeInstruction::SoftPurge {
4476 module: "x-old".into(),
4477 },
4478 ],
4479 ),
4480 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4481 ];
4482 validate_upgrade_from(&entries).unwrap();
4483 }
4484
4485 #[test]
4486 fn validate_upgrade_from_accepts_empty_list() {
4487 // Absent `:upgrade-from` (the bare `feira init` shape) — the
4488 // gate must trivially pass an empty list. Mirrors the per-axis
4489 // "empty list passes" positive control on every peer typed-
4490 // graph gate (`validate_membros` empty list, `validate_placement`
4491 // requires non-empty clusters but only after a `Placement`
4492 // exists, etc.).
4493 validate_upgrade_from(&[]).unwrap();
4494 }
4495
4496 #[test]
4497 fn validate_upgrade_from_rejects_duplicate_from() {
4498 // Fail-before-pass-after pin: two entries with the same parsed-
4499 // semver `:from` are an ambiguous edge in the typed upgrade
4500 // graph (OTP appup picks at most one matching block per running
4501 // version; with two matching blocks the operator picks either
4502 // set non-deterministically — author intent is one path per
4503 // prior version). Same set-not-multiset discipline as
4504 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4505 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4506 // `:entrada :paths` (eb3456d) — now extended onto the fifth
4507 // typed-graph axis.
4508 let entries = vec![
4509 entry(
4510 "0.1.0",
4511 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4512 ),
4513 entry(
4514 "0.1.0",
4515 vec![
4516 UpgradeInstruction::LoadModule { module: "x".into() },
4517 UpgradeInstruction::SoftPurge {
4518 module: "x-old".into(),
4519 },
4520 ],
4521 ),
4522 ];
4523 let err = validate_upgrade_from(&entries).unwrap_err();
4524 assert_eq!(
4525 err,
4526 UpgradeError::DuplicateFrom {
4527 from: "0.1.0".into()
4528 },
4529 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4530 offending value verbatim"
4531 );
4532 }
4533
4534 #[test]
4535 fn validate_upgrade_from_treats_pre_release_as_distinct() {
4536 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4537 // equal under semver (pre-release version is part of the
4538 // identity), so they're distinct upgrade paths and must not
4539 // collide. A future tightening that collapses pre-release into
4540 // the release version surfaces here.
4541 let entries = vec![
4542 entry("1.0.0", vec![UpgradeInstruction::Restart]),
4543 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4544 ];
4545 validate_upgrade_from(&entries).unwrap();
4546 }
4547
4548 #[test]
4549 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4550 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4551 // compares build metadata (it derives equality across all
4552 // fields including `pre` + `build`), so `1.0.0+build1` and
4553 // `1.0.0+build2` are *not* duplicates from the gate's
4554 // perspective — the operator may treat the build-metadata
4555 // suffix as a tiebreaker even though the semver spec says
4556 // build metadata is ignored for precedence
4557 // (https://semver.org/#spec-item-10). Pin the conservative
4558 // behavior here so a future switch to a build-metadata-
4559 // stripping comparator surfaces as a test failure first; that
4560 // change would require coordinating with the wasm-operator's
4561 // `:from`-match dispatch step, which is the load-bearing
4562 // semantic we'd be mirroring.
4563 let entries = vec![
4564 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4565 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4566 ];
4567 validate_upgrade_from(&entries).unwrap();
4568 }
4569
4570 #[test]
4571 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4572 // Order pin: a malformed `:from` on the second entry surfaces
4573 // its `FromInvalid` diagnostic, not a (less-useful)
4574 // `DuplicateFrom`. The per-entry shape pass runs *inline*
4575 // before the duplicate-key insert — parallel to
4576 // `child_versao_invalid_fires_before_duplicate_check`
4577 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4578 // (9888b13). Without this pin a future shortcut that runs the
4579 // cross-entry gate first would surface a duplicate diagnostic
4580 // on a string that isn't even parsable as a version.
4581 let entries = vec![
4582 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4583 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4584 ];
4585 let err = validate_upgrade_from(&entries).unwrap_err();
4586 assert!(
4587 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4588 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4589 );
4590 }
4591
4592 #[test]
4593 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4594 // Symmetric arm: a malformed shape on the *first* entry of a
4595 // duplicate pair surfaces its per-entry diagnostic too (not
4596 // the duplicate diagnostic that would otherwise fire on the
4597 // second entry). Pinned separately so a future shortcut that
4598 // walks the duplicate-check ahead of the per-entry pass for the
4599 // first entry only — easy regression to introduce — surfaces
4600 // here.
4601 let entries = vec![
4602 entry(
4603 "0.1.0",
4604 vec![UpgradeInstruction::LoadModule {
4605 module: String::new(),
4606 }],
4607 ),
4608 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4609 ];
4610 let err = validate_upgrade_from(&entries).unwrap_err();
4611 assert_eq!(
4612 err,
4613 UpgradeError::ModuleEmpty {
4614 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4615 },
4616 "malformed instruction on the first entry of a duplicate pair must surface its \
4617 per-entry diagnostic before the duplicate gate fires, got {err:?}"
4618 );
4619 }
4620
4621 #[test]
4622 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4623 // Diagnostic-shape pin: when three entries carry the same
4624 // `:from`, the gate reports the *first* collision (the second
4625 // entry) and stops — the third entry's duplicate is masked by
4626 // the first surfaced one. Mirrors
4627 // `validate_duplicate_child_diagnostic_names_first_collision`
4628 // (dbf50a9) on the supervisor axis.
4629 let entries = vec![
4630 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4631 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4632 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4633 ];
4634 let err = validate_upgrade_from(&entries).unwrap_err();
4635 assert_eq!(
4636 err,
4637 UpgradeError::DuplicateFrom {
4638 from: "0.1.0".into()
4639 }
4640 );
4641 }
4642
4643 #[test]
4644 fn validate_upgrade_from_single_entry_never_duplicates() {
4645 // Boundary control: a list of one entry can never produce a
4646 // duplicate, regardless of `:from` value (any single-element
4647 // set is trivially without duplicates). Pin this so a future
4648 // off-by-one in the seen-set insert doesn't accidentally flag
4649 // a single entry as duplicating itself.
4650 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4651 validate_upgrade_from(&entries).unwrap();
4652 }
4653
4654 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4655
4656 #[test]
4657 fn versao_gate_accepts_strict_upgrade() {
4658 // Positive control: the canonical "chain prior versions →
4659 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4660 // `:from` strictly less than the current `:versao` under
4661 // SemVer-2 precedence. The gate must not regress this baseline.
4662 let entries = vec![
4663 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4664 entry("0.1.5", vec![UpgradeInstruction::Restart]),
4665 entry("0.1.9", vec![UpgradeInstruction::Restart]),
4666 ];
4667 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4668 }
4669
4670 #[test]
4671 fn versao_gate_accepts_empty_entries() {
4672 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4673 // the gate is a no-op when the entries list is empty. Mirrors
4674 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4675 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4676 }
4677
4678 #[test]
4679 fn versao_gate_rejects_equal_from() {
4680 // Self-upgrade no-op: declaring `:from "0.2.0"` while
4681 // `:versao "0.2.0"` means "upgrade from myself to myself" —
4682 // the operator's dispatch either skips silently or
4683 // trivially "succeeds" with no observable state change.
4684 // Reject as the canonical "I forgot to bump :versao when
4685 // adding this entry" footgun.
4686 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4687 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4688 assert_eq!(
4689 err,
4690 UpgradeError::FromNotBeforeVersao {
4691 from: "0.2.0".into(),
4692 versao: "0.2.0".into(),
4693 },
4694 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4695 values verbatim, got {err:?}"
4696 );
4697 }
4698
4699 #[test]
4700 fn versao_gate_rejects_downgrade_from() {
4701 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4702 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4703 // the operator's `:from`-match dispatch can never reach (it
4704 // never runs a version >= the current one). Reject as the
4705 // canonical "I copy-pasted from the next minor version and
4706 // forgot to bump :versao" footgun.
4707 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4708 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4709 assert_eq!(
4710 err,
4711 UpgradeError::FromNotBeforeVersao {
4712 from: "0.3.0".into(),
4713 versao: "0.2.0".into(),
4714 }
4715 );
4716 }
4717
4718 #[test]
4719 fn versao_gate_accepts_prerelease_before_release() {
4720 // SemVer §11 precedence: pre-release versions are *less than*
4721 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4722 // FROM an RC TO the GA release is the canonical authoring
4723 // shape — must pass. A regression that collapses pre-release
4724 // into the release version (treating them as equal) surfaces
4725 // here as a false-positive rejection.
4726 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4727 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4728 }
4729
4730 #[test]
4731 fn versao_gate_rejects_release_after_prerelease() {
4732 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4733 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4734 // the typical "I'm on an RC of a release that already
4735 // shipped" footgun. The gate names both values verbatim
4736 // so the author can grep for either side and fix in one
4737 // edit.
4738 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4739 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4740 assert_eq!(
4741 err,
4742 UpgradeError::FromNotBeforeVersao {
4743 from: "0.2.0".into(),
4744 versao: "0.2.0-rc.1".into(),
4745 }
4746 );
4747 }
4748
4749 #[test]
4750 fn versao_gate_rejects_build_metadata_only_difference() {
4751 // SemVer §11 explicitly excludes build metadata from
4752 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4753 // *equal* under [`semver::Version::cmp`]. From the
4754 // operator's `:from`-match dispatch perspective this is a
4755 // self-upgrade no-op (no semantic transition between the
4756 // two), so the gate rejects it — *unlike* the peer
4757 // duplicate-`:from` gate which uses derived `PartialEq` and
4758 // treats build-metadata variants as distinct dispatch keys.
4759 // The two gates' different equality notions are deliberate:
4760 // duplicate-check is conservative (preserves operator-side
4761 // tiebreaking surface), precedence-check is permissive
4762 // (matches operator-side dispatch semantic).
4763 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4764 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4765 assert_eq!(
4766 err,
4767 UpgradeError::FromNotBeforeVersao {
4768 from: "0.2.0+build.1".into(),
4769 versao: "0.2.0".into(),
4770 }
4771 );
4772 }
4773
4774 #[test]
4775 fn versao_gate_silently_passes_on_unparseable_versao() {
4776 // Defensive arm: a malformed `:versao` (gated by the
4777 // narrower `ManifestError::VersaoInvalid` surface at the
4778 // load-bearing call site) must not regress into a
4779 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4780 // the precedence error over an unparseable `:versao` would
4781 // mask the more actionable root cause (the author meant to
4782 // type `"0.2.0"`, not `"v0.2.0"`).
4783 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4784 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4785 }
4786
4787 #[test]
4788 fn versao_gate_silently_passes_on_unparseable_from() {
4789 // Symmetric defensive arm: a malformed `:from` is gated by
4790 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4791 // upstream at the LayoutInvariants call site. Surfacing the
4792 // precedence error over an unparseable `:from` from this
4793 // gate alone would mask the narrower `FromInvalid`
4794 // diagnostic that's expected to lead — same fall-through
4795 // posture as the unparseable-`:versao` arm above. The
4796 // wiring in `LayoutInvariants::verify` runs
4797 // `validate_upgrade_from` *before* this gate, so in practice
4798 // an unparseable `:from` surfaces as `FromInvalid` first
4799 // and this gate is never reached on that input.
4800 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4801 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4802 }
4803
4804 #[test]
4805 fn versao_gate_reports_first_offending_entry() {
4806 // Determinism pin: with multiple offending entries the gate
4807 // surfaces the *first* one in declaration order — same
4808 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4809 // on the peer gate. Walks the entries in order; first
4810 // failing `:from >= :versao` short-circuits.
4811 let entries = vec![
4812 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4813 entry("0.3.0", vec![UpgradeInstruction::Restart]),
4814 entry("0.4.0", vec![UpgradeInstruction::Restart]),
4815 ];
4816 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4817 assert_eq!(
4818 err,
4819 UpgradeError::FromNotBeforeVersao {
4820 from: "0.3.0".into(),
4821 versao: "0.2.0".into(),
4822 },
4823 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
4824 );
4825 }
4826
4827 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
4828
4829 #[test]
4830 fn validate_rejects_restart_mixed_with_load_module() {
4831 // The "I'll try the typed path *then* restart anyway" footgun:
4832 // an instructions list with `(:restart)` plus `(:load-module …)`
4833 // is dead code in both directions (succeed → restart discards
4834 // the work that just succeeded, defeating the typed sequence's
4835 // whole point; fail → restart never reached because the entry
4836 // already failed). The gate names the offending entry's `:from`
4837 // verbatim plus the kebab-case lisp-form of every non-`:restart`
4838 // peer so the author can grep their caixa.lisp for either side
4839 // and fix in one edit.
4840 let e = entry(
4841 "0.1.0",
4842 vec![
4843 UpgradeInstruction::LoadModule {
4844 module: "hello-rio".into(),
4845 },
4846 UpgradeInstruction::Restart,
4847 ],
4848 );
4849 let err = e.validate().unwrap_err();
4850 assert_eq!(
4851 err,
4852 UpgradeError::RestartNotExclusive {
4853 from: "0.1.0".into(),
4854 restart_count: 1,
4855 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
4856 },
4857 "restart + load-module mix must surface as RestartNotExclusive naming the \
4858 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
4859 );
4860 }
4861
4862 #[test]
4863 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
4864 // Sweep the typed-sequence universe — every non-`:restart`
4865 // variant alongside `:restart` — and assert every typed
4866 // instruction's lisp-form appears in `other_kinds` in
4867 // declaration order. The author should be able to grep for
4868 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
4869 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
4870 // mapping surfaces here.
4871 let e = entry(
4872 "0.1.0",
4873 vec![
4874 UpgradeInstruction::LoadModule {
4875 module: "hello-rio".into(),
4876 },
4877 UpgradeInstruction::StateChange {
4878 script: PathBuf::from("lib/m.lisp"),
4879 },
4880 UpgradeInstruction::SoftPurge {
4881 module: "hello-rio-old".into(),
4882 },
4883 UpgradeInstruction::Purge {
4884 module: "hello-rio-old".into(),
4885 },
4886 UpgradeInstruction::Restart,
4887 ],
4888 );
4889 let err = e.validate().unwrap_err();
4890 assert_eq!(
4891 err,
4892 UpgradeError::RestartNotExclusive {
4893 from: "0.1.0".into(),
4894 restart_count: 1,
4895 other_kinds: vec![
4896 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
4897 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
4898 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4899 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4900 ],
4901 },
4902 );
4903 }
4904
4905 #[test]
4906 fn validate_rejects_restart_duplicated() {
4907 // `((:restart) (:restart))` — multiple Restart variants in one
4908 // entry. The fallback is a single semantic (restart the pod;
4909 // the new version comes up fresh); repeating it is at best
4910 // redundant, at worst suggests the author thought the second
4911 // would re-trigger after the first. The gate reports
4912 // `restart_count: 2` so the diagnostic surfaces the duplication
4913 // mode unambiguously even when `other_kinds` is empty.
4914 let e = entry(
4915 "0.1.0",
4916 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
4917 );
4918 let err = e.validate().unwrap_err();
4919 assert_eq!(
4920 err,
4921 UpgradeError::RestartNotExclusive {
4922 from: "0.1.0".into(),
4923 restart_count: 2,
4924 other_kinds: vec![],
4925 },
4926 );
4927 }
4928
4929 #[test]
4930 fn validate_accepts_sole_restart() {
4931 // Positive control: the canonical "this prior version's typed
4932 // upgrade is impossible — restart" authoring shape from the
4933 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
4934 // is the entry's whole instructions list and the only valid
4935 // Restart-bearing shape.
4936 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
4937 e.validate().unwrap();
4938 }
4939
4940 #[test]
4941 fn validate_accepts_typed_sequence_without_restart() {
4942 // Positive control: the canonical typed hot-upgrade authoring
4943 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
4944 // `:state-change` → `:soft-purge`. Absent `:restart` is the
4945 // only shape that lets the sequence run to completion under
4946 // the wasm-operator's `:from`-match dispatch. Drift here =
4947 // a future tighten that rejects any canonical typed-only shape
4948 // surfaces as a regression at this gate.
4949 let e = entry(
4950 "0.1.0",
4951 vec![
4952 UpgradeInstruction::LoadModule {
4953 module: "hello-rio".into(),
4954 },
4955 UpgradeInstruction::StateChange {
4956 script: PathBuf::from("lib/m.lisp"),
4957 },
4958 UpgradeInstruction::SoftPurge {
4959 module: "hello-rio-old".into(),
4960 },
4961 ],
4962 );
4963 e.validate().unwrap();
4964 }
4965
4966 // ── within-entry state-change-ordering invariant ───────────────────
4967
4968 #[test]
4969 fn validate_rejects_state_change_without_load() {
4970 // Fail-before-pass-after pin: a `:state-change` migrates state
4971 // into the newly-loaded code (gen_server:code_change/3 analog),
4972 // so an entry that runs it with no preceding `:load-module`
4973 // migrates state into code that was never loaded. The operator
4974 // runs instructions in declared order, so this is a build error,
4975 // not a runtime surprise (CAIXA-SDLC §III).
4976 let e = entry(
4977 "0.1.0",
4978 vec![UpgradeInstruction::StateChange {
4979 script: PathBuf::from("lib/m.lisp"),
4980 }],
4981 );
4982 let err = e.validate().unwrap_err();
4983 assert_eq!(
4984 err,
4985 UpgradeError::StateChangeWithoutPriorLoad {
4986 from: "0.1.0".into(),
4987 script: PathBuf::from("lib/m.lisp"),
4988 },
4989 "a `:state-change` with no preceding `:load-module` must surface as \
4990 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
4991 );
4992 }
4993
4994 #[test]
4995 fn validate_rejects_state_change_before_load() {
4996 // Right-instructions-wrong-order: the load is present but runs
4997 // *after* the migration. Because the operator executes in
4998 // declared order, the migration runs before the new code is
4999 // resident — the same incoherence as the missing-load case.
5000 let e = entry(
5001 "0.1.0",
5002 vec![
5003 UpgradeInstruction::StateChange {
5004 script: PathBuf::from("lib/m.lisp"),
5005 },
5006 UpgradeInstruction::LoadModule {
5007 module: "hello-rio".into(),
5008 },
5009 ],
5010 );
5011 let err = e.validate().unwrap_err();
5012 assert!(
5013 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5014 "a `:state-change` ahead of its `:load-module` must surface as \
5015 StateChangeWithoutPriorLoad, got {err:?}"
5016 );
5017 }
5018
5019 #[test]
5020 fn validate_accepts_state_change_after_load() {
5021 // Positive control: the canonical `(:load-module …)
5022 // (:state-change …)` order validates. The load need not name
5023 // the same module the migration targets (StateChange carries a
5024 // script, not a module ref), so any preceding `:load-module`
5025 // satisfies "new code is resident before its migration runs".
5026 let e = entry(
5027 "0.1.0",
5028 vec![
5029 UpgradeInstruction::LoadModule {
5030 module: "hello-rio".into(),
5031 },
5032 UpgradeInstruction::StateChange {
5033 script: PathBuf::from("lib/m.lisp"),
5034 },
5035 ],
5036 );
5037 e.validate().unwrap();
5038 }
5039
5040 #[test]
5041 fn validate_accepts_multiple_state_changes_after_one_load() {
5042 // A single leading `:load-module` covers every subsequent
5043 // `:state-change` — the `loaded` latch stays set once the new
5044 // code is resident.
5045 let e = entry(
5046 "0.1.0",
5047 vec![
5048 UpgradeInstruction::LoadModule {
5049 module: "hello-rio".into(),
5050 },
5051 UpgradeInstruction::StateChange {
5052 script: PathBuf::from("lib/m1.lisp"),
5053 },
5054 UpgradeInstruction::StateChange {
5055 script: PathBuf::from("lib/m2.lisp"),
5056 },
5057 ],
5058 );
5059 e.validate().unwrap();
5060 }
5061
5062 #[test]
5063 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5064 {
5065 // Byte-identity pin on the
5066 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5067 // migrate ordering dispatch against the pre-lift
5068 // `match instr { UpgradeInstruction::LoadModule { .. } =>
5069 // loaded = true, UpgradeInstruction::StateChange { script } if
5070 // !loaded => …, _ => {} }` open-coded pattern-match the site
5071 // previously carried. Asserts the two projections agree
5072 // byte-for-byte on every arm of the enum — the load-family
5073 // arm-discriminator via `is_load_module()` and the migration-
5074 // family `:script` scalar via `declared_path()` — so a future
5075 // derive regression that flipped the predicate's arm-set (a
5076 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5077 // a byte-collision flipping a second variant to `true`) or an
5078 // accessor extension that promoted an additional variant onto
5079 // the `PathBuf`-carrying axis would trip here at caixa-core
5080 // test time rather than laundering the arm at the gate's
5081 // per-entry ordering scan far from the derive site.
5082 //
5083 // Peer of the sibling
5084 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5085 // (c9ce91d) pin on the peer within-entry per-instruction-class
5086 // singularity gate's load-family + `String`-carrying dispatch,
5087 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5088 // (580d0f1) pin on the paired load → cleanup ordering gate's
5089 // load-family sticky-latch dispatch, and the
5090 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5091 // pin on the peer within-entry per-instruction-class singularity
5092 // gate's migration-family script-projection dispatch — closes
5093 // the last unlifted `match`-shaped per-arm-hand-rolled load-
5094 // family arm-discriminator + migration-family script-projection
5095 // pair inside `impl UpgradeFromEntry`. The four within-entry
5096 // ordering / singularity gates now share one byte-identity pin
5097 // apiece against their respective substrate-primitive typed
5098 // dispatches on the OTP-appup closed-set enum.
5099 //
5100 // Three-arm projective coverage:
5101 // (a) `LoadModule` satisfies `is_load_module()`, so the
5102 // sticky-latch advances byte-equal to the pre-lift
5103 // `UpgradeInstruction::LoadModule { .. }` arm; every
5104 // other variant leaves the latch untouched;
5105 // (b) a `((:state-change …))`-only entry (no preceding load)
5106 // trips the gate on the first `StateChange` with
5107 // `StateChangeWithoutPriorLoad` carrying the offending
5108 // script verbatim — the migration-family script surfaces
5109 // through `declared_path()` byte-equal to the raw
5110 // `StateChange { script }` pattern-bound field;
5111 // (c) a `((:load-module …) (:state-change …))` entry leaves
5112 // the gate vacuous with `Ok(())` — the `loaded = true`
5113 // latch on the first arm satisfies the `!loaded` guard
5114 // negation on the second, so the `declared_path()`
5115 // `Some(script)` fall-through does not fire — and a
5116 // non-`StateChange`-non-`LoadModule` sequence
5117 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5118 // the gate vacuous because `declared_path()` is `None`
5119 // on all three of those arms.
5120 //
5121 // Fail-before-pass-after verified locally: swapping the
5122 // production `if instr.is_load_module() { loaded = true; }
5123 // else if !loaded && let Some(script) = instr.declared_path()
5124 // { … }` back to `match instr { UpgradeInstruction::LoadModule
5125 // { .. } => loaded = true, UpgradeInstruction::StateChange
5126 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5127 // passing but silently detaches the gate from the accessor's
5128 // typed dispatch — any future `is_load_module` / `declared_path`
5129 // extension (a hole in either predicate, a promotion of an
5130 // additional variant onto either axis, an operator-side
5131 // pre-resolved-path cache the accessor materializes) would
5132 // then silently disagree between this gate's raw pattern-match
5133 // and the peer per-`UpgradeInstruction` consumers that route
5134 // through the accessor pair.
5135
5136 // (a) is_load_module() partitions the arm-set byte-equal to
5137 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5138 // { .. })` and declared_path() surfaces the StateChange
5139 // `:script` byte-equal to the raw field access.
5140 let lm = UpgradeInstruction::LoadModule {
5141 module: "hello-rio".into(),
5142 };
5143 assert!(
5144 lm.is_load_module(),
5145 "LoadModule must satisfy is_load_module() — the gate's \
5146 load-family sticky-latch relies on this partition"
5147 );
5148 assert!(
5149 lm.declared_path().is_none(),
5150 "LoadModule must not carry a declared_path — the gate's \
5151 else-if migration-family arm must not fire on load arms"
5152 );
5153 let sc = UpgradeInstruction::StateChange {
5154 script: PathBuf::from("lib/m.lisp"),
5155 };
5156 assert!(
5157 !sc.is_load_module(),
5158 "StateChange must not satisfy is_load_module() — the gate's \
5159 sticky-latch must not advance on migration arms"
5160 );
5161 assert_eq!(
5162 sc.declared_path().map(std::path::PathBuf::as_path),
5163 Some(PathBuf::from("lib/m.lisp").as_path()),
5164 "declared_path() must project the StateChange :script \
5165 byte-equal to the raw field access — accessor divergence \
5166 would silently detach the gate from the projection every \
5167 peer per-`UpgradeInstruction` consumer routes through"
5168 );
5169
5170 // (b) A `((:state-change …))`-only entry trips
5171 // StateChangeWithoutPriorLoad byte-identical to the
5172 // pre-lift match-pattern shape.
5173 let no_prior_load = entry(
5174 "0.1.0",
5175 vec![UpgradeInstruction::StateChange {
5176 script: PathBuf::from("lib/m.lisp"),
5177 }],
5178 );
5179 assert_eq!(
5180 no_prior_load.validate_state_change_ordering(),
5181 Err(UpgradeError::StateChangeWithoutPriorLoad {
5182 from: "0.1.0".into(),
5183 script: PathBuf::from("lib/m.lisp"),
5184 }),
5185 "a `:state-change` with no preceding `:load-module` must fire \
5186 StateChangeWithoutPriorLoad carrying the offending script \
5187 verbatim through the declared_path() accessor"
5188 );
5189
5190 // (c) `((:load-module …) (:state-change …))` leaves the gate
5191 // vacuous; so does a non-StateChange-non-LoadModule
5192 // sequence (SoftPurge / Purge / Restart alone).
5193 let load_before_migrate = entry(
5194 "0.1.0",
5195 vec![
5196 UpgradeInstruction::LoadModule {
5197 module: "hello-rio".into(),
5198 },
5199 UpgradeInstruction::StateChange {
5200 script: PathBuf::from("lib/m.lisp"),
5201 },
5202 ],
5203 );
5204 assert_eq!(
5205 load_before_migrate.validate_state_change_ordering(),
5206 Ok(()),
5207 "load-before-migrate entries must leave the ordering gate \
5208 vacuous — the `loaded = true` sticky-latch on the first arm \
5209 satisfies the `!loaded` guard negation on the else-if arm"
5210 );
5211 for instr in [
5212 UpgradeInstruction::SoftPurge {
5213 module: "x-old".into(),
5214 },
5215 UpgradeInstruction::Purge {
5216 module: "x-old".into(),
5217 },
5218 UpgradeInstruction::Restart,
5219 ] {
5220 let e = entry("0.1.0", vec![instr.clone()]);
5221 assert_eq!(
5222 e.validate_state_change_ordering(),
5223 Ok(()),
5224 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5225 leave the ordering gate vacuous — declared_path() is None \
5226 on every non-StateChange arm, so the else-if migration-\
5227 family arm never fires"
5228 );
5229 }
5230 }
5231
5232 #[test]
5233 fn validate_state_change_ordering_fires_after_restart_exclusive() {
5234 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5235 // shape is *both* state-change-without-load and restart-mixed.
5236 // The more-fundamental `RestartNotExclusive` must win (a valid
5237 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5238 // entry should reach the ordering gate). Guards the call order
5239 // in `validate` against silent reordering.
5240 let e = entry(
5241 "0.1.0",
5242 vec![
5243 UpgradeInstruction::StateChange {
5244 script: PathBuf::from("lib/m.lisp"),
5245 },
5246 UpgradeInstruction::Restart,
5247 ],
5248 );
5249 let err = e.validate().unwrap_err();
5250 assert!(
5251 matches!(err, UpgradeError::RestartNotExclusive { .. }),
5252 "restart-mixed must surface before the ordering gate, got {err:?}"
5253 );
5254 }
5255
5256 // ── within-entry purge-ordering invariant ──────────────────────────
5257
5258 #[test]
5259 fn validate_rejects_soft_purge_without_load() {
5260 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5261 // module after the new one is resident (OTP's two-phase code
5262 // load — code:load_module/1 then code:soft_purge/1), so an
5263 // entry that runs it with no preceding `:load-module` drains
5264 // the live module with no replacement. The operator runs
5265 // instructions in declared order, so this is a build error,
5266 // not a runtime surprise (CAIXA-SDLC §III).
5267 let e = entry(
5268 "0.1.0",
5269 vec![UpgradeInstruction::SoftPurge {
5270 module: "x-old".into(),
5271 }],
5272 );
5273 let err = e.validate().unwrap_err();
5274 assert_eq!(
5275 err,
5276 UpgradeError::PurgeWithoutPriorLoad {
5277 from: "0.1.0".into(),
5278 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5279 module: "x-old".into(),
5280 },
5281 "a `:soft-purge` with no preceding `:load-module` must surface as \
5282 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5283 );
5284 }
5285
5286 #[test]
5287 fn validate_rejects_purge_without_load() {
5288 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5289 // the more catastrophic peer of `:soft-purge`; same gate, same
5290 // shape, kind-tag differs so the author can grep their
5291 // caixa.lisp for the offending `(:purge …)` form.
5292 let e = entry(
5293 "0.1.0",
5294 vec![UpgradeInstruction::Purge {
5295 module: "x-old".into(),
5296 }],
5297 );
5298 let err = e.validate().unwrap_err();
5299 assert_eq!(
5300 err,
5301 UpgradeError::PurgeWithoutPriorLoad {
5302 from: "0.1.0".into(),
5303 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5304 module: "x-old".into(),
5305 },
5306 );
5307 }
5308
5309 #[test]
5310 fn validate_rejects_soft_purge_before_load() {
5311 // Right-instructions-wrong-order: the load is present but runs
5312 // *after* the purge. Because the operator executes in declared
5313 // order, the cleanup drains the old code before the new code
5314 // is resident — same incoherence as the missing-load case,
5315 // leaving a window during which neither version is available.
5316 let e = entry(
5317 "0.1.0",
5318 vec![
5319 UpgradeInstruction::SoftPurge {
5320 module: "x-old".into(),
5321 },
5322 UpgradeInstruction::LoadModule { module: "x".into() },
5323 ],
5324 );
5325 let err = e.validate().unwrap_err();
5326 assert!(
5327 matches!(
5328 err,
5329 UpgradeError::PurgeWithoutPriorLoad {
5330 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5331 ..
5332 }
5333 ),
5334 "a `:soft-purge` ahead of its `:load-module` must surface as \
5335 PurgeWithoutPriorLoad, got {err:?}"
5336 );
5337 }
5338
5339 #[test]
5340 fn validate_rejects_purge_before_load() {
5341 // Symmetric arm on the `:purge` variant — the kind tag
5342 // distinguishes the diagnostic so the author lands on the
5343 // offending form directly.
5344 let e = entry(
5345 "0.1.0",
5346 vec![
5347 UpgradeInstruction::Purge {
5348 module: "x-old".into(),
5349 },
5350 UpgradeInstruction::LoadModule { module: "x".into() },
5351 ],
5352 );
5353 let err = e.validate().unwrap_err();
5354 assert!(
5355 matches!(
5356 err,
5357 UpgradeError::PurgeWithoutPriorLoad {
5358 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5359 ..
5360 }
5361 ),
5362 "a `:purge` ahead of its `:load-module` must surface as \
5363 PurgeWithoutPriorLoad, got {err:?}"
5364 );
5365 }
5366
5367 #[test]
5368 fn validate_accepts_soft_purge_after_load() {
5369 // Positive control: the canonical `(:load-module …)
5370 // (:soft-purge …)` order validates. The load need not name the
5371 // same module the purge targets — the cleanup typically targets
5372 // the *old* module name (e.g. `"x-old"`) and the load brings up
5373 // the *new* one (`"x"`); the gate only requires that *some*
5374 // `:load-module` precedes the purge, so the new code is resident
5375 // before the old one is drained.
5376 let e = entry(
5377 "0.1.0",
5378 vec![
5379 UpgradeInstruction::LoadModule { module: "x".into() },
5380 UpgradeInstruction::SoftPurge {
5381 module: "x-old".into(),
5382 },
5383 ],
5384 );
5385 e.validate().unwrap();
5386 }
5387
5388 #[test]
5389 fn validate_accepts_multiple_purges_after_one_load() {
5390 // A single leading `:load-module` covers every subsequent
5391 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5392 // the new code is resident. Same shape as
5393 // `validate_accepts_multiple_state_changes_after_one_load` on
5394 // the peer ordering gate.
5395 let e = entry(
5396 "0.1.0",
5397 vec![
5398 UpgradeInstruction::LoadModule { module: "x".into() },
5399 UpgradeInstruction::SoftPurge {
5400 module: "x-old".into(),
5401 },
5402 UpgradeInstruction::Purge {
5403 module: "x-oldest".into(),
5404 },
5405 ],
5406 );
5407 e.validate().unwrap();
5408 }
5409
5410 #[test]
5411 fn validate_purge_ordering_fires_after_state_change_ordering() {
5412 // Diagnostic-precedence pin: an entry like `((:state-change …)
5413 // (:soft-purge …))` is *both* state-change-without-load and
5414 // purge-without-load. The state-change gate must win — it's
5415 // the load-bearing semantic on this ordering contract, and
5416 // surfacing the purge diagnostic first would mask the more-
5417 // fundamental migration-against-stale-code defect. Guards the
5418 // call order in `validate` against silent reordering.
5419 let e = entry(
5420 "0.1.0",
5421 vec![
5422 UpgradeInstruction::StateChange {
5423 script: PathBuf::from("lib/m.lisp"),
5424 },
5425 UpgradeInstruction::SoftPurge {
5426 module: "x-old".into(),
5427 },
5428 ],
5429 );
5430 let err = e.validate().unwrap_err();
5431 assert!(
5432 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5433 "state-change-without-load must surface before purge-without-load, got {err:?}"
5434 );
5435 }
5436
5437 #[test]
5438 fn validate_purge_ordering_fires_after_per_instr_shape() {
5439 // Order pin: a malformed `:module` value on a `:soft-purge` (an
5440 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5441 // diagnostic *before* the within-entry purge-ordering gate fires.
5442 // The per-instruction shape pass walks the list inline before
5443 // the ordering checks, so the narrower self-locating diagnostic
5444 // surfaces first — mirrors the empty-first cascade on every peer
5445 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5446 // per_instr_shape` pin on the sibling ordering gate.
5447 let e = entry(
5448 "0.1.0",
5449 vec![UpgradeInstruction::SoftPurge {
5450 module: String::new(),
5451 }],
5452 );
5453 let err = e.validate().unwrap_err();
5454 assert_eq!(
5455 err,
5456 UpgradeError::ModuleEmpty {
5457 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5458 },
5459 "malformed instruction must surface its kind-tagged diagnostic before the \
5460 purge-ordering gate fires, got {err:?}"
5461 );
5462 }
5463
5464 #[test]
5465 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5466 // The whole-list entry-point surfaces the per-entry ordering
5467 // error (mirrors
5468 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5469 // the gate is reachable from the LayoutInvariants call site, not
5470 // only from a direct `entry.validate()`.
5471 let entries = vec![entry(
5472 "0.1.0",
5473 vec![UpgradeInstruction::Purge {
5474 module: "x-old".into(),
5475 }],
5476 )];
5477 let err = validate_upgrade_from(&entries).unwrap_err();
5478 assert!(
5479 matches!(
5480 err,
5481 UpgradeError::PurgeWithoutPriorLoad {
5482 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5483 ..
5484 }
5485 ),
5486 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5487 );
5488 }
5489
5490 #[test]
5491 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5492 // The whole-list entry-point surfaces the per-entry ordering
5493 // error (mirrors `validate_restart_exclusive_threads_through_…`):
5494 // the gate is reachable from the LayoutInvariants call site, not
5495 // only from a direct `entry.validate()`.
5496 let entries = vec![entry(
5497 "0.1.0",
5498 vec![UpgradeInstruction::StateChange {
5499 script: PathBuf::from("lib/m.lisp"),
5500 }],
5501 )];
5502 let err = validate_upgrade_from(&entries).unwrap_err();
5503 assert!(
5504 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5505 "validate_upgrade_from must thread the ordering error, got {err:?}"
5506 );
5507 }
5508
5509 // ── within-entry cleanup-singularity invariant ─────────────────────
5510
5511 #[test]
5512 fn validate_rejects_duplicate_soft_purge_for_same_module() {
5513 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5514 // its target module (code:soft_purge/1 analog); after the
5515 // first the module is gone, so a second `:soft-purge` of the
5516 // same module is at best a no-op and at worst undefined
5517 // (depending on the operator's handling of a non-resident-
5518 // module purge). Author one cleanup per module.
5519 let e = entry(
5520 "0.1.0",
5521 vec![
5522 UpgradeInstruction::LoadModule { module: "x".into() },
5523 UpgradeInstruction::SoftPurge {
5524 module: "x-old".into(),
5525 },
5526 UpgradeInstruction::SoftPurge {
5527 module: "x-old".into(),
5528 },
5529 ],
5530 );
5531 let err = e.validate().unwrap_err();
5532 assert_eq!(
5533 err,
5534 UpgradeError::DuplicateCleanup {
5535 from: "0.1.0".into(),
5536 module: "x-old".into(),
5537 kinds: vec![
5538 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5539 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5540 ],
5541 },
5542 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5543 module + both kinds in declaration order, got {err:?}"
5544 );
5545 }
5546
5547 #[test]
5548 fn validate_rejects_duplicate_purge_for_same_module() {
5549 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5550 // the more catastrophic peer of `:soft-purge`; same gate, same
5551 // shape, kind-tag distinguishes so the author can grep their
5552 // caixa.lisp for the offending `(:purge …)` form.
5553 let e = entry(
5554 "0.1.0",
5555 vec![
5556 UpgradeInstruction::LoadModule { module: "x".into() },
5557 UpgradeInstruction::Purge {
5558 module: "x-old".into(),
5559 },
5560 UpgradeInstruction::Purge {
5561 module: "x-old".into(),
5562 },
5563 ],
5564 );
5565 let err = e.validate().unwrap_err();
5566 assert_eq!(
5567 err,
5568 UpgradeError::DuplicateCleanup {
5569 from: "0.1.0".into(),
5570 module: "x-old".into(),
5571 kinds: vec![
5572 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5573 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5574 ],
5575 },
5576 );
5577 }
5578
5579 #[test]
5580 fn validate_rejects_soft_purge_then_purge_for_same_module() {
5581 // Soft-then-hard footgun: the author wrote "drain, and if
5582 // drain doesn't clean up, force-discard", but the operator
5583 // runs declared instructions unconditionally — the `:purge`
5584 // fires whether the `:soft-purge` already discarded the
5585 // module or not, so the imagined fallback semantic is
5586 // missing. Fallback on cleanup failure is the operator's
5587 // job, not authored into the entry. Both kinds carry in
5588 // declaration order so the author can grep for either side
5589 // and pick one.
5590 let e = entry(
5591 "0.1.0",
5592 vec![
5593 UpgradeInstruction::LoadModule { module: "x".into() },
5594 UpgradeInstruction::SoftPurge {
5595 module: "x-old".into(),
5596 },
5597 UpgradeInstruction::Purge {
5598 module: "x-old".into(),
5599 },
5600 ],
5601 );
5602 let err = e.validate().unwrap_err();
5603 assert_eq!(
5604 err,
5605 UpgradeError::DuplicateCleanup {
5606 from: "0.1.0".into(),
5607 module: "x-old".into(),
5608 kinds: vec![
5609 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5610 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5611 ],
5612 },
5613 );
5614 }
5615
5616 #[test]
5617 fn validate_rejects_purge_then_soft_purge_for_same_module() {
5618 // Reversed-ordering arm: `:purge` discards immediately; the
5619 // trailing `:soft-purge` has no module to drain. The kinds
5620 // list reflects declaration order so the diagnostic locates
5621 // both forms in the source.
5622 let e = entry(
5623 "0.1.0",
5624 vec![
5625 UpgradeInstruction::LoadModule { module: "x".into() },
5626 UpgradeInstruction::Purge {
5627 module: "x-old".into(),
5628 },
5629 UpgradeInstruction::SoftPurge {
5630 module: "x-old".into(),
5631 },
5632 ],
5633 );
5634 let err = e.validate().unwrap_err();
5635 assert_eq!(
5636 err,
5637 UpgradeError::DuplicateCleanup {
5638 from: "0.1.0".into(),
5639 module: "x-old".into(),
5640 kinds: vec![
5641 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5642 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5643 ],
5644 },
5645 );
5646 }
5647
5648 #[test]
5649 fn validate_accepts_distinct_cleanup_modules() {
5650 // Positive control: `:soft-purge` and `:purge` on *different*
5651 // modules pass the gate. Mirrors
5652 // `validate_accepts_multiple_purges_after_one_load` — the
5653 // cleanup-singularity gate is keyed on (module), not on
5654 // (kind, module) pair, so distinct old-version names render
5655 // distinct cleanup targets and don't collide. Sweep both
5656 // same-class (two `:soft-purge` distinct modules) and cross-
5657 // class (`:soft-purge` then `:purge` distinct modules) so a
5658 // future tighten to a kind-only key (which would over-fire on
5659 // distinct modules) surfaces here.
5660 let two_soft = entry(
5661 "0.1.0",
5662 vec![
5663 UpgradeInstruction::LoadModule { module: "x".into() },
5664 UpgradeInstruction::SoftPurge {
5665 module: "x-old".into(),
5666 },
5667 UpgradeInstruction::SoftPurge {
5668 module: "x-older".into(),
5669 },
5670 ],
5671 );
5672 two_soft.validate().unwrap();
5673 let mixed = entry(
5674 "0.1.0",
5675 vec![
5676 UpgradeInstruction::LoadModule { module: "x".into() },
5677 UpgradeInstruction::SoftPurge {
5678 module: "x-old".into(),
5679 },
5680 UpgradeInstruction::Purge {
5681 module: "x-oldest".into(),
5682 },
5683 ],
5684 );
5685 mixed.validate().unwrap();
5686 }
5687
5688 #[test]
5689 fn validate_accepts_single_cleanup_per_module() {
5690 // Boundary control: a list with exactly one `:soft-purge` and
5691 // one `:purge` (distinct modules, the canonical "drain one,
5692 // hard-discard the other" shape) is the gate's identity
5693 // element. Pin so a future off-by-one in the duplicate-detection
5694 // scan doesn't accidentally flag a single occurrence as
5695 // duplicating itself — mirrors
5696 // `validate_upgrade_from_single_entry_never_duplicates` on
5697 // the peer cross-entry duplicate axis.
5698 let e = entry(
5699 "0.1.0",
5700 vec![
5701 UpgradeInstruction::LoadModule { module: "x".into() },
5702 UpgradeInstruction::SoftPurge {
5703 module: "x-old".into(),
5704 },
5705 UpgradeInstruction::Purge {
5706 module: "y-old".into(),
5707 },
5708 ],
5709 );
5710 e.validate().unwrap();
5711 }
5712
5713 #[test]
5714 fn validate_cleanup_singularity_fires_after_purge_ordering() {
5715 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5716 // (:soft-purge "x"))` is *both* purge-without-load and
5717 // duplicate-cleanup. The more-fundamental ordering gate must
5718 // win — the missing-load defect is load-bearing (the canonical
5719 // OTP shape requires the new code be resident before any
5720 // cleanup runs), and surfacing the duplicate diagnostic first
5721 // would mask the no-replacement-window defect the ordering
5722 // gate exists to close. Guards the call order in `validate`
5723 // against silent reordering. Same posture as
5724 // `validate_purge_ordering_fires_after_state_change_ordering`
5725 // on the sibling ordering gate.
5726 let e = entry(
5727 "0.1.0",
5728 vec![
5729 UpgradeInstruction::SoftPurge {
5730 module: "x-old".into(),
5731 },
5732 UpgradeInstruction::SoftPurge {
5733 module: "x-old".into(),
5734 },
5735 ],
5736 );
5737 let err = e.validate().unwrap_err();
5738 assert!(
5739 matches!(
5740 err,
5741 UpgradeError::PurgeWithoutPriorLoad {
5742 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5743 ..
5744 }
5745 ),
5746 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5747 );
5748 }
5749
5750 #[test]
5751 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5752 // Order pin: a malformed `:module` value on a `:soft-purge`
5753 // (an empty string) surfaces its narrower kind-tagged
5754 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5755 // singularity gate fires. The per-instruction shape pass walks
5756 // the list inline before the singularity check, so the
5757 // narrower self-locating diagnostic surfaces first — mirrors
5758 // the empty-first cascade on every peer DNS-1123 gate and the
5759 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5760 // the sibling ordering gate.
5761 //
5762 // Two empty-string `:soft-purge` would *otherwise* duplicate
5763 // (both modules are the same empty string), so this pin
5764 // double-locks the precedence: the per-instr shape gate must
5765 // win on the first malformed instruction before the duplicate
5766 // scan even reaches the second.
5767 let e = entry(
5768 "0.1.0",
5769 vec![
5770 UpgradeInstruction::LoadModule { module: "x".into() },
5771 UpgradeInstruction::SoftPurge {
5772 module: String::new(),
5773 },
5774 UpgradeInstruction::SoftPurge {
5775 module: String::new(),
5776 },
5777 ],
5778 );
5779 let err = e.validate().unwrap_err();
5780 assert_eq!(
5781 err,
5782 UpgradeError::ModuleEmpty {
5783 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5784 },
5785 "malformed instruction must surface its kind-tagged diagnostic before the \
5786 cleanup-singularity gate fires, got {err:?}"
5787 );
5788 }
5789
5790 #[test]
5791 fn validate_cleanup_singularity_reports_first_collision() {
5792 // Determinism pin: with three cleanups of the same module the
5793 // gate reports the *first* collision (the second occurrence)
5794 // and stops — the third's duplicate is masked by the first
5795 // surfaced one. Mirrors
5796 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5797 // on the peer cross-entry duplicate axis.
5798 let e = entry(
5799 "0.1.0",
5800 vec![
5801 UpgradeInstruction::LoadModule { module: "x".into() },
5802 UpgradeInstruction::SoftPurge {
5803 module: "x-old".into(),
5804 },
5805 UpgradeInstruction::SoftPurge {
5806 module: "x-old".into(),
5807 },
5808 UpgradeInstruction::Purge {
5809 module: "x-old".into(),
5810 },
5811 ],
5812 );
5813 let err = e.validate().unwrap_err();
5814 assert_eq!(
5815 err,
5816 UpgradeError::DuplicateCleanup {
5817 from: "0.1.0".into(),
5818 module: "x-old".into(),
5819 kinds: vec![
5820 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5821 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5822 ],
5823 },
5824 "the first colliding pair must surface, not the later `:purge` collision"
5825 );
5826 }
5827
5828 #[test]
5829 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
5830 // The whole-list entry-point surfaces the per-entry singularity
5831 // error (mirrors
5832 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
5833 // the gate is reachable from the LayoutInvariants call site,
5834 // not only from a direct `entry.validate()`.
5835 let entries = vec![entry(
5836 "0.1.0",
5837 vec![
5838 UpgradeInstruction::LoadModule { module: "x".into() },
5839 UpgradeInstruction::SoftPurge {
5840 module: "x-old".into(),
5841 },
5842 UpgradeInstruction::Purge {
5843 module: "x-old".into(),
5844 },
5845 ],
5846 )];
5847 let err = validate_upgrade_from(&entries).unwrap_err();
5848 assert!(
5849 matches!(err, UpgradeError::DuplicateCleanup { .. }),
5850 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
5851 );
5852 }
5853
5854 #[test]
5855 fn validate_rejects_duplicate_load_module_for_same_module() {
5856 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
5857 // §II.4): each module is loaded exactly once per upgrade entry,
5858 // the operator's dispatch table reads the module name to bind
5859 // the wasm component, and a second `(:load-module "x")` re-reads
5860 // the same module name and re-binds the same component — a
5861 // no-op the second time. systools-generated `.relup` files emit
5862 // at most one `load_module` per module per upgrade step for
5863 // this reason. Author one `(:load-module "x")` per old module.
5864 let e = entry(
5865 "0.1.0",
5866 vec![
5867 UpgradeInstruction::LoadModule { module: "x".into() },
5868 UpgradeInstruction::LoadModule { module: "x".into() },
5869 ],
5870 );
5871 let err = e.validate().unwrap_err();
5872 assert_eq!(
5873 err,
5874 UpgradeError::DuplicateLoadModule {
5875 from: "0.1.0".into(),
5876 module: "x".into(),
5877 },
5878 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
5879 the module, got {err:?}"
5880 );
5881 }
5882
5883 #[test]
5884 fn validate_accepts_distinct_load_modules() {
5885 // Positive control: `:load-module` instructions on *different*
5886 // modules pass the gate. Mirrors
5887 // `validate_accepts_distinct_cleanup_modules` on the sibling
5888 // singularity axis — the load-singularity gate is keyed on
5889 // (module), so distinct module names render distinct load
5890 // targets and don't collide. Sweep both the bare two-load shape
5891 // and the canonical load-pair-with-cleanup shape so a future
5892 // tighten that over-fires on distinct loads surfaces here.
5893 let two_loads = entry(
5894 "0.1.0",
5895 vec![
5896 UpgradeInstruction::LoadModule { module: "x".into() },
5897 UpgradeInstruction::LoadModule { module: "y".into() },
5898 ],
5899 );
5900 two_loads.validate().unwrap();
5901 let with_cleanup = entry(
5902 "0.1.0",
5903 vec![
5904 UpgradeInstruction::LoadModule { module: "x".into() },
5905 UpgradeInstruction::LoadModule { module: "y".into() },
5906 UpgradeInstruction::SoftPurge {
5907 module: "x-old".into(),
5908 },
5909 UpgradeInstruction::SoftPurge {
5910 module: "y-old".into(),
5911 },
5912 ],
5913 );
5914 with_cleanup.validate().unwrap();
5915 }
5916
5917 #[test]
5918 fn validate_accepts_single_load_per_module() {
5919 // Boundary control: a list with exactly one `:load-module`
5920 // followed by the canonical `:state-change` + `:soft-purge`
5921 // sequence (the module-doc OTP shape) is the gate's identity
5922 // element. Pin so a future off-by-one in the duplicate-
5923 // detection scan doesn't accidentally flag a single occurrence
5924 // as duplicating itself — mirrors
5925 // `validate_accepts_single_cleanup_per_module` on the sibling
5926 // singularity axis.
5927 let e = entry(
5928 "0.1.0",
5929 vec![
5930 UpgradeInstruction::LoadModule { module: "x".into() },
5931 UpgradeInstruction::StateChange {
5932 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5933 },
5934 UpgradeInstruction::SoftPurge {
5935 module: "x-old".into(),
5936 },
5937 ],
5938 );
5939 e.validate().unwrap();
5940 }
5941
5942 #[test]
5943 fn validate_load_singularity_fires_after_state_change_ordering() {
5944 // Diagnostic-precedence pin: an entry like `((:state-change
5945 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
5946 // state-change-without-load and duplicate-load. The more-
5947 // fundamental ordering gate must win — the missing-load defect
5948 // is load-bearing (the migration runs against unloaded code),
5949 // and surfacing the duplicate diagnostic first would mask the
5950 // migrate-into-unloaded-code defect the ordering gate exists
5951 // to close. Guards the call order in `validate` against silent
5952 // reordering. Same posture as
5953 // `validate_cleanup_singularity_fires_after_purge_ordering`
5954 // on the sibling singularity gate.
5955 let e = entry(
5956 "0.1.0",
5957 vec![
5958 UpgradeInstruction::StateChange {
5959 script: PathBuf::from("lib/m.lisp"),
5960 },
5961 UpgradeInstruction::LoadModule { module: "x".into() },
5962 UpgradeInstruction::LoadModule { module: "x".into() },
5963 ],
5964 );
5965 let err = e.validate().unwrap_err();
5966 assert!(
5967 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5968 "state-change-without-load must surface before duplicate-load, got {err:?}"
5969 );
5970 }
5971
5972 #[test]
5973 fn validate_load_singularity_fires_after_purge_ordering() {
5974 // Diagnostic-precedence pin: an entry like `((:soft-purge
5975 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
5976 // purge-without-load and duplicate-load. The more-fundamental
5977 // ordering gate must win — the missing-load defect is load-
5978 // bearing (the cleanup runs against no-replacement-window),
5979 // and surfacing the duplicate diagnostic first would mask the
5980 // drain-to-nothing defect the ordering gate exists to close.
5981 // Sibling of
5982 // `validate_cleanup_singularity_fires_after_purge_ordering` on
5983 // the load-singularity axis.
5984 let e = entry(
5985 "0.1.0",
5986 vec![
5987 UpgradeInstruction::SoftPurge {
5988 module: "x-old".into(),
5989 },
5990 UpgradeInstruction::LoadModule { module: "x".into() },
5991 UpgradeInstruction::LoadModule { module: "x".into() },
5992 ],
5993 );
5994 let err = e.validate().unwrap_err();
5995 assert!(
5996 matches!(
5997 err,
5998 UpgradeError::PurgeWithoutPriorLoad {
5999 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6000 ..
6001 }
6002 ),
6003 "purge-without-load must surface before duplicate-load, got {err:?}"
6004 );
6005 }
6006
6007 #[test]
6008 fn validate_load_singularity_fires_after_per_instr_shape() {
6009 // Order pin: a malformed `:module` value on a `:load-module`
6010 // (an empty string) surfaces its narrower kind-tagged
6011 // `ModuleEmpty` diagnostic *before* the within-entry load-
6012 // singularity gate fires. The per-instruction shape pass walks
6013 // the list inline before the singularity check, so the
6014 // narrower self-locating diagnostic surfaces first — mirrors
6015 // the empty-first cascade on every peer DNS-1123 gate and the
6016 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6017 // pin on the sibling singularity gate.
6018 //
6019 // Two empty-string `:load-module` would *otherwise* duplicate
6020 // (both modules are the same empty string), so this pin
6021 // double-locks the precedence: the per-instr shape gate must
6022 // win on the first malformed instruction before the duplicate
6023 // scan even reaches the second.
6024 let e = entry(
6025 "0.1.0",
6026 vec![
6027 UpgradeInstruction::LoadModule {
6028 module: String::new(),
6029 },
6030 UpgradeInstruction::LoadModule {
6031 module: String::new(),
6032 },
6033 ],
6034 );
6035 let err = e.validate().unwrap_err();
6036 assert_eq!(
6037 err,
6038 UpgradeError::ModuleEmpty {
6039 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6040 },
6041 "malformed instruction must surface its kind-tagged diagnostic before the \
6042 load-singularity gate fires, got {err:?}"
6043 );
6044 }
6045
6046 #[test]
6047 fn validate_load_singularity_fires_before_cleanup_singularity() {
6048 // Diagnostic-precedence pin: an entry that violates *both*
6049 // singularities — duplicate load on "x" *and* duplicate cleanup
6050 // on "y-old" — must surface the load-side diagnostic first.
6051 // The load axis precedes the cleanup axis in the canonical OTP
6052 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6053 // in [`UpgradeInstruction`] declaration order (LoadModule
6054 // before SoftPurge/Purge), so the load-side singularity is the
6055 // load-bearing diagnostic when both fire — the cleanup-side
6056 // duplicate is meaningless either way without a coherent load.
6057 // Guards the call order in `validate`: `validate_load_singularity`
6058 // runs before `validate_cleanup_singularity`.
6059 let e = entry(
6060 "0.1.0",
6061 vec![
6062 UpgradeInstruction::LoadModule { module: "x".into() },
6063 UpgradeInstruction::LoadModule { module: "x".into() },
6064 UpgradeInstruction::SoftPurge {
6065 module: "y-old".into(),
6066 },
6067 UpgradeInstruction::SoftPurge {
6068 module: "y-old".into(),
6069 },
6070 ],
6071 );
6072 let err = e.validate().unwrap_err();
6073 assert_eq!(
6074 err,
6075 UpgradeError::DuplicateLoadModule {
6076 from: "0.1.0".into(),
6077 module: "x".into(),
6078 },
6079 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6080 );
6081 }
6082
6083 #[test]
6084 fn validate_load_singularity_reports_first_collision() {
6085 // Determinism pin: with three loads of the same module the gate
6086 // reports the *first* collision (the second occurrence) and
6087 // stops — the third's duplicate is masked by the first surfaced
6088 // one. Mirrors
6089 // `validate_cleanup_singularity_reports_first_collision` on the
6090 // sibling singularity axis and every peer duplicate gate's
6091 // first-collision discipline.
6092 let e = entry(
6093 "0.1.0",
6094 vec![
6095 UpgradeInstruction::LoadModule { module: "x".into() },
6096 UpgradeInstruction::LoadModule { module: "x".into() },
6097 UpgradeInstruction::LoadModule { module: "x".into() },
6098 ],
6099 );
6100 let err = e.validate().unwrap_err();
6101 assert_eq!(
6102 err,
6103 UpgradeError::DuplicateLoadModule {
6104 from: "0.1.0".into(),
6105 module: "x".into(),
6106 },
6107 "the first colliding occurrence must surface, not the later third-load collision"
6108 );
6109 }
6110
6111 #[test]
6112 fn validate_load_singularity_threads_through_validate_upgrade_from() {
6113 // The whole-list entry-point surfaces the per-entry singularity
6114 // error (mirrors
6115 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6116 // the gate is reachable from the LayoutInvariants call site,
6117 // not only from a direct `entry.validate()`.
6118 let entries = vec![entry(
6119 "0.1.0",
6120 vec![
6121 UpgradeInstruction::LoadModule { module: "x".into() },
6122 UpgradeInstruction::LoadModule { module: "x".into() },
6123 ],
6124 )];
6125 let err = validate_upgrade_from(&entries).unwrap_err();
6126 assert!(
6127 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6128 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6129 );
6130 }
6131
6132 // ── within-entry state-change-singularity invariant ────────────────
6133
6134 #[test]
6135 fn validate_rejects_duplicate_state_change_for_same_script() {
6136 // `StateChange` is the `gen_server:code_change/3` analog
6137 // (INSPIRATIONS §II.4): the script folds the prior-version
6138 // state shape into the current-version shape — a one-shot
6139 // transition, not a step that composes with itself. OTP's
6140 // release_handler invokes `code_change/3` exactly once per
6141 // upgrade per gen_server; systools-generated `.relup` files
6142 // emit at most one `code_change` per gen_server per upgrade
6143 // step for this reason. A second `(:state-change "m.lisp")`
6144 // re-runs the same fold on the already-migrated state — at
6145 // best a no-op and at worst silent state corruption from
6146 // double-applied non-idempotent transforms (`add column`,
6147 // `increment counter`, `rename field`). Author one
6148 // `(:state-change "m.lisp")` per migration script per entry.
6149 let e = entry(
6150 "0.1.0",
6151 vec![
6152 UpgradeInstruction::LoadModule { module: "x".into() },
6153 UpgradeInstruction::StateChange {
6154 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6155 },
6156 UpgradeInstruction::StateChange {
6157 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6158 },
6159 ],
6160 );
6161 let err = e.validate().unwrap_err();
6162 assert_eq!(
6163 err,
6164 UpgradeError::DuplicateStateChange {
6165 from: "0.1.0".into(),
6166 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6167 },
6168 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6169 the script, got {err:?}"
6170 );
6171 }
6172
6173 #[test]
6174 fn validate_accepts_distinct_state_change_scripts() {
6175 // Positive control: `:state-change` instructions on *different*
6176 // scripts pass the gate. Mirrors
6177 // `validate_accepts_distinct_cleanup_modules` /
6178 // `validate_accepts_distinct_load_modules` on the sibling
6179 // singularity axes — the state-change-singularity gate is keyed
6180 // on the script PathBuf, so distinct scripts render distinct
6181 // migration targets and don't collide. Sweep both the bare two-
6182 // migration shape and the canonical load-pair-with-cleanup shape
6183 // so a future tighten that over-fires on distinct scripts
6184 // surfaces here. This positive control is the gate-level peer of
6185 // `validate_accepts_multiple_state_changes_after_one_load` (the
6186 // ordering-gate positive control on distinct scripts), pinned
6187 // here independently so a future refactor that decouples the
6188 // gates can't accidentally drop coverage on either.
6189 let two_migrations = entry(
6190 "0.1.0",
6191 vec![
6192 UpgradeInstruction::LoadModule { module: "x".into() },
6193 UpgradeInstruction::StateChange {
6194 script: PathBuf::from("lib/m1.lisp"),
6195 },
6196 UpgradeInstruction::StateChange {
6197 script: PathBuf::from("lib/m2.lisp"),
6198 },
6199 ],
6200 );
6201 two_migrations.validate().unwrap();
6202 let with_cleanup = entry(
6203 "0.1.0",
6204 vec![
6205 UpgradeInstruction::LoadModule { module: "x".into() },
6206 UpgradeInstruction::StateChange {
6207 script: PathBuf::from("lib/m1.lisp"),
6208 },
6209 UpgradeInstruction::StateChange {
6210 script: PathBuf::from("lib/m2.lisp"),
6211 },
6212 UpgradeInstruction::SoftPurge {
6213 module: "x-old".into(),
6214 },
6215 ],
6216 );
6217 with_cleanup.validate().unwrap();
6218 }
6219
6220 #[test]
6221 fn validate_accepts_single_state_change_per_script() {
6222 // Boundary control: a list with exactly one `:state-change`
6223 // wrapped by the canonical `:load-module` + `:soft-purge`
6224 // sequence (the module-doc OTP shape) is the gate's identity
6225 // element. Pin so a future off-by-one in the duplicate-
6226 // detection scan doesn't accidentally flag a single occurrence
6227 // as duplicating itself — mirrors
6228 // `validate_accepts_single_load_per_module` /
6229 // `validate_accepts_single_cleanup_per_module` on the sibling
6230 // singularity axes.
6231 let e = entry(
6232 "0.1.0",
6233 vec![
6234 UpgradeInstruction::LoadModule { module: "x".into() },
6235 UpgradeInstruction::StateChange {
6236 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6237 },
6238 UpgradeInstruction::SoftPurge {
6239 module: "x-old".into(),
6240 },
6241 ],
6242 );
6243 e.validate().unwrap();
6244 }
6245
6246 #[test]
6247 fn validate_state_change_singularity_fires_after_state_change_ordering() {
6248 // Diagnostic-precedence pin: an entry like `((:state-change
6249 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6250 // without-load and duplicate-state-change. The more-fundamental
6251 // ordering gate must win — the missing-load defect is load-
6252 // bearing (the migration runs against unloaded code), and
6253 // surfacing the duplicate diagnostic first would mask the
6254 // migrate-into-unloaded-code defect the ordering gate exists to
6255 // close. Guards the call order in `validate` against silent
6256 // reordering. Same posture as
6257 // `validate_load_singularity_fires_after_state_change_ordering`
6258 // on the sibling singularity gate.
6259 //
6260 // Two same-script `:state-change` would *otherwise* duplicate
6261 // (both scripts collide on the very first `:state-change`-
6262 // without-load encountered), so this pin double-locks the
6263 // precedence: the ordering gate must win on the first un-loaded
6264 // `:state-change` before the singularity scan even reaches the
6265 // second.
6266 let e = entry(
6267 "0.1.0",
6268 vec![
6269 UpgradeInstruction::StateChange {
6270 script: PathBuf::from("lib/m.lisp"),
6271 },
6272 UpgradeInstruction::StateChange {
6273 script: PathBuf::from("lib/m.lisp"),
6274 },
6275 ],
6276 );
6277 let err = e.validate().unwrap_err();
6278 assert!(
6279 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6280 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6281 );
6282 }
6283
6284 #[test]
6285 fn validate_state_change_singularity_fires_after_purge_ordering() {
6286 // Diagnostic-precedence pin: an entry like `((:soft-purge
6287 // "x-old") (:load-module "x") (:state-change "m.lisp")
6288 // (:state-change "m.lisp"))` is *both* purge-without-load and
6289 // duplicate-state-change. The more-fundamental ordering gate
6290 // must win — the missing-load defect (a cleanup that drains the
6291 // only resident version to nothing) is load-bearing, and
6292 // surfacing the duplicate diagnostic first would mask the
6293 // drain-to-nothing defect the ordering gate exists to close.
6294 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6295 // on the state-change-singularity axis.
6296 let e = entry(
6297 "0.1.0",
6298 vec![
6299 UpgradeInstruction::SoftPurge {
6300 module: "x-old".into(),
6301 },
6302 UpgradeInstruction::LoadModule { module: "x".into() },
6303 UpgradeInstruction::StateChange {
6304 script: PathBuf::from("lib/m.lisp"),
6305 },
6306 UpgradeInstruction::StateChange {
6307 script: PathBuf::from("lib/m.lisp"),
6308 },
6309 ],
6310 );
6311 let err = e.validate().unwrap_err();
6312 assert!(
6313 matches!(
6314 err,
6315 UpgradeError::PurgeWithoutPriorLoad {
6316 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6317 ..
6318 }
6319 ),
6320 "purge-without-load must surface before duplicate-state-change, got {err:?}"
6321 );
6322 }
6323
6324 #[test]
6325 fn validate_state_change_singularity_fires_after_per_instr_shape() {
6326 // Order pin: a malformed `:script` value on a `:state-change`
6327 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6328 // *before* the within-entry state-change-singularity gate fires.
6329 // The per-instruction shape pass walks the list inline before
6330 // the singularity check, so the narrower self-locating
6331 // diagnostic surfaces first — mirrors the empty-first cascade on
6332 // every peer path-shape gate and the
6333 // `validate_load_singularity_fires_after_per_instr_shape` /
6334 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6335 // pins on the sibling singularity gates.
6336 //
6337 // Two empty-path `:state-change` would *otherwise* duplicate
6338 // (both scripts are the same empty PathBuf), so this pin double-
6339 // locks the precedence: the per-instr shape gate must win on the
6340 // first malformed instruction before the duplicate scan even
6341 // reaches the second.
6342 let e = entry(
6343 "0.1.0",
6344 vec![
6345 UpgradeInstruction::LoadModule { module: "x".into() },
6346 UpgradeInstruction::StateChange {
6347 script: PathBuf::new(),
6348 },
6349 UpgradeInstruction::StateChange {
6350 script: PathBuf::new(),
6351 },
6352 ],
6353 );
6354 let err = e.validate().unwrap_err();
6355 assert_eq!(
6356 err,
6357 UpgradeError::EmptyScript,
6358 "malformed instruction must surface its narrower diagnostic before the \
6359 state-change-singularity gate fires, got {err:?}"
6360 );
6361 }
6362
6363 #[test]
6364 fn validate_state_change_singularity_fires_after_load_singularity() {
6365 // Diagnostic-precedence pin: an entry that violates *both*
6366 // singularities — duplicate load on "x" *and* duplicate
6367 // state-change on "m.lisp" — must surface the load-side
6368 // diagnostic first. The load axis precedes the migration axis
6369 // in the canonical OTP sequence (`code:load_module/1` then
6370 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6371 // declaration order (LoadModule before StateChange), so the
6372 // load-side singularity is the load-bearing diagnostic when
6373 // both fire — the migration-side duplicate is meaningless
6374 // either way without a coherent load. Guards the call order in
6375 // `validate`: `validate_load_singularity` runs before
6376 // `validate_state_change_singularity`.
6377 let e = entry(
6378 "0.1.0",
6379 vec![
6380 UpgradeInstruction::LoadModule { module: "x".into() },
6381 UpgradeInstruction::LoadModule { module: "x".into() },
6382 UpgradeInstruction::StateChange {
6383 script: PathBuf::from("lib/m.lisp"),
6384 },
6385 UpgradeInstruction::StateChange {
6386 script: PathBuf::from("lib/m.lisp"),
6387 },
6388 ],
6389 );
6390 let err = e.validate().unwrap_err();
6391 assert_eq!(
6392 err,
6393 UpgradeError::DuplicateLoadModule {
6394 from: "0.1.0".into(),
6395 module: "x".into(),
6396 },
6397 "duplicate-load must surface before duplicate-state-change, got {err:?}"
6398 );
6399 }
6400
6401 #[test]
6402 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6403 // Diagnostic-precedence pin: an entry that violates *both*
6404 // singularities — duplicate state-change on "m.lisp" *and*
6405 // duplicate cleanup on "y-old" — must surface the migration-
6406 // side diagnostic first. The migration axis precedes the
6407 // cleanup axis in the canonical OTP sequence
6408 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6409 // [`UpgradeInstruction`] declaration order (StateChange before
6410 // SoftPurge/Purge), so the migration-side singularity is the
6411 // load-bearing diagnostic when both fire — the cleanup-side
6412 // duplicate is irrelevant once the migration has corrupted
6413 // state by double-applying. Guards the call order in
6414 // `validate`: `validate_state_change_singularity` runs before
6415 // `validate_cleanup_singularity`.
6416 let e = entry(
6417 "0.1.0",
6418 vec![
6419 UpgradeInstruction::LoadModule { module: "x".into() },
6420 UpgradeInstruction::StateChange {
6421 script: PathBuf::from("lib/m.lisp"),
6422 },
6423 UpgradeInstruction::StateChange {
6424 script: PathBuf::from("lib/m.lisp"),
6425 },
6426 UpgradeInstruction::SoftPurge {
6427 module: "y-old".into(),
6428 },
6429 UpgradeInstruction::SoftPurge {
6430 module: "y-old".into(),
6431 },
6432 ],
6433 );
6434 let err = e.validate().unwrap_err();
6435 assert_eq!(
6436 err,
6437 UpgradeError::DuplicateStateChange {
6438 from: "0.1.0".into(),
6439 script: PathBuf::from("lib/m.lisp"),
6440 },
6441 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6442 );
6443 }
6444
6445 #[test]
6446 fn validate_state_change_singularity_reports_first_collision() {
6447 // Determinism pin: with three state-changes on the same script
6448 // the gate reports the *first* collision (the second
6449 // occurrence) and stops — the third's duplicate is masked by
6450 // the first surfaced one. Mirrors
6451 // `validate_load_singularity_reports_first_collision` /
6452 // `validate_cleanup_singularity_reports_first_collision` on the
6453 // sibling singularity axes and every peer duplicate gate's
6454 // first-collision discipline.
6455 let e = entry(
6456 "0.1.0",
6457 vec![
6458 UpgradeInstruction::LoadModule { module: "x".into() },
6459 UpgradeInstruction::StateChange {
6460 script: PathBuf::from("lib/m.lisp"),
6461 },
6462 UpgradeInstruction::StateChange {
6463 script: PathBuf::from("lib/m.lisp"),
6464 },
6465 UpgradeInstruction::StateChange {
6466 script: PathBuf::from("lib/m.lisp"),
6467 },
6468 ],
6469 );
6470 let err = e.validate().unwrap_err();
6471 assert_eq!(
6472 err,
6473 UpgradeError::DuplicateStateChange {
6474 from: "0.1.0".into(),
6475 script: PathBuf::from("lib/m.lisp"),
6476 },
6477 "the first colliding occurrence must surface, not the later third-migration collision"
6478 );
6479 }
6480
6481 #[test]
6482 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6483 // The whole-list entry-point surfaces the per-entry singularity
6484 // error (mirrors
6485 // `validate_load_singularity_threads_through_validate_upgrade_from`
6486 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6487 // the gate is reachable from the LayoutInvariants call site,
6488 // not only from a direct `entry.validate()`.
6489 let entries = vec![entry(
6490 "0.1.0",
6491 vec![
6492 UpgradeInstruction::LoadModule { module: "x".into() },
6493 UpgradeInstruction::StateChange {
6494 script: PathBuf::from("lib/m.lisp"),
6495 },
6496 UpgradeInstruction::StateChange {
6497 script: PathBuf::from("lib/m.lisp"),
6498 },
6499 ],
6500 )];
6501 let err = validate_upgrade_from(&entries).unwrap_err();
6502 assert!(
6503 matches!(err, UpgradeError::DuplicateStateChange { .. }),
6504 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6505 );
6506 }
6507
6508 #[test]
6509 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6510 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6511 // per-instruction `StateChange`-arm script-path projection must
6512 // route through the sibling lifted
6513 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6514 // accessor, not the raw
6515 // `match instr { UpgradeInstruction::StateChange { script } =>
6516 // script.as_path(), _ => continue }` open-coded pattern-match
6517 // the gate previously carried.
6518 //
6519 // Structurally: the gate's projection accept-set is the union
6520 // of every [`UpgradeInstruction`] variant for which
6521 // `declared_path().is_some()` — today exactly
6522 // [`UpgradeInstruction::StateChange`] per the sibling
6523 // `declared_path_only_for_state_change` pin, so a
6524 // duplicate-scripts input trips `DuplicateStateChange` and a
6525 // non-`StateChange` input (module-bearing / terminal) leaves
6526 // `seen` empty and the gate returns `Ok(())` byte-identical to
6527 // the pattern-match shape.
6528 //
6529 // Byte-equal today (`declared_path` returns `Some(script)` iff
6530 // `StateChange`, byte-for-byte from the variant's own storage);
6531 // the pin catches any future accessor extension that promotes
6532 // an additional variant onto the `PathBuf`-carrying axis — the
6533 // gate then fires on duplicate scripts from that variant too,
6534 // and the singularity discipline the sibling
6535 // `validate_load_singularity` / `validate_cleanup_singularity`
6536 // gates share on the `String`-carrying axis's per-variant
6537 // consumers extends to the promoted variant by construction.
6538 //
6539 // Peer of the sibling four per-`UpgradeInstruction` consumers
6540 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6541 // sandbox-path fan-out, the layout-side per-`StateChange`
6542 // script-existence fan-out at
6543 // `caixa-core/src/layout.rs:1017`, the cross-slot
6544 // [`validate_upgrade_from_against_behavior`] gate's per-
6545 // `StateChange` detection loop, the peer
6546 // [`UpgradeInstruction::declared_module`] `String`-axis
6547 // per-variant unifier) — this gate now shares one typed
6548 // dispatch on the substrate primitive's `PathBuf`-carrying
6549 // axis with those consumers, so a future rebrand on the axis
6550 // migrates as a single caixa-core edit rather than a
6551 // coordinated rewrite of five call sites.
6552 //
6553 // Three-arm projective coverage:
6554 // (a) `StateChange` scripts project through `declared_path()`
6555 // byte-equal to the raw `script.as_path()` field access;
6556 // (b) a duplicate-`StateChange` input trips the gate on the
6557 // second occurrence with `DuplicateStateChange` carrying
6558 // the offending script verbatim;
6559 // (c) a non-`StateChange`-only input (`LoadModule` /
6560 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6561 // vacuous with `Ok(())` — the `declared_path().is_none()`
6562 // arm's `continue` fall-through pins.
6563 //
6564 // Fail-before-pass-after verified locally: swapping the
6565 // production `let Some(script) = instr.declared_path() else {
6566 // continue };` back to `let script = match instr {
6567 // UpgradeInstruction::StateChange { script } =>
6568 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6569 // passing but silently detaches the gate from the accessor's
6570 // typed dispatch — any future `declared_path` extension
6571 // (promotion of an additional variant onto the axis, an
6572 // operator-side pre-resolved-path cache the accessor
6573 // materializes) would then silently disagree between this
6574 // gate's raw pattern-match and the peer four sibling consumers
6575 // that route through the accessor.
6576 use std::path::PathBuf;
6577
6578 // (a) StateChange projection byte-equal via declared_path.
6579 let sc = UpgradeInstruction::StateChange {
6580 script: PathBuf::from("lib/m.lisp"),
6581 };
6582 assert_eq!(
6583 sc.declared_path().map(std::path::PathBuf::as_path),
6584 Some(PathBuf::from("lib/m.lisp").as_path()),
6585 "declared_path() must project the StateChange :script byte-equal to the raw \
6586 field access — accessor divergence would silently detach the gate from the \
6587 projection every peer per-`UpgradeInstruction` consumer routes through"
6588 );
6589
6590 // (b) Duplicate-StateChange input trips the gate.
6591 let dup = entry(
6592 "0.1.0",
6593 vec![
6594 UpgradeInstruction::LoadModule { module: "x".into() },
6595 UpgradeInstruction::StateChange {
6596 script: PathBuf::from("lib/m.lisp"),
6597 },
6598 UpgradeInstruction::StateChange {
6599 script: PathBuf::from("lib/m.lisp"),
6600 },
6601 ],
6602 );
6603 assert_eq!(
6604 dup.validate_state_change_singularity(),
6605 Err(UpgradeError::DuplicateStateChange {
6606 from: "0.1.0".into(),
6607 script: PathBuf::from("lib/m.lisp"),
6608 }),
6609 "duplicate StateChange scripts must trip the gate on the second occurrence \
6610 through the declared_path accessor's Some(script) arm"
6611 );
6612
6613 // (c) Non-StateChange-only inputs leave the gate vacuous.
6614 for instrs in [
6615 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6616 vec![
6617 UpgradeInstruction::LoadModule { module: "x".into() },
6618 UpgradeInstruction::SoftPurge {
6619 module: "x-old".into(),
6620 },
6621 ],
6622 vec![
6623 UpgradeInstruction::LoadModule { module: "x".into() },
6624 UpgradeInstruction::Purge {
6625 module: "x-old".into(),
6626 },
6627 ],
6628 vec![UpgradeInstruction::Restart],
6629 ] {
6630 for instr in &instrs {
6631 assert!(
6632 instr.declared_path().is_none(),
6633 "non-StateChange variants must project None through declared_path — \
6634 accessor divergence would let this gate silently fire on a duplicate \
6635 module reference far from any :state-change site"
6636 );
6637 }
6638 let e = entry("0.1.0", instrs);
6639 assert_eq!(
6640 e.validate_state_change_singularity(),
6641 Ok(()),
6642 "the state-change-singularity gate must return Ok(()) on an entry whose \
6643 instructions all project None through declared_path — the accessor's \
6644 continue arm the pattern-match's `_ => continue` previously carried"
6645 );
6646 }
6647 }
6648
6649 // ── within-entry state-change-before-cleanup ordering invariant ──
6650
6651 #[test]
6652 fn validate_rejects_state_change_after_soft_purge() {
6653 // Fail-before-pass-after pin: `:state-change` is the
6654 // gen_server:code_change/3 analog and folds the prior-version
6655 // state shape into the current shape; `:soft-purge` drains the
6656 // prior code. The operator runs instructions in declared order,
6657 // so a `:soft-purge` ahead of a `:state-change` drains the
6658 // prior module before the migration callback runs against the
6659 // state it held — the canonical OTP error mode
6660 // "`code_change/3` invoked on a purged module" the
6661 // release_handler closes by always ordering the migration
6662 // before the cleanup.
6663 let e = entry(
6664 "0.1.0",
6665 vec![
6666 UpgradeInstruction::LoadModule { module: "x".into() },
6667 UpgradeInstruction::SoftPurge {
6668 module: "x-old".into(),
6669 },
6670 UpgradeInstruction::StateChange {
6671 script: PathBuf::from("lib/m.lisp"),
6672 },
6673 ],
6674 );
6675 let err = e.validate().unwrap_err();
6676 assert_eq!(
6677 err,
6678 UpgradeError::StateChangeAfterCleanup {
6679 from: "0.1.0".into(),
6680 script: PathBuf::from("lib/m.lisp"),
6681 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6682 prior_cleanup_module: "x-old".into(),
6683 },
6684 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6685 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6686 );
6687 }
6688
6689 #[test]
6690 fn validate_rejects_state_change_after_purge() {
6691 // Per-arm coverage: `:purge` (immediate discard, no drain) is
6692 // the more catastrophic peer of `:soft-purge` on the cleanup
6693 // axis; same gate, same shape, the `prior_cleanup_kind` field
6694 // distinguishes the diagnostic so the author can grep their
6695 // caixa.lisp for the offending `(:purge …)` form.
6696 let e = entry(
6697 "0.1.0",
6698 vec![
6699 UpgradeInstruction::LoadModule { module: "x".into() },
6700 UpgradeInstruction::Purge {
6701 module: "x-old".into(),
6702 },
6703 UpgradeInstruction::StateChange {
6704 script: PathBuf::from("lib/m.lisp"),
6705 },
6706 ],
6707 );
6708 let err = e.validate().unwrap_err();
6709 assert_eq!(
6710 err,
6711 UpgradeError::StateChangeAfterCleanup {
6712 from: "0.1.0".into(),
6713 script: PathBuf::from("lib/m.lisp"),
6714 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6715 prior_cleanup_module: "x-old".into(),
6716 },
6717 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6718 `prior_cleanup_kind: \":purge\"`, got {err:?}"
6719 );
6720 }
6721
6722 #[test]
6723 fn validate_accepts_state_change_before_cleanup() {
6724 // Positive control: the canonical `(:load-module …)
6725 // (:state-change …) (:soft-purge …)` order validates — the
6726 // exact shape the module doc example and `validate_accepts_
6727 // well_formed` already pin, restated here on the new gate's
6728 // identity element so a future shortcut that runs the
6729 // singularity gates first doesn't silently mask a regression
6730 // here.
6731 let e = entry(
6732 "0.1.0",
6733 vec![
6734 UpgradeInstruction::LoadModule { module: "x".into() },
6735 UpgradeInstruction::StateChange {
6736 script: PathBuf::from("lib/m.lisp"),
6737 },
6738 UpgradeInstruction::SoftPurge {
6739 module: "x-old".into(),
6740 },
6741 ],
6742 );
6743 e.validate().unwrap();
6744 }
6745
6746 #[test]
6747 fn validate_accepts_cleanup_without_state_change() {
6748 // Empty-set identity: an entry that carries no `:state-change`
6749 // at all has nothing to order against the cleanup, so the gate
6750 // passes regardless of how the cleanups are placed (after the
6751 // single required `:load-module`). Mirrors the
6752 // `validate_accepts_multiple_purges_after_one_load` positive
6753 // control on the peer purge-ordering gate; metadata-only
6754 // upgrades with cleanup-but-no-migration land here.
6755 let e = entry(
6756 "0.1.0",
6757 vec![
6758 UpgradeInstruction::LoadModule { module: "x".into() },
6759 UpgradeInstruction::SoftPurge {
6760 module: "x-old".into(),
6761 },
6762 UpgradeInstruction::Purge {
6763 module: "x-oldest".into(),
6764 },
6765 ],
6766 );
6767 e.validate().unwrap();
6768 }
6769
6770 #[test]
6771 fn validate_accepts_state_change_without_cleanup() {
6772 // Empty-set identity on the dual axis: an entry that carries no
6773 // cleanup at all has nothing to order against the state-change,
6774 // so the gate passes — additive-upgrade shapes (load new code,
6775 // migrate state, leave old code resident for in-flight callers
6776 // to drain naturally) land here.
6777 let e = entry(
6778 "0.1.0",
6779 vec![
6780 UpgradeInstruction::LoadModule { module: "x".into() },
6781 UpgradeInstruction::StateChange {
6782 script: PathBuf::from("lib/m.lisp"),
6783 },
6784 ],
6785 );
6786 e.validate().unwrap();
6787 }
6788
6789 #[test]
6790 fn validate_accepts_multiple_state_changes_before_cleanup() {
6791 // Coverage: every state-change must precede every cleanup, not
6792 // just the first. A chain `(load) (sc) (sc) (sp)` is the
6793 // canonical "two distinct migration scripts on a chained
6794 // upgrade" shape (one module's schema *and* another's
6795 // projection per the DuplicateStateChange diagnostic), and
6796 // it must pass when each state-change has distinct script
6797 // paths. Pinned here so a future shortcut that only checks
6798 // the first state-change doesn't silently accept a
6799 // `(load) (sc-1) (sp) (sc-2)` regression.
6800 let e = entry(
6801 "0.1.0",
6802 vec![
6803 UpgradeInstruction::LoadModule { module: "x".into() },
6804 UpgradeInstruction::StateChange {
6805 script: PathBuf::from("lib/m1.lisp"),
6806 },
6807 UpgradeInstruction::StateChange {
6808 script: PathBuf::from("lib/m2.lisp"),
6809 },
6810 UpgradeInstruction::SoftPurge {
6811 module: "x-old".into(),
6812 },
6813 ],
6814 );
6815 e.validate().unwrap();
6816 }
6817
6818 #[test]
6819 fn validate_rejects_state_change_sandwiched_between_cleanups() {
6820 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
6821 // (sp-2)` violates the gate because the state-change runs
6822 // after the first cleanup. The reported `prior_cleanup_*`
6823 // names the *first* cleanup (the load-bearing one), not the
6824 // last — mirrors every peer first-collision diagnostic
6825 // posture on this module (`validate_state_change_ordering`,
6826 // `validate_purge_ordering`, `validate_load_singularity`,
6827 // `validate_state_change_singularity`,
6828 // `validate_cleanup_singularity` all report the first
6829 // colliding instruction, not the last).
6830 let e = entry(
6831 "0.1.0",
6832 vec![
6833 UpgradeInstruction::LoadModule { module: "x".into() },
6834 UpgradeInstruction::SoftPurge {
6835 module: "x-old".into(),
6836 },
6837 UpgradeInstruction::StateChange {
6838 script: PathBuf::from("lib/m.lisp"),
6839 },
6840 UpgradeInstruction::Purge {
6841 module: "y-old".into(),
6842 },
6843 ],
6844 );
6845 let err = e.validate().unwrap_err();
6846 assert_eq!(
6847 err,
6848 UpgradeError::StateChangeAfterCleanup {
6849 from: "0.1.0".into(),
6850 script: PathBuf::from("lib/m.lisp"),
6851 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6852 prior_cleanup_module: "x-old".into(),
6853 },
6854 "the first cleanup the state-change follows must surface (not the trailing one), \
6855 got {err:?}"
6856 );
6857 }
6858
6859 #[test]
6860 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
6861 // Diagnostic-precedence pin: an entry like `((:soft-purge
6862 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
6863 // *both* purge-without-load (the cleanup runs before the
6864 // load) and state-change-after-cleanup (the state-change
6865 // runs after the cleanup). The more-fundamental ordering
6866 // gate must win — the missing-load defect (a cleanup that
6867 // drains the only resident version to nothing) is load-
6868 // bearing, and surfacing the state-change-after-cleanup
6869 // diagnostic first would mask the drain-to-nothing defect
6870 // the peer purge-ordering gate exists to close. Guards the
6871 // call order in `validate` against silent reordering. Same
6872 // posture as `validate_purge_ordering_fires_after_state_
6873 // change_ordering` on the sibling ordering gate.
6874 //
6875 // Pin specifically uses the load-after-cleanup shape (rather
6876 // than load-less) so the state-change-ordering gate (which
6877 // would otherwise fire first on a `((:soft-purge …)
6878 // (:state-change …))` shape with no leading load) is
6879 // sidestepped: with the load present after the cleanup,
6880 // state-change-ordering passes (its `loaded` latch is set
6881 // before the state-change is encountered) but purge-ordering
6882 // still fails (the cleanup precedes the load). That isolates
6883 // the precedence between purge-ordering and this gate
6884 // cleanly.
6885 let e = entry(
6886 "0.1.0",
6887 vec![
6888 UpgradeInstruction::SoftPurge {
6889 module: "x-old".into(),
6890 },
6891 UpgradeInstruction::LoadModule { module: "x".into() },
6892 UpgradeInstruction::StateChange {
6893 script: PathBuf::from("lib/m.lisp"),
6894 },
6895 ],
6896 );
6897 let err = e.validate().unwrap_err();
6898 assert!(
6899 matches!(
6900 err,
6901 UpgradeError::PurgeWithoutPriorLoad {
6902 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6903 ..
6904 }
6905 ),
6906 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
6907 );
6908 }
6909
6910 #[test]
6911 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
6912 // Diagnostic-precedence pin: an entry like `((:state-change
6913 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
6914 // load (because no `:load-module` precedes the state-change)
6915 // but *not* state-change-after-cleanup (the state-change
6916 // precedes the cleanup textually). The state-change-ordering
6917 // gate must surface first regardless — the missing-load
6918 // defect on the migration axis is the load-bearing semantic
6919 // and surfacing a different ordering diagnostic would mask
6920 // the migration-against-stale-code defect. Guards the call
6921 // order in `validate` against silent reordering on a shape
6922 // that fires only the state-change-ordering gate (not this
6923 // one), pinning that the state-change-ordering gate wins
6924 // ahead of this gate's chance to look at the list.
6925 let e = entry(
6926 "0.1.0",
6927 vec![
6928 UpgradeInstruction::StateChange {
6929 script: PathBuf::from("lib/m.lisp"),
6930 },
6931 UpgradeInstruction::SoftPurge {
6932 module: "x-old".into(),
6933 },
6934 ],
6935 );
6936 let err = e.validate().unwrap_err();
6937 assert!(
6938 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6939 "state-change-without-load must surface before purge-without-load (the canonical \
6940 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
6941 );
6942 }
6943
6944 #[test]
6945 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
6946 // Order pin: a malformed `:script` value on a `:state-change`
6947 // (an empty path) surfaces its narrower `EmptyScript`
6948 // diagnostic *before* the within-entry state-change-before-
6949 // cleanup gate fires. The per-instruction shape pass walks
6950 // the list inline before the ordering check, so the narrower
6951 // self-locating diagnostic surfaces first — mirrors the
6952 // empty-first cascade on every peer path-shape gate and the
6953 // `validate_purge_ordering_fires_after_per_instr_shape` pin
6954 // on the sibling ordering gate.
6955 let e = entry(
6956 "0.1.0",
6957 vec![
6958 UpgradeInstruction::LoadModule { module: "x".into() },
6959 UpgradeInstruction::SoftPurge {
6960 module: "x-old".into(),
6961 },
6962 UpgradeInstruction::StateChange {
6963 script: PathBuf::new(),
6964 },
6965 ],
6966 );
6967 let err = e.validate().unwrap_err();
6968 assert_eq!(
6969 err,
6970 UpgradeError::EmptyScript,
6971 "malformed instruction must surface its narrower diagnostic before the \
6972 state-change-before-cleanup gate fires, got {err:?}"
6973 );
6974 }
6975
6976 #[test]
6977 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
6978 // Diagnostic-precedence pin: an entry like `((:load-module
6979 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
6980 // (:state-change "m.lisp"))` violates *both* this ordering
6981 // gate (the first state-change follows the cleanup) and the
6982 // state-change-singularity gate (the same script appears
6983 // twice). The ordering gate must win — the canonical
6984 // "ordering before singularity" precedence the peer
6985 // `validate_state_change_ordering` / `validate_purge_
6986 // ordering` gates already establish over their own singularity
6987 // gates, applied uniformly across the OTP canonical-sequence
6988 // ordering axis here. Guards the call order in `validate`:
6989 // `validate_state_change_before_cleanup` runs before the
6990 // per-instruction-class singularity gates.
6991 let e = entry(
6992 "0.1.0",
6993 vec![
6994 UpgradeInstruction::LoadModule { module: "x".into() },
6995 UpgradeInstruction::SoftPurge {
6996 module: "x-old".into(),
6997 },
6998 UpgradeInstruction::StateChange {
6999 script: PathBuf::from("lib/m.lisp"),
7000 },
7001 UpgradeInstruction::StateChange {
7002 script: PathBuf::from("lib/m.lisp"),
7003 },
7004 ],
7005 );
7006 let err = e.validate().unwrap_err();
7007 assert!(
7008 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7009 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7010 );
7011 }
7012
7013 #[test]
7014 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7015 // The whole-list entry-point surfaces the per-entry ordering
7016 // error (mirrors `validate_purge_ordering_threads_through_
7017 // validate_upgrade_from` and every peer wiring pin): the gate
7018 // is reachable from the LayoutInvariants call site, not only
7019 // from a direct `entry.validate()`.
7020 let entries = vec![entry(
7021 "0.1.0",
7022 vec![
7023 UpgradeInstruction::LoadModule { module: "x".into() },
7024 UpgradeInstruction::SoftPurge {
7025 module: "x-old".into(),
7026 },
7027 UpgradeInstruction::StateChange {
7028 script: PathBuf::from("lib/m.lisp"),
7029 },
7030 ],
7031 )];
7032 let err = validate_upgrade_from(&entries).unwrap_err();
7033 assert!(
7034 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7035 "validate_upgrade_from must thread the state-change-before-cleanup error, \
7036 got {err:?}"
7037 );
7038 }
7039
7040 #[test]
7041 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7042 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7043 // per-instruction `StateChange`-arm script-path projection must
7044 // route through the sibling lifted
7045 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7046 // accessor, not the raw
7047 // `if let UpgradeInstruction::StateChange { script } = instr`
7048 // open-coded pattern-match the gate previously carried inside
7049 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7050 //
7051 // Structurally: the gate's projection accept-set is the union
7052 // of every [`UpgradeInstruction`] variant for which
7053 // `declared_path().is_some()` — today exactly
7054 // [`UpgradeInstruction::StateChange`] per the sibling
7055 // `declared_path_only_for_state_change` pin, so a
7056 // state-change-after-cleanup input trips
7057 // `StateChangeAfterCleanup` and a non-`StateChange` input
7058 // (module-bearing / terminal) leaves the sticky-once latch
7059 // sweep quiet byte-identical to the pattern-match shape.
7060 //
7061 // Byte-equal today (`declared_path` returns `Some(script)` iff
7062 // `StateChange`, byte-for-byte from the variant's own storage);
7063 // the pin catches any future accessor extension that promotes
7064 // an additional variant onto the `PathBuf`-carrying axis — the
7065 // gate then fires on migrate-after-cleanup for that variant too,
7066 // and the migrate→cleanup ordering discipline the peer
7067 // [`validate_state_change_singularity`] /
7068 // [`validate_upgrade_from_against_behavior`] gates share on the
7069 // same axis extends to the promoted variant by construction.
7070 //
7071 // Peer of the sibling four per-`UpgradeInstruction` consumers
7072 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7073 // sandbox-path fan-out, the layout-side per-`StateChange`
7074 // script-existence fan-out at
7075 // `caixa-core/src/layout.rs:1058`, the within-entry
7076 // [`UpgradeFromEntry::validate_state_change_singularity`]
7077 // per-`StateChange` script-projection fan-out, the cross-slot
7078 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7079 // detection loop) — the fifth (and last unlifted inside
7080 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7081 // the `PathBuf`-carrying axis to now route through the accessor.
7082 // Same shape as the sibling
7083 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7084 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7085 // pins extended onto the within-entry migrate→cleanup ordering
7086 // gate.
7087 //
7088 // Three-arm projective coverage:
7089 // (a) `StateChange` scripts project through `declared_path()`
7090 // byte-equal to the raw `script.clone()` field access
7091 // the diagnostic previously carried;
7092 // (b) a `:state-change`-after-cleanup input trips the gate
7093 // with `StateChangeAfterCleanup` carrying the offending
7094 // script + the prior cleanup's kind/module verbatim;
7095 // (c) a non-`StateChange`-only input (`LoadModule` /
7096 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7097 // vacuous with `Ok(())` — the `declared_path().is_none()`
7098 // arm's fall-through pins.
7099 //
7100 // Fail-before-pass-after verified structurally: swapping the
7101 // production
7102 // `else if let Some(script) = instr.declared_path() && … { … }`
7103 // back to
7104 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7105 // keeps arms (a)-(c) passing but silently detaches this within-
7106 // entry ordering gate from the accessor's typed dispatch — any
7107 // future `declared_path` extension (promotion of an additional
7108 // variant onto the axis, an operator-side pre-resolved-path
7109 // cache the accessor materializes) would then silently disagree
7110 // between this gate's raw pattern-match and the peer four
7111 // sibling consumers that route through the accessor.
7112
7113 // (a) StateChange projection byte-equal via declared_path.
7114 let sc = UpgradeInstruction::StateChange {
7115 script: PathBuf::from("lib/m.lisp"),
7116 };
7117 assert_eq!(
7118 sc.declared_path().cloned(),
7119 Some(PathBuf::from("lib/m.lisp")),
7120 "declared_path() must project the StateChange :script byte-equal to the raw \
7121 field access — accessor divergence would silently detach this within-entry \
7122 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7123 consumer routes through"
7124 );
7125
7126 // (b) StateChange-after-cleanup trips the gate through the accessor.
7127 let after = entry(
7128 "0.1.0",
7129 vec![
7130 UpgradeInstruction::LoadModule { module: "x".into() },
7131 UpgradeInstruction::SoftPurge {
7132 module: "x-old".into(),
7133 },
7134 UpgradeInstruction::StateChange {
7135 script: PathBuf::from("lib/m.lisp"),
7136 },
7137 ],
7138 );
7139 assert_eq!(
7140 after.validate(),
7141 Err(UpgradeError::StateChangeAfterCleanup {
7142 from: "0.1.0".into(),
7143 script: PathBuf::from("lib/m.lisp"),
7144 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7145 prior_cleanup_module: "x-old".into(),
7146 }),
7147 "a :state-change following a cleanup must trip the gate through the declared_path \
7148 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7149 kind/module verbatim byte-identical to the pattern-match shape"
7150 );
7151
7152 // (c) Non-StateChange-only inputs leave the gate vacuous.
7153 for instrs in [
7154 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7155 vec![
7156 UpgradeInstruction::LoadModule { module: "x".into() },
7157 UpgradeInstruction::SoftPurge {
7158 module: "x-old".into(),
7159 },
7160 ],
7161 vec![
7162 UpgradeInstruction::LoadModule { module: "x".into() },
7163 UpgradeInstruction::Purge {
7164 module: "x-old".into(),
7165 },
7166 ],
7167 vec![UpgradeInstruction::Restart],
7168 ] {
7169 for instr in &instrs {
7170 assert!(
7171 instr.declared_path().is_none(),
7172 "non-StateChange variants must project None through declared_path — \
7173 accessor divergence would let this within-entry ordering gate silently \
7174 fire on a cleanup-only sequence far from any :state-change site"
7175 );
7176 }
7177 let e = entry("0.1.0", instrs);
7178 assert_eq!(
7179 e.validate(),
7180 Ok(()),
7181 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7182 instructions all project None through declared_path — the accessor's \
7183 None arm the pattern-match's implicit fall-through previously carried"
7184 );
7185 }
7186 }
7187
7188 #[test]
7189 fn validate_restart_order_independent() {
7190 // Position-agnostic: `(:restart)` leading or trailing the
7191 // mixed sequence surfaces the same RestartNotExclusive shape.
7192 // Mirrors OTP appup's order-insensitive
7193 // `restart_emulator | restart_new_emulator` terminal rule —
7194 // the position of the restart instruction in the script is
7195 // irrelevant; what matters is the script *contains* it
7196 // alongside other instructions at all. The gate must not
7197 // gain a false positive by depending on instruction ordering.
7198 let leading = entry(
7199 "0.1.0",
7200 vec![
7201 UpgradeInstruction::Restart,
7202 UpgradeInstruction::LoadModule { module: "x".into() },
7203 ],
7204 );
7205 let trailing = entry(
7206 "0.1.0",
7207 vec![
7208 UpgradeInstruction::LoadModule { module: "x".into() },
7209 UpgradeInstruction::Restart,
7210 ],
7211 );
7212 let middle = entry(
7213 "0.1.0",
7214 vec![
7215 UpgradeInstruction::LoadModule { module: "a".into() },
7216 UpgradeInstruction::Restart,
7217 UpgradeInstruction::SoftPurge {
7218 module: "a-old".into(),
7219 },
7220 ],
7221 );
7222 for e in [&leading, &trailing, &middle] {
7223 assert!(
7224 matches!(
7225 e.validate().unwrap_err(),
7226 UpgradeError::RestartNotExclusive {
7227 restart_count: 1,
7228 ..
7229 }
7230 ),
7231 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7232 instruction order, got {:?}",
7233 e.validate()
7234 );
7235 }
7236 }
7237
7238 #[test]
7239 fn validate_restart_exclusive_fires_after_per_instr_shape() {
7240 // Order pin: a malformed `:module` value on a Module-bearing
7241 // instruction (an empty string) surfaces its narrower
7242 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7243 // entry restart-exclusivity gate fires. The per-instruction
7244 // shape pass walks the list inline before the restart-
7245 // exclusive check, so the narrower self-locating diagnostic
7246 // surfaces first — mirrors the empty-first cascade on every
7247 // peer DNS-1123 gate (`validate_module`,
7248 // `validate_membro_caixa`, `validate_placement_cluster`) and
7249 // the `*_invalid_fires_before_duplicate_check` arm-ordering
7250 // pins on every typed-graph axis. Without this pin a future
7251 // shortcut that runs the restart-exclusive check ahead of
7252 // per-instruction shape would surface a less-actionable
7253 // RestartNotExclusive over an instruction list that's also
7254 // malformed at the per-instruction layer.
7255 let e = entry(
7256 "0.1.0",
7257 vec![
7258 UpgradeInstruction::LoadModule {
7259 module: String::new(),
7260 },
7261 UpgradeInstruction::Restart,
7262 ],
7263 );
7264 let err = e.validate().unwrap_err();
7265 assert_eq!(
7266 err,
7267 UpgradeError::ModuleEmpty {
7268 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7269 },
7270 "malformed instruction must surface its kind-tagged diagnostic before the \
7271 restart-exclusivity gate fires, got {err:?}"
7272 );
7273 }
7274
7275 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7276 // Helper for the cross-slot composition gate's pass arm: a
7277 // BehaviorSpec carrying just the `:on-state-change` callback,
7278 // the runtime hook the per-version `(:state-change "…")`
7279 // instruction is delivered through during hot upgrade. Mirrors
7280 // the canonical authoring shape pinned in the module doc.
7281 crate::BehaviorSpec {
7282 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7283 ..Default::default()
7284 }
7285 }
7286
7287 #[test]
7288 fn behavior_gate_rejects_state_change_without_any_behavior() {
7289 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7290 // caixa carries no `:behavior` at all surfaces the missing-
7291 // callback diagnostic naming the offending entry's `:from` +
7292 // script. The "I added the upgrade path but never declared
7293 // `:behavior`" footgun: `:behavior` is optional at the typed
7294 // root, the typed `:upgrade-from` slot validates on its own
7295 // merits, and the operator's hot-upgrade dispatch reaches for
7296 // a callback that doesn't exist.
7297 let entries = vec![entry(
7298 "0.1.0",
7299 vec![
7300 UpgradeInstruction::LoadModule { module: "x".into() },
7301 UpgradeInstruction::StateChange {
7302 script: PathBuf::from("lib/m.lisp"),
7303 },
7304 ],
7305 )];
7306 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7307 assert_eq!(
7308 err,
7309 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7310 from: "0.1.0".into(),
7311 script: PathBuf::from("lib/m.lisp"),
7312 },
7313 );
7314 }
7315
7316 #[test]
7317 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7318 // `:behavior` declared with *other* callbacks set
7319 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7320 // None still surfaces the missing-callback diagnostic — only
7321 // the `:on-state-change` axis matters for this gate. The
7322 // "I declared `:behavior` but missed the migration callback"
7323 // footgun: a caixa that registers its lifecycle hooks but
7324 // forgets the migration delivery path leaves the
7325 // `:state-change` instruction with no runtime hook to
7326 // dispatch through.
7327 let entries = vec![entry(
7328 "0.1.0",
7329 vec![
7330 UpgradeInstruction::LoadModule { module: "x".into() },
7331 UpgradeInstruction::StateChange {
7332 script: PathBuf::from("lib/m.lisp"),
7333 },
7334 ],
7335 )];
7336 let b = crate::BehaviorSpec {
7337 on_init: Some(PathBuf::from("lib/init.lisp")),
7338 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7339 ..Default::default()
7340 };
7341 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7342 assert_eq!(
7343 err,
7344 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7345 from: "0.1.0".into(),
7346 script: PathBuf::from("lib/m.lisp"),
7347 },
7348 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7349 the missing migration hook"
7350 );
7351 }
7352
7353 #[test]
7354 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7355 // The canonical composition shape: a per-version
7356 // `(:state-change "lib/m.lisp")` instruction paired with the
7357 // `:behavior :on-state-change "lib/migrations.lisp"` callback
7358 // it is delivered through at hot-upgrade time. Pins the gate's
7359 // pass arm — drift here = a future tighten that rejects the
7360 // canonical OTP-shape composition surfaces as a regression at
7361 // this positive-control pin.
7362 let entries = vec![entry(
7363 "0.1.0",
7364 vec![
7365 UpgradeInstruction::LoadModule { module: "x".into() },
7366 UpgradeInstruction::StateChange {
7367 script: PathBuf::from("lib/m.lisp"),
7368 },
7369 ],
7370 )];
7371 let b = behavior_with_state_change_callback();
7372 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7373 }
7374
7375 #[test]
7376 fn behavior_gate_accepts_entries_without_any_state_change() {
7377 // Empty-set identity: entries carrying no `:state-change`
7378 // instruction at all (load + cleanup only — the metadata-only
7379 // upgrade shape the module doc names, "On any failure, the
7380 // current version stays load-bearing — a typed atomic
7381 // upgrade") leave the gate vacuous. The composition only
7382 // requires a callback when the per-version script exists; a
7383 // load + cleanup pair has no migration to deliver, so the
7384 // absence of `:on-state-change` is coherent.
7385 let entries = vec![entry(
7386 "0.1.0",
7387 vec![
7388 UpgradeInstruction::LoadModule { module: "x".into() },
7389 UpgradeInstruction::SoftPurge {
7390 module: "x-old".into(),
7391 },
7392 ],
7393 )];
7394 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7395 }
7396
7397 #[test]
7398 fn behavior_gate_accepts_restart_only_entry() {
7399 // The terminal-fallback `((:restart))` shape carries no
7400 // `:state-change` — the operator restarts the pod and the
7401 // new version comes up fresh against its initial state, no
7402 // migration. Pinned alongside the metadata-only positive
7403 // control above as the second empty-state-change shape.
7404 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7405 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7406 }
7407
7408 #[test]
7409 fn behavior_gate_accepts_empty_entries_list() {
7410 // Empty `:upgrade-from` (a caixa with no declared upgrade
7411 // paths — the v0.1.0 caixa before any upgrade entries are
7412 // added) trivially passes the gate. Pinned so the gate
7413 // doesn't accidentally fire on a caixa that hasn't yet
7414 // declared any upgrades.
7415 let entries: Vec<UpgradeFromEntry> = vec![];
7416 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7417 }
7418
7419 #[test]
7420 fn behavior_gate_reports_first_state_change_in_first_entry() {
7421 // First-collision determinism: with multiple `:state-change`
7422 // instructions across multiple entries, the gate reports the
7423 // *first* one encountered in declaration order — the entry's
7424 // declaration order first, then the within-entry instruction
7425 // order. Mirrors every peer first-collision diagnostic posture
7426 // on this module (`validate_state_change_ordering`,
7427 // `validate_purge_ordering`, the singularity gates), so a
7428 // future shortcut that walks the list in reverse or returns
7429 // the last collision surfaces as a regression here.
7430 let entries = vec![
7431 entry(
7432 "0.1.0",
7433 vec![
7434 UpgradeInstruction::LoadModule { module: "x".into() },
7435 UpgradeInstruction::StateChange {
7436 script: PathBuf::from("lib/m1.lisp"),
7437 },
7438 UpgradeInstruction::StateChange {
7439 script: PathBuf::from("lib/m2.lisp"),
7440 },
7441 ],
7442 ),
7443 entry(
7444 "0.1.5",
7445 vec![
7446 UpgradeInstruction::LoadModule { module: "x".into() },
7447 UpgradeInstruction::StateChange {
7448 script: PathBuf::from("lib/m3.lisp"),
7449 },
7450 ],
7451 ),
7452 ];
7453 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7454 assert_eq!(
7455 err,
7456 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7457 from: "0.1.0".into(),
7458 script: PathBuf::from("lib/m1.lisp"),
7459 },
7460 "the first :state-change in the first entry must surface, not later collisions"
7461 );
7462 }
7463
7464 #[test]
7465 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7466 // Cross-entry pin: a first entry with no `:state-change` (just
7467 // a load + cleanup) leaves the gate's per-entry walk continuing
7468 // to the second entry, where the offending instruction lives.
7469 // The diagnostic names the *second* entry's `:from` because
7470 // that's where the missing-callback shape is exposed — pinned
7471 // so a shortcut that bails on the first entry without a
7472 // `:state-change` (rather than continuing) doesn't mask the
7473 // defect in a later entry.
7474 let entries = vec![
7475 entry(
7476 "0.1.0",
7477 vec![
7478 UpgradeInstruction::LoadModule { module: "x".into() },
7479 UpgradeInstruction::SoftPurge {
7480 module: "x-old".into(),
7481 },
7482 ],
7483 ),
7484 entry(
7485 "0.1.5",
7486 vec![
7487 UpgradeInstruction::LoadModule { module: "x".into() },
7488 UpgradeInstruction::StateChange {
7489 script: PathBuf::from("lib/m.lisp"),
7490 },
7491 ],
7492 ),
7493 ];
7494 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7495 assert_eq!(
7496 err,
7497 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7498 from: "0.1.5".into(),
7499 script: PathBuf::from("lib/m.lisp"),
7500 },
7501 "the offending entry's `:from` must surface even when an earlier entry carries no \
7502 :state-change"
7503 );
7504 }
7505
7506 #[test]
7507 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7508 // Positive control: a multi-entry `:upgrade-from` (chained
7509 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7510 // a `:state-change` passes when the callback is declared once
7511 // at the caixa root. The callback is a single per-caixa
7512 // runtime hook; one declaration covers every entry's
7513 // `:state-change`, mirroring OTP's
7514 // `release_handler:install_release/1` which dispatches every
7515 // appup's `code_change` instruction through the single
7516 // `gen_server:code_change/3` callback registered on the
7517 // module.
7518 let entries = vec![
7519 entry(
7520 "0.1.0",
7521 vec![
7522 UpgradeInstruction::LoadModule { module: "x".into() },
7523 UpgradeInstruction::StateChange {
7524 script: PathBuf::from("lib/m1.lisp"),
7525 },
7526 ],
7527 ),
7528 entry(
7529 "0.1.5",
7530 vec![
7531 UpgradeInstruction::LoadModule { module: "x".into() },
7532 UpgradeInstruction::StateChange {
7533 script: PathBuf::from("lib/m2.lisp"),
7534 },
7535 ],
7536 ),
7537 ];
7538 let b = behavior_with_state_change_callback();
7539 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7540 }
7541
7542 #[test]
7543 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7544 // Symmetry pin: the gate's pass arm doesn't depend on the
7545 // entry actually carrying a `:state-change` — if no
7546 // `:state-change` is declared, the gate is vacuous regardless
7547 // of the callback (an `:on-state-change` declared without a
7548 // matching per-version script is fine, the callback is the
7549 // runtime default for any *future* migration the author hasn't
7550 // yet added). Pins that a caixa author can declare the
7551 // callback ahead of any migration without the gate
7552 // complaining.
7553 let entries = vec![entry(
7554 "0.1.0",
7555 vec![
7556 UpgradeInstruction::LoadModule { module: "x".into() },
7557 UpgradeInstruction::SoftPurge {
7558 module: "x-old".into(),
7559 },
7560 ],
7561 )];
7562 let b = behavior_with_state_change_callback();
7563 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7564 }
7565
7566 #[test]
7567 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7568 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7569 // per-instruction `StateChange`-arm script-path projection must
7570 // route through the sibling lifted
7571 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7572 // accessor, not the raw
7573 // `if let UpgradeInstruction::StateChange { script } = instr`
7574 // open-coded pattern-match the cross-slot gate previously
7575 // carried at caixa-core/src/upgrade.rs:1365.
7576 //
7577 // Structurally: the gate's projection accept-set is the union
7578 // of every [`UpgradeInstruction`] variant for which
7579 // `declared_path().is_some()` — today exactly
7580 // [`UpgradeInstruction::StateChange`] per the sibling
7581 // `declared_path_only_for_state_change` pin, so a
7582 // `:state-change`-carrying entry without an `:on-state-change`
7583 // callback trips `StateChangeWithoutOnStateChangeCallback` and
7584 // a non-`StateChange` entry (load-only / cleanup-only /
7585 // restart-only / empty-`:instructions`) leaves the per-entry
7586 // walk continuing past every non-projecting instruction
7587 // byte-identical to the pattern-match shape.
7588 //
7589 // Byte-equal today (`declared_path` returns `Some(script)` iff
7590 // `StateChange`, byte-for-byte from the variant's own storage);
7591 // the pin catches any future accessor extension that promotes
7592 // an additional variant onto the `PathBuf`-carrying axis — the
7593 // gate then fires on scripts from that variant too, and the
7594 // cross-slot composition discipline the sibling per-
7595 // `UpgradeInstruction` consumers share on the `PathBuf`-
7596 // carrying axis extends to the promoted variant by
7597 // construction.
7598 //
7599 // Peer of the sibling four per-`UpgradeInstruction` consumers
7600 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7601 // sandbox-path fan-out, the layout-side per-`StateChange`
7602 // script-existence fan-out at
7603 // `caixa-core/src/layout.rs:1058`, the within-entry
7604 // [`UpgradeFromEntry::validate_state_change_singularity`]
7605 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7606 // peer [`UpgradeInstruction::declared_module`] `String`-axis
7607 // per-variant unifier) — the fourth (and last) per-
7608 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7609 // to now route through the accessor. Same shape as the
7610 // sibling
7611 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7612 // pin extended onto the cross-slot composition gate.
7613 //
7614 // Three-arm projective coverage:
7615 // (a) `StateChange` scripts project through `declared_path()`
7616 // byte-equal to the raw `script.clone()` field access
7617 // the diagnostic previously carried;
7618 // (b) a `:state-change`-carrying entry with `behavior: None`
7619 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
7620 // carrying the offending script verbatim;
7621 // (c) a non-`StateChange`-only entry (`LoadModule` /
7622 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7623 // vacuous with `Ok(())` — the `declared_path().is_none()`
7624 // arm's fall-through pins.
7625 //
7626 // Fail-before-pass-after verified structurally: swapping the
7627 // production
7628 // `if let Some(script) = instr.declared_path() { … }`
7629 // back to
7630 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
7631 // keeps arms (a)-(c) passing but silently detaches the gate
7632 // from the accessor's typed dispatch — any future
7633 // `declared_path` extension (promotion of an additional
7634 // variant onto the axis, an operator-side pre-resolved-path
7635 // cache the accessor materializes) would then silently
7636 // disagree between this cross-slot gate's raw pattern-match
7637 // and the peer four sibling consumers that route through the
7638 // accessor.
7639
7640 // (a) StateChange projection byte-equal via declared_path.
7641 let sc = UpgradeInstruction::StateChange {
7642 script: PathBuf::from("lib/m.lisp"),
7643 };
7644 assert_eq!(
7645 sc.declared_path().cloned(),
7646 Some(PathBuf::from("lib/m.lisp")),
7647 "declared_path() must project the StateChange :script byte-equal to the raw \
7648 field access — accessor divergence would silently detach this cross-slot \
7649 composition gate from the projection every peer per-`UpgradeInstruction` \
7650 consumer routes through"
7651 );
7652
7653 // (b) StateChange-carrying entry with behavior: None trips gate.
7654 let entries = vec![entry(
7655 "0.1.0",
7656 vec![
7657 UpgradeInstruction::LoadModule { module: "x".into() },
7658 UpgradeInstruction::StateChange {
7659 script: PathBuf::from("lib/m.lisp"),
7660 },
7661 ],
7662 )];
7663 assert_eq!(
7664 validate_upgrade_from_against_behavior(&entries, None),
7665 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7666 from: "0.1.0".into(),
7667 script: PathBuf::from("lib/m.lisp"),
7668 }),
7669 "a :state-change-carrying entry with behavior: None must trip the gate through \
7670 the declared_path accessor's Some(script) arm — carrying the offending script \
7671 verbatim byte-identical to the pattern-match shape"
7672 );
7673
7674 // (c) Non-StateChange-only inputs leave the gate vacuous.
7675 for instrs in [
7676 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7677 vec![
7678 UpgradeInstruction::LoadModule { module: "x".into() },
7679 UpgradeInstruction::SoftPurge {
7680 module: "x-old".into(),
7681 },
7682 ],
7683 vec![
7684 UpgradeInstruction::LoadModule { module: "x".into() },
7685 UpgradeInstruction::Purge {
7686 module: "x-old".into(),
7687 },
7688 ],
7689 vec![UpgradeInstruction::Restart],
7690 ] {
7691 for instr in &instrs {
7692 assert!(
7693 instr.declared_path().is_none(),
7694 "non-StateChange variants must project None through declared_path — \
7695 accessor divergence would let this cross-slot composition gate silently \
7696 fire on a module reference far from any :state-change site"
7697 );
7698 }
7699 let entries = vec![entry("0.1.0", instrs)];
7700 assert_eq!(
7701 validate_upgrade_from_against_behavior(&entries, None),
7702 Ok(()),
7703 "the cross-slot composition gate must return Ok(()) on an entry whose \
7704 instructions all project None through declared_path — the accessor's \
7705 None arm the pattern-match's implicit fall-through previously carried"
7706 );
7707 }
7708 }
7709
7710 #[test]
7711 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7712 // Wiring pin: the within-entry restart-exclusivity gate fires
7713 // through [`validate_upgrade_from`] (which delegates to
7714 // [`UpgradeFromEntry::validate`] per entry) before the cross-
7715 // entry duplicate-`:from` gate would have a chance to run on
7716 // the malformed entry. Pinned here so a future refactor that
7717 // walks the cross-entry gate first doesn't accidentally
7718 // surface a DuplicateFrom over an entry that's also malformed
7719 // at the within-entry restart-exclusivity layer.
7720 let entries = vec![
7721 entry(
7722 "0.1.0",
7723 vec![
7724 UpgradeInstruction::LoadModule { module: "x".into() },
7725 UpgradeInstruction::Restart,
7726 ],
7727 ),
7728 entry("0.1.0", vec![UpgradeInstruction::Restart]),
7729 ];
7730 let err = validate_upgrade_from(&entries).unwrap_err();
7731 assert!(
7732 matches!(
7733 err,
7734 UpgradeError::RestartNotExclusive {
7735 restart_count: 1,
7736 ..
7737 }
7738 ),
7739 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7740 duplicate-`:from` gate fires, got {err:?}"
7741 );
7742 }
7743
7744 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7745
7746 #[test]
7747 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7748 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7749 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7750 // name the exact camelCase JSON keys the `#[serde(rename_all =
7751 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7752 // test-side probe across the caixa-core / caixa-flux renderer
7753 // test fixtures navigates into each element of the rendered
7754 // `:upgrade-from` overlay sequence by consulting one of these two
7755 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7756 // and pin that each canonical byte-sequence appears verbatim in
7757 // the JSON — a future accidental `rename_all = "snake_case"` /
7758 // `"kebab-case"` / verbatim-field-name flip at the derive
7759 // attribute (any of which would silently break every test-side
7760 // probe that reaches for one of the two consts) surfaces here as
7761 // a build-time test failure at `upgrade.rs`, not as an apply-time
7762 // `.get(<stale-canonical-const>)` returning `None` far from the
7763 // derive-attr drift's commit. Same discipline the sibling
7764 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7765 // (d8b8b4f) and
7766 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7767 // (21fe462) pins established on the peer `:limits` / `:behavior`
7768 // sub-slot axes: one canonical byte-string per typed sub-key
7769 // axis, pinned to the load-bearing serde derivation at the type
7770 // itself.
7771 let e = UpgradeFromEntry {
7772 from: "0.1.0".into(),
7773 instructions: vec![UpgradeInstruction::LoadModule {
7774 module: "hello-rio".into(),
7775 }],
7776 };
7777 let json = serde_json::to_string(&e).unwrap();
7778 for key in [
7779 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7780 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7781 ] {
7782 let quoted = format!("\"{key}\"");
7783 assert!(
7784 json.contains("ed),
7785 "serialized UpgradeFromEntry must carry the lifted \
7786 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7787 the JSON emission (got: {json})",
7788 );
7789 }
7790 }
7791
7792 #[test]
7793 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7794 // Cross-axis drift-detection pin: a future collapse of the two
7795 // canonical sub-key byte-strings onto the same value (e.g. an
7796 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7797 // to also read `"from"`) would silently reroute every test-side
7798 // probe on one axis onto the sibling axis's per-entry field and
7799 // pass every propagation-probe test that expected only the stale
7800 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7801 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7802 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7803 let all = [
7804 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7805 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7806 ];
7807 for (i, a) in all.iter().enumerate() {
7808 for b in all.iter().skip(i + 1) {
7809 assert_ne!(
7810 a, b,
7811 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
7812 canonical byte-sequences — got `{a}` == `{b}`",
7813 );
7814 }
7815 }
7816 }
7817
7818 #[test]
7819 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
7820 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7821 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
7822 // tagged variant-discriminator key axis: the
7823 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
7824 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
7825 // attribute on [`UpgradeInstruction`] emits, and every downstream
7826 // consumer that navigates the serialized instruction blob to
7827 // route by variant (the caixa-core reflection-vs-serde round-trip
7828 // check in `dispatcher_registration.rs` that probes
7829 // `v.get("kind")` against every variant's expected kebab-case
7830 // tag, the future M4 admission-webhook path, any wasm-operator
7831 // dispatch step consuming the serialized instruction blob) reads
7832 // through the same `&'static str`. Serialize every variant and
7833 // pin that the const's byte-sequence appears verbatim as the
7834 // tag-slot JSON key with the expected kebab-case value — a
7835 // future accidental `tag = "type"` / `tag = "op"` /
7836 // `tag = "instruction"` rebrand at the derive attribute (any of
7837 // which would silently break every consumer probe reaching for
7838 // the stale-tag-key const) surfaces here as a build-time test
7839 // failure at `upgrade.rs`, not as an apply-time
7840 // `.get(<stale-tag-key>)` returning `None` far from the derive-
7841 // attr drift's commit.
7842 //
7843 // Same "one canonical byte-string per typed axis" discipline the
7844 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7845 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
7846 // outer-container axis — this pin extends the discipline one
7847 // altitude deeper onto the per-instruction *tag* axis inside
7848 // each element of the `:instructions` list, completing the
7849 // typed coverage of the `:upgrade-from :instructions` dual
7850 // (key = "kind" + five variant-value tags): the five
7851 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
7852 // per-variant kebab-case *values*; this pin pins the tag *key*
7853 // above them.
7854 let samples: [(UpgradeInstruction, &'static str); 5] = [
7855 (
7856 UpgradeInstruction::LoadModule {
7857 module: "hello-rio".into(),
7858 },
7859 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
7860 ),
7861 (
7862 UpgradeInstruction::StateChange {
7863 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7864 },
7865 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
7866 ),
7867 (
7868 UpgradeInstruction::SoftPurge {
7869 module: "hello-rio-old".into(),
7870 },
7871 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
7872 ),
7873 (
7874 UpgradeInstruction::Purge {
7875 module: "hello-rio-old".into(),
7876 },
7877 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
7878 ),
7879 (
7880 UpgradeInstruction::Restart,
7881 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
7882 ),
7883 ];
7884 for (sample, expected_value) in &samples {
7885 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
7886 let got = v
7887 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
7888 .and_then(|k| k.as_str());
7889 assert_eq!(
7890 got,
7891 Some(*expected_value),
7892 "serialized {sample:?} must carry the lifted \
7893 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
7894 ({:?}) verbatim as the tag-slot JSON key, holding the \
7895 expected kebab-case value {expected_value:?} (got: {v})",
7896 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
7897 );
7898 }
7899 }
7900
7901 #[test]
7902 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
7903 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
7904 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7905 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7906 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7907 // whitespace / colons / dots) — the canonical shape a serde
7908 // internally-tagged discriminator key takes across every peer
7909 // enum in this crate. A future flip to a non-camelCase byte at
7910 // the const surfaces here at build time. Peer of
7911 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
7912 // sibling per-entry outer-container axis.
7913 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7914 assert!(
7915 !key.is_empty(),
7916 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
7917 );
7918 let first = key.chars().next().unwrap();
7919 assert!(
7920 first.is_ascii_lowercase(),
7921 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
7922 byte (got {key:?}, leads with {first:?})",
7923 );
7924 assert!(
7925 key.chars().all(|c| c.is_ascii_alphanumeric()),
7926 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
7927 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7928 );
7929 }
7930
7931 #[test]
7932 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
7933 // Cross-axis drift-detection pin: the tag-slot key
7934 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
7935 // disjoint from every per-variant data-field key the
7936 // internally-tagged serialization also emits (`"module"` for
7937 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
7938 // future accidental rebrand that collapses `tag = "kind"` onto
7939 // one of the data-field names (e.g. `tag = "module"`) would
7940 // silently corrupt every serialized LoadModule blob (the
7941 // module string and the variant tag would collide on the same
7942 // JSON key) and every consumer probe would either misread the
7943 // tag or fail to distinguish variants. Pin the disjointness at
7944 // build time. Same cross-axis discipline the sibling
7945 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
7946 // (36ffe65) established on the outer container's own
7947 // `from`/`instructions` pair.
7948 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7949 // Enumerate every per-variant data-field key across all five
7950 // variants of [`UpgradeInstruction`], routing through the two
7951 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
7952 // that name the same per-variant data-field JSON keys the
7953 // `variant_fields` reflection in
7954 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
7955 // per-variant struct-field rebrand (`module` → `component`,
7956 // `script` → `path`) lands as an edit to exactly one const and
7957 // reaches this disjointness pin by construction — the two axes
7958 // (tag-slot key on one side, per-variant data-field keys on the
7959 // other) share one source of truth per axis.
7960 for data_field in [
7961 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7962 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7963 ] {
7964 assert_ne!(
7965 key, data_field,
7966 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
7967 must be disjoint from every UpgradeInstruction per-variant \
7968 data-field key — got tag-key {key:?} colliding with \
7969 data-field {data_field:?}, which would silently corrupt \
7970 the internally-tagged serialization",
7971 );
7972 }
7973 }
7974
7975 #[test]
7976 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
7977 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7978 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
7979 // data-field JSON key axis: the two
7980 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
7981 // `_SCRIPT`) name the exact per-variant field JSON keys the
7982 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
7983 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
7984 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
7985 // const — the `module: String` struct-field on
7986 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
7987 // struct-field on `StateChange` are promoted to sibling JSON keys
7988 // at the same nesting level as the tag by the internally-tagged
7989 // serialization, and every downstream consumer that navigates the
7990 // serialized instruction blob to reach the payload (the caixa-core
7991 // reflection round-trip in `dispatcher_registration.rs` that
7992 // consults `variant_fields`, the sibling disjointness pin below,
7993 // any future wasm-operator upgrade-dispatch step consuming the
7994 // serialized instruction blob to route the per-module load /
7995 // soft-purge / purge action or the per-script state-change action)
7996 // reads through the same `&'static str`. Serialize one Module-
7997 // bearing variant and one Script-bearing variant, then pin that
7998 // each const's byte-sequence appears verbatim in the JSON emission
7999 // — a future accidental struct-field rebrand (`module: String` →
8000 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8001 // either variant surfaces here as a build-time test failure at
8002 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8003 // returning `None` far from the field-name drift's commit.
8004 //
8005 // Same "one canonical byte-string per typed axis" discipline the
8006 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8007 // pin established on the peer tag-slot key axis on the same
8008 // enum — this pin extends the discipline onto the per-variant
8009 // data-field key axis, completing the `:upgrade-from :instructions`
8010 // variant-JSON dual (tag key + tag values + per-variant field keys)
8011 // fully into caixa-core.
8012 let module_sample = UpgradeInstruction::LoadModule {
8013 module: "hello-rio".into(),
8014 };
8015 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8016 assert_eq!(
8017 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8018 .and_then(|k| k.as_str()),
8019 Some("hello-rio"),
8020 "serialized {module_sample:?} must carry the lifted \
8021 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8022 ({:?}) verbatim as the data-field JSON key holding the \
8023 module string (got: {v})",
8024 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8025 );
8026
8027 let script_sample = UpgradeInstruction::StateChange {
8028 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8029 };
8030 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8031 assert_eq!(
8032 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8033 .and_then(|k| k.as_str()),
8034 Some("lib/migrations/v01-to-v02.lisp"),
8035 "serialized {script_sample:?} must carry the lifted \
8036 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8037 ({:?}) verbatim as the data-field JSON key holding the \
8038 script path (got: {v})",
8039 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8040 );
8041 }
8042
8043 #[test]
8044 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8045 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8046 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8047 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8048 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8049 // whitespace / colons / dots) — the canonical shape a Rust
8050 // struct-field name promoted to a JSON key by serde takes on this
8051 // internally-tagged variant surface, matching the sibling
8052 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8053 // shape. A future flip to a non-camelCase byte at either const
8054 // (an accidental `rename_all` regime interleave, or a struct-
8055 // field flip like `module` → `module_name`) surfaces here at
8056 // build time. Peer of
8057 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8058 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8059 // the sibling wire-key axes.
8060 for key in [
8061 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8062 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8063 ] {
8064 assert!(
8065 !key.is_empty(),
8066 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8067 );
8068 let first = key.chars().next().unwrap();
8069 assert!(
8070 first.is_ascii_lowercase(),
8071 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8072 byte (got {key:?}, leads with {first:?})",
8073 );
8074 assert!(
8075 key.chars().all(|c| c.is_ascii_alphanumeric()),
8076 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8077 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8078 );
8079 }
8080 }
8081
8082 #[test]
8083 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8084 // Cross-axis drift-detection pin: a future collapse of the two
8085 // canonical per-variant data-field byte-strings onto the same
8086 // value (e.g. an accidental copy-paste flip of
8087 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8088 // `"module"`) would silently reroute every test-side probe on one
8089 // variant's payload onto the sibling variant's payload and pass
8090 // every propagation-probe test that expected only the stale
8091 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8092 // on the sibling per-entry outer-container axis, and of
8093 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8094 // on the sibling tag-slot key ↔ per-variant data-field key axis.
8095 let all = [
8096 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8097 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8098 ];
8099 for (i, a) in all.iter().enumerate() {
8100 for b in all.iter().skip(i + 1) {
8101 assert_ne!(
8102 a, b,
8103 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8104 canonical byte-sequences — got `{a}` == `{b}`",
8105 );
8106 }
8107 }
8108 }
8109
8110 #[test]
8111 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8112 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8113 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8114 // `kebab-case` hyphens, no `PascalCase` leading capital, no
8115 // whitespace / colons / dots) — the canonical shape the
8116 // `#[serde(rename_all = "camelCase")]` derive produces on
8117 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8118 // at the derive surfaces both here (this test fails on the
8119 // stale-constant shape) and at
8120 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8121 // (that test fails on the mismatch between const and derive).
8122 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8123 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8124 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8125 for key in [
8126 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8127 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8128 ] {
8129 assert!(
8130 !key.is_empty(),
8131 "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8132 );
8133 let first = key.chars().next().unwrap();
8134 assert!(
8135 first.is_ascii_lowercase(),
8136 "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8137 byte (got {key:?}, leads with {first:?})",
8138 );
8139 assert!(
8140 key.chars().all(|c| c.is_ascii_alphanumeric()),
8141 "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8142 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8143 );
8144 }
8145 }
8146
8147 #[test]
8148 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8149 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8150 // OTP-appup variant-tag axis: the five canonical author-facing
8151 // kebab-case labels (`:load-module` / `:state-change` /
8152 // `:soft-purge` / `:purge` / `:restart`) the substrate's
8153 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8154 // from and every downstream consumer probes for verbatim. Same
8155 // scalar-value discipline the peer
8156 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8157 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8158 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8159 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8160 // (be40492) established for the sibling M2 / M3 / Supervisor
8161 // top-level and sub-slot author-facing-label axes. Fail-before-
8162 // pass-after locally verified by mutating
8163 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8164 // pin fires as expected; restoring passes.
8165 //
8166 // A future OTP-lineage per-variant rebrand (e.g.
8167 // `:load-module` → `:load` matching Erlang's abbreviated
8168 // `code:load_module` name, `:state-change` → `:code-change`
8169 // matching Erlang's verbatim `code_change/3` callback,
8170 // `:soft-purge` → `:drain` matching a hypothetical operator-side
8171 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8172 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8173 // matching a supervisor-tree vocabulary alignment) lands as an
8174 // edit to exactly one const, and every consumer that reaches for
8175 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8176 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8177 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8178 // `prior_cleanup_kind:` diagnostic field, the
8179 // [`LayoutError::UpgradeViolation`] `issue:` probe in
8180 // `layout.rs`) picks it up at build time rather than at runtime
8181 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8182 // far from the rename's commit.
8183 assert_eq!(
8184 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8185 ":load-module"
8186 );
8187 assert_eq!(
8188 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8189 ":state-change"
8190 );
8191 assert_eq!(
8192 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8193 ":soft-purge"
8194 );
8195 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8196 assert_eq!(
8197 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8198 ":restart"
8199 );
8200 }
8201
8202 #[test]
8203 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8204 // Cross-arm drift-detection pin on the M2
8205 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8206 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8207 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8208 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8209 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8210 // closed-set OTP-appup variant-tag pentad: a future collapse
8211 // of two canonical variant byte-strings onto the same value
8212 // (an accidental copy-paste flip of
8213 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8214 // to also read `":purge"`, a per-arm rebrand that lands one
8215 // const without touching its paired peer) would silently
8216 // reroute every downstream OTP-appup dispatcher's per-
8217 // instruction branch onto the sibling arm's runtime
8218 // behavior and pass every propagation-probe test that
8219 // expected only the stale arm's tag — a `:soft-purge`
8220 // instruction (drain-then-swap: existing callers finish
8221 // under the old module, new callers land on the new one)
8222 // would come up under the `:purge` reconcile branch
8223 // (drop-existing: every in-flight caller terminates
8224 // immediately) on every hot-upgrade cycle, so a rolling
8225 // module swap would silently downgrade to a hard cutover
8226 // against its declared appup discipline, with no field
8227 // naming the instruction-tag drift root cause. Every
8228 // [`crate::UpgradeError`] diagnostic that surfaces the tag
8229 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8230 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8231 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8232 // `prior_cleanup_kind:` field, the
8233 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8234 // `layout.rs`) would emit the sibling arm's stale bytes at
8235 // the operator's console, far from the source rebrand
8236 // commit. Peer of the sibling
8237 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8238 // (09ffb2d) /
8239 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8240 // (ccdf955) /
8241 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8242 // (d739850) distinctness pins on the sibling OTP-shape /
8243 // caixa-kind closed-set typed-enum discriminator axes —
8244 // the fifth closed-set OTP-appup / typed-enum axis to
8245 // converge on the same
8246 // "pairwise-distinct-by-construction" discipline, and the
8247 // canonical companion to the peer
8248 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8249 // (ff980bb) distinctness pin on the sibling internally-
8250 // tagged-JSON per-variant data-field-key axis (the tag axis
8251 // this pin covers vs. the data-field-key axis its peer
8252 // covers — two paired axes on the same
8253 // [`crate::UpgradeInstruction`] typed enum surface).
8254 //
8255 // Fail-before-pass-after locally verified by mutating
8256 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8257 // to also read `":purge"` — this pin fires as expected;
8258 // restoring passes.
8259 let all = [
8260 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8261 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8262 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8263 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8264 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8265 ];
8266 for (i, a) in all.iter().enumerate() {
8267 for (j, b) in all.iter().enumerate() {
8268 if i != j {
8269 assert_ne!(
8270 a, b,
8271 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8272 distinct — got duplicate {a:?} at indices {i} and {j}",
8273 );
8274 }
8275 }
8276 }
8277 }
8278
8279 #[test]
8280 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8281 // Production-through-const pin: the five per-variant labels
8282 // [`UpgradeInstruction::lisp_form`] returns route through the
8283 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8284 // so a future rebrand that reaches the const but not the
8285 // dispatch (or vice versa) surfaces here at build time rather
8286 // than at runtime as a downstream
8287 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8288 // diagnostic drift far from the rename's commit. Mirror of the
8289 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8290 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8291 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8292 // (f49c8b0) production-through-const pins on the sibling M3 /
8293 // M2 top-level slot axes.
8294 //
8295 // Fail-before-pass-after locally verified by mutating
8296 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8297 // `":purge-drift"` — this pin fires as expected; restoring
8298 // passes.
8299 let cases: &[(UpgradeInstruction, &'static str)] = &[
8300 (
8301 UpgradeInstruction::LoadModule { module: "x".into() },
8302 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8303 ),
8304 (
8305 UpgradeInstruction::StateChange {
8306 script: PathBuf::from("lib/m.lisp"),
8307 },
8308 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8309 ),
8310 (
8311 UpgradeInstruction::SoftPurge {
8312 module: "x-old".into(),
8313 },
8314 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8315 ),
8316 (
8317 UpgradeInstruction::Purge {
8318 module: "x-old".into(),
8319 },
8320 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8321 ),
8322 (
8323 UpgradeInstruction::Restart,
8324 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8325 ),
8326 ];
8327 for (instr, expected) in cases {
8328 assert_eq!(
8329 instr.lisp_form(),
8330 *expected,
8331 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8332 const (expected {expected:?})",
8333 );
8334 }
8335 }
8336
8337 #[test]
8338 fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8339 // Return-lifetime pin on the substrate primitive: because
8340 // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8341 // (threaded verbatim from the paired
8342 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8343 // roster's program-lifetime storage), the label survives
8344 // dropping the borrow through `self` — a downstream logger
8345 // that stashes the tag in a `&'static`-bounded position
8346 // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8347 // accept-set, a static formatter's `%s` argument) reads it
8348 // without re-borrowing through the instruction reference. A
8349 // future refactor that accidentally narrows the return to
8350 // `&str` (lifetime-bound to `&self`) — say by projecting through
8351 // an owned `String` intermediate — would fail this compile-time
8352 // pin at build time far from the runtime-side lifetime
8353 // regression at every downstream `&'static str` consumer. Peer
8354 // pin discipline the sibling
8355 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8356 // `pub const _: &str = "..."` shape already carries at the
8357 // paired wire-form axis.
8358 //
8359 // The pin fires by taking the label from an instruction that
8360 // goes out of scope before the label is read — if
8361 // `lisp_form` returned a `&str` tied to `&self`, this would
8362 // fail to compile with "borrowed value does not live long
8363 // enough". Fail-before-pass-after locally verified: narrowing
8364 // the signature to `fn lisp_form(&self) -> &str { … }`
8365 // reproduces the compile error.
8366 fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8367 instr.lisp_form()
8368 }
8369 let label = {
8370 let instr = UpgradeInstruction::LoadModule {
8371 module: "ephemeral".into(),
8372 };
8373 stash_label_as_static(&instr)
8374 // instr drops here; label must survive
8375 };
8376 assert_eq!(
8377 label,
8378 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8379 "the &'static str return must survive the borrowed \
8380 UpgradeInstruction going out of scope — a lifetime narrowing \
8381 to &str would fail this pin at build time",
8382 );
8383 }
8384
8385 #[test]
8386 fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8387 // Const-position pin on the substrate primitive: because
8388 // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8389 // consumers can call it in `const` contexts — a `const`
8390 // declaration threading the label through, a `static` lookup
8391 // table pre-computed at compile time, a `match` arm's
8392 // `const`-eligible branch label. `pub` matters here: a
8393 // `pub(crate) const fn` would compile in-crate const contexts
8394 // but no external caixa-<target> renderer or feira verb could
8395 // reach the projection in a const context. Fail-before-pass-
8396 // after locally verified: reverting the visibility to
8397 // `pub(crate) const fn` (or removing `pub`) makes this pin
8398 // fail to compile at the const-context call site below.
8399 const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8400 assert_eq!(
8401 RESTART_LABEL,
8402 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8403 "const-position dispatch on Restart must yield the lifted \
8404 M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8405 );
8406 }
8407
8408 #[test]
8409 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8410 // The canonical per-`:upgrade-from :instructions` OTP-appup
8411 // migration-instruction-list slice-shape pin:
8412 // [`UpgradeFromEntry::instructions`] must return the
8413 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8414 // a `&[UpgradeInstruction]` slice-view over the same backing
8415 // buffer the raw `self.instructions.as_slice()` field access
8416 // borrows from, byte-equal across every representative fixture
8417 // in the accept-set — the empty slice (the "no-op upgrade" /
8418 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8419 // field's own docstring names), the singleton slice on every
8420 // variant of the [`UpgradeInstruction`] arm-space
8421 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8422 // `Restart` — the five OTP-appup runtime-primitive variants),
8423 // and multi-instruction cohorts (the canonical
8424 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8425 // load + state-migration triad the module doc names as the
8426 // "runs the instructions in order" example).
8427 //
8428 // Pins against a future silent detour that returned
8429 // `&Vec<UpgradeInstruction>` (which would type-check but leak
8430 // the storage-side `Vec`'s grow/push/reserve surface no
8431 // consumer of the typed view reaches for), a fresh-allocated
8432 // `Vec<UpgradeInstruction>` copy (which would type-check via
8433 // a coercion but silently break every downstream caller that
8434 // relied on the slice sharing the backing buffer's identity),
8435 // or an out-of-order or length-drifted projection (which
8436 // would silently split the paired within-entry cross-
8437 // instruction ordering gates' inputs from the peer per-
8438 // instruction shape-check loop's input, one seven-gate cohort
8439 // silently drifting from the peer gate's actual traversal
8440 // input).
8441 //
8442 // Peer of the sibling
8443 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8444 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8445 // `:contratos` edge-list axis, extended onto the M2 per-
8446 // `:upgrade-from :instructions` migration-instruction-list
8447 // axis — the fifth `&[T]`-return byte-equal pin, closing the
8448 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8449 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8450 Vec::new(),
8451 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8452 vec![UpgradeInstruction::StateChange {
8453 script: PathBuf::from("lib/m.lisp"),
8454 }],
8455 vec![UpgradeInstruction::SoftPurge {
8456 module: "x-old".into(),
8457 }],
8458 vec![UpgradeInstruction::Purge {
8459 module: "x-old".into(),
8460 }],
8461 vec![UpgradeInstruction::Restart],
8462 vec![
8463 UpgradeInstruction::LoadModule { module: "x".into() },
8464 UpgradeInstruction::StateChange {
8465 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8466 },
8467 UpgradeInstruction::SoftPurge {
8468 module: "x-old".into(),
8469 },
8470 ],
8471 ];
8472 for instructions in fixtures {
8473 let e = UpgradeFromEntry {
8474 from: "0.1.0".into(),
8475 instructions: instructions.clone(),
8476 };
8477 assert_eq!(
8478 e.instructions(),
8479 e.instructions.as_slice(),
8480 "UpgradeFromEntry::instructions must project the raw \
8481 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8482 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8483 (fixture: {instructions:?})",
8484 );
8485 assert_eq!(
8486 e.instructions().len(),
8487 instructions.len(),
8488 "UpgradeFromEntry::instructions length must match the raw \
8489 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8490 );
8491 }
8492 }
8493
8494 #[test]
8495 fn validate_reads_through_lifted_instructions_accessor() {
8496 // Three-consumer coherence pin on the lifted
8497 // [`UpgradeFromEntry::instructions`] slice-return accessor:
8498 // exercises three of the nine paired production consumers of
8499 // the per-`:upgrade-from :instructions` OTP-appup migration-
8500 // instruction-list surface through end-to-end validate() paths
8501 // that require the accessor to reach each of the fixture's
8502 // instructions.
8503 //
8504 // (1) The per-instruction shape-check fan-out
8505 // ([`UpgradeFromEntry::validate`]'s `for instr in
8506 // self.instructions()` loop): pass the well-formed load →
8507 // state-change → soft-purge triad — `validate()` must accept
8508 // it, which requires the accessor to project every entry so
8509 // each `instr.validate()` fires.
8510 //
8511 // (2) The within-entry state-change-ordering gate
8512 // ([`Self::validate_state_change_ordering`]): pass a
8513 // `((:state-change …))` singleton — `validate()` must return
8514 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8515 // requires the accessor to reach the state-change so the
8516 // no-prior-load probe fires.
8517 //
8518 // (3) The within-entry per-module cleanup-singularity gate
8519 // ([`Self::validate_cleanup_singularity`]): pass a
8520 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8521 // "x-old"))` cohort — `validate()` must return
8522 // [`UpgradeError::DuplicateCleanup`], which requires the
8523 // accessor to iterate the whole list so the second `SoftPurge`
8524 // matches the first via the `seen` set.
8525 //
8526 // Peer of the sibling
8527 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8528 // three-consumer coherence pin on the M3 per-`:contratos`
8529 // edge-list axis, extended onto the M2 per-`:upgrade-from
8530 // :instructions` migration-instruction-list axis.
8531
8532 // (1) accept the well-formed OTP two-phase code-load triad
8533 let well_formed = entry(
8534 "0.1.0",
8535 vec![
8536 UpgradeInstruction::LoadModule { module: "x".into() },
8537 UpgradeInstruction::StateChange {
8538 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8539 },
8540 UpgradeInstruction::SoftPurge {
8541 module: "x-old".into(),
8542 },
8543 ],
8544 );
8545 assert!(
8546 well_formed.validate().is_ok(),
8547 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8548 the per-instruction shape-check fan-out requires the accessor to reach every entry"
8549 );
8550
8551 // (2) refuse a `((:state-change …))` singleton — the
8552 // state-change-without-prior-load gate must fire, which
8553 // requires the accessor to reach the single instruction.
8554 let no_prior_load = entry(
8555 "0.1.0",
8556 vec![UpgradeInstruction::StateChange {
8557 script: PathBuf::from("lib/m.lisp"),
8558 }],
8559 );
8560 match no_prior_load.validate() {
8561 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8562 other => panic!(
8563 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8564 — the within-entry state-change-ordering gate must reach the single \
8565 instruction through the lifted accessor; got: {other:?}"
8566 ),
8567 }
8568
8569 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8570 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8571 // singularity gate must fire on the second `SoftPurge`, which
8572 // requires the accessor to iterate the whole list.
8573 let duplicate_cleanup = entry(
8574 "0.1.0",
8575 vec![
8576 UpgradeInstruction::LoadModule { module: "x".into() },
8577 UpgradeInstruction::SoftPurge {
8578 module: "x-old".into(),
8579 },
8580 UpgradeInstruction::SoftPurge {
8581 module: "x-old".into(),
8582 },
8583 ],
8584 );
8585 match duplicate_cleanup.validate() {
8586 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8587 assert_eq!(
8588 module, "x-old",
8589 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8590 cleanup-singularity gate must iterate through the lifted accessor to \
8591 match the second SoftPurge against the first via the `seen` set"
8592 );
8593 }
8594 other => panic!(
8595 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8596 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8597 iterate the whole list through the lifted accessor; got: {other:?}"
8598 ),
8599 }
8600
8601 // Path::new suppresses the unused-import warning if the
8602 // outer module trims `use std::path::Path;` in a future edit.
8603 let _ = Path::new("lib/m.lisp");
8604 }
8605
8606 // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8607 // macro definition (see the paired doc-block above the macro
8608 // definition) — every generated `<ctor>(from: &str, script: &Path)
8609 // -> Self` constructor folds the uniform `Self::<Variant> { from:
8610 // from.to_string(), script: script.to_path_buf() }` two-field
8611 // struct-literal onto one substrate primitive. The three per-variant
8612 // equivalence pins below (fail-before-pass-after by construction — a
8613 // byte-mismatched macro arm would trip its equivalence pin first)
8614 // lock each generated constructor to its struct-literal peer under
8615 // `PartialEq`, so every wire-up in
8616 // [`UpgradeFromEntry::validate_state_change_ordering`],
8617 // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8618 // [`validate_state_change_on_state_change_callback`] on that
8619 // variant produces a byte-equal `UpgradeError` to the pre-lift
8620 // open-coded struct-literal. The cross-axis pin that follows
8621 // (non-default `(from, script)` pair) routes both constructor input
8622 // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8623 // not silently collapse onto a fixed `from` / `script` value.
8624 //
8625 // Peer of the sibling `empty_child_version_ctor_matches_struct_
8626 // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8627 // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8628 // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8629 // to_string` equivalence + cross-axis pins the sibling
8630 // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8631 // established on the peer `SupervisorError` envelope; extended
8632 // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8633 // two-slot envelope so every substrate-primitive ctor family in
8634 // caixa-core guarantees the same-shape fold every wire-up on the
8635 // family reads through one dispatch.
8636
8637 #[test]
8638 fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8639 let from = "0.1.0";
8640 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8641 assert_eq!(
8642 UpgradeError::state_change_without_prior_load(from, script),
8643 UpgradeError::StateChangeWithoutPriorLoad {
8644 from: from.to_string(),
8645 script: script.to_path_buf(),
8646 },
8647 "generated state_change_without_prior_load ctor must produce \
8648 byte-equal UpgradeError to the open-coded struct-literal \
8649 wrap on the same (&str, &Path) fixture",
8650 );
8651 }
8652
8653 #[test]
8654 fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
8655 let from = "0.1.0";
8656 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8657 assert_eq!(
8658 UpgradeError::duplicate_state_change(from, script),
8659 UpgradeError::DuplicateStateChange {
8660 from: from.to_string(),
8661 script: script.to_path_buf(),
8662 },
8663 "generated duplicate_state_change ctor must produce byte-equal \
8664 UpgradeError to the open-coded struct-literal wrap on the \
8665 same (&str, &Path) fixture",
8666 );
8667 }
8668
8669 #[test]
8670 fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
8671 let from = "0.1.0";
8672 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8673 assert_eq!(
8674 UpgradeError::state_change_without_on_state_change_callback(from, script),
8675 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8676 from: from.to_string(),
8677 script: script.to_path_buf(),
8678 },
8679 "generated state_change_without_on_state_change_callback ctor \
8680 must produce byte-equal UpgradeError to the open-coded \
8681 struct-literal wrap on the same (&str, &Path) fixture",
8682 );
8683 }
8684
8685 #[test]
8686 fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
8687 // Cross-axis pin: sweep both constructor input axes (`from:
8688 // &str`, `script: &Path`) through non-default fixtures against
8689 // every generated arm in the [`upgrade_from_script_ctors!`]
8690 // macro, so any wrapper-side lowercase / trim / truncate /
8691 // re-order / fixed-path substitution on the two-field
8692 // construction surfaces here rather than at a downstream
8693 // diagnostic-shape mismatch. Also exercises the `&Path`
8694 // parameter under both `&Path` (direct `Path::new`) and
8695 // `&PathBuf` (via Deref coercion), matching the two shapes the
8696 // three wire-up sites thread through — the ordering /
8697 // callback-declaration gates hand a `&PathBuf` from
8698 // `instr.declared_path()`; the uniqueness gate hands a `&Path`
8699 // from `script.as_path()`. Peer of the sibling
8700 // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
8701 // cross-axis pin on the peer `SupervisorError` `{ caixa:
8702 // String }` envelope.
8703 let from = "1.2.3-rc.1";
8704 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8705 let script_ref: &Path = script_owned.as_path();
8706 for script in [script_ref, &script_owned as &Path] {
8707 assert_eq!(
8708 UpgradeError::state_change_without_prior_load(from, script),
8709 UpgradeError::StateChangeWithoutPriorLoad {
8710 from: from.to_string(),
8711 script: script.to_path_buf(),
8712 },
8713 );
8714 assert_eq!(
8715 UpgradeError::duplicate_state_change(from, script),
8716 UpgradeError::DuplicateStateChange {
8717 from: from.to_string(),
8718 script: script.to_path_buf(),
8719 },
8720 );
8721 assert_eq!(
8722 UpgradeError::state_change_without_on_state_change_callback(from, script),
8723 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8724 from: from.to_string(),
8725 script: script.to_path_buf(),
8726 },
8727 );
8728 }
8729 }
8730
8731 // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
8732 // macro definition (see the paired doc-block above the macro
8733 // definition) — every generated `<ctor>(script: &Path) -> Self`
8734 // constructor folds the uniform `Self::<Variant> { script:
8735 // script.to_path_buf() }` one-field struct-literal onto one substrate
8736 // primitive. The three per-variant equivalence pins below
8737 // (fail-before-pass-after by construction — a byte-mismatched macro
8738 // arm would trip its equivalence pin first) lock each generated
8739 // constructor to its struct-literal peer under `PartialEq`, so every
8740 // closure passed to [`crate::render::require_sandboxed_lisp_path`]
8741 // at [`UpgradeInstruction::validate`] on that variant produces a
8742 // byte-equal `UpgradeError` to the pre-lift open-coded
8743 // struct-literal. The cross-axis pin that follows (non-default
8744 // `script` path, both `&Path` and `&PathBuf` shapes) routes the
8745 // constructor input axis through `.to_path_buf()`, so the fold does
8746 // not silently collapse onto a fixed `script` value or drop the
8747 // Deref-coercion arm the wire-up sites depend on.
8748 //
8749 // Peer of the sibling
8750 // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
8751 // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
8752 // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
8753 // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
8754 // equivalence + cross-axis pins the sibling
8755 // [`upgrade_from_script_ctors!`] family (8e67041) established on the
8756 // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
8757 // extended here onto the `{ script: PathBuf }` one-slot envelope
8758 // shape so every substrate-primitive ctor family on `UpgradeError`
8759 // guarantees the same-shape fold every wire-up on the family reads
8760 // through one dispatch.
8761
8762 #[test]
8763 fn absolute_script_ctor_matches_struct_literal_wrap() {
8764 let script = Path::new("/etc/nope.lisp");
8765 assert_eq!(
8766 UpgradeError::absolute_script(script),
8767 UpgradeError::AbsoluteScript {
8768 script: script.to_path_buf(),
8769 },
8770 "generated absolute_script ctor must produce byte-equal \
8771 UpgradeError to the open-coded struct-literal wrap on the \
8772 same &Path fixture",
8773 );
8774 }
8775
8776 #[test]
8777 fn parent_escape_script_ctor_matches_struct_literal_wrap() {
8778 let script = Path::new("../oops.lisp");
8779 assert_eq!(
8780 UpgradeError::parent_escape_script(script),
8781 UpgradeError::ParentEscapeScript {
8782 script: script.to_path_buf(),
8783 },
8784 "generated parent_escape_script ctor must produce byte-equal \
8785 UpgradeError to the open-coded struct-literal wrap on the \
8786 same &Path fixture",
8787 );
8788 }
8789
8790 #[test]
8791 fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8792 let script = Path::new("lib/migrations.rs");
8793 assert_eq!(
8794 UpgradeError::non_lisp_extension_script(script),
8795 UpgradeError::NonLispExtensionScript {
8796 script: script.to_path_buf(),
8797 },
8798 "generated non_lisp_extension_script ctor must produce \
8799 byte-equal UpgradeError to the open-coded struct-literal \
8800 wrap on the same &Path fixture",
8801 );
8802 }
8803
8804 #[test]
8805 fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8806 // Cross-axis pin: sweep the constructor input axis (`script:
8807 // &Path`) through a non-default fixture against every generated
8808 // arm in the [`upgrade_script_only_ctors!`] macro, so any
8809 // wrapper-side lowercase / trim / truncate / re-order /
8810 // fixed-path substitution on the one-field construction
8811 // surfaces here rather than at a downstream diagnostic-shape
8812 // mismatch. Also exercises the `&Path` parameter under both
8813 // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
8814 // coercion), matching the shape the three closures at
8815 // [`UpgradeInstruction::validate`] thread through — the
8816 // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
8817 // each closure, so the Deref-coercion arm the ctor advertises
8818 // must actually route through `.to_path_buf()` and not
8819 // silently swap in a fixed path.
8820 //
8821 // Peer of the sibling
8822 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8823 // cross-axis pin on the sibling `{ from, script }` two-slot
8824 // envelope shape.
8825 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8826 let script_ref: &Path = script_owned.as_path();
8827 for script in [script_ref, &script_owned as &Path] {
8828 assert_eq!(
8829 UpgradeError::absolute_script(script),
8830 UpgradeError::AbsoluteScript {
8831 script: script.to_path_buf(),
8832 },
8833 );
8834 assert_eq!(
8835 UpgradeError::parent_escape_script(script),
8836 UpgradeError::ParentEscapeScript {
8837 script: script.to_path_buf(),
8838 },
8839 );
8840 assert_eq!(
8841 UpgradeError::non_lisp_extension_script(script),
8842 UpgradeError::NonLispExtensionScript {
8843 script: script.to_path_buf(),
8844 },
8845 );
8846 }
8847 }
8848
8849 // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
8850 // macro definition (see the paired doc-block above the macro
8851 // definition) — every generated `<ctor>(from: &str, <axis>: &str)
8852 // -> Self` constructor folds the uniform `Self::<Variant> { from:
8853 // from.to_string(), <axis>: <axis>.to_string() }` two-field
8854 // struct-literal onto one substrate primitive. The three per-variant
8855 // equivalence pins below (fail-before-pass-after by construction — a
8856 // byte-mismatched macro arm would trip its equivalence pin first)
8857 // lock each generated constructor to its struct-literal peer under
8858 // `PartialEq`, so every wire-up in
8859 // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
8860 // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
8861 // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
8862 // `:from < :versao` gate on that variant produces a byte-equal
8863 // `UpgradeError` to the pre-lift open-coded struct-literal. The
8864 // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
8865 // pair) routes both constructor input axes through `.to_string()`
8866 // in declared field order, so the fold does not silently swap `from`
8867 // and the middle `<axis>` field, or silently collapse onto a fixed
8868 // `from` / `<axis>` value on any one variant.
8869 //
8870 // Peer of the sibling `state_change_without_prior_load_ctor_matches_
8871 // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
8872 // struct_literal_wrap` / `state_change_without_on_state_change_
8873 // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
8874 // ctors_route_from_and_script_verbatim` equivalence + cross-axis
8875 // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
8876 // established on the sibling `{ from: String, script: PathBuf }`
8877 // two-slot envelope shape; extended here onto the `{ from: String,
8878 // <axis>: String }` two-slot envelope shape so every substrate-
8879 // primitive ctor family on `UpgradeError` guarantees the same-shape
8880 // fold every wire-up on the family reads through one dispatch. Also
8881 // mirror-symmetric peer of the sibling
8882 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8883 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
8884 // <axis>: String }` two-slot envelope shape.
8885
8886 #[test]
8887 fn from_invalid_ctor_matches_struct_literal_wrap() {
8888 let from = "not-a-semver";
8889 let reason = "unexpected character '-' at position 3";
8890 assert_eq!(
8891 UpgradeError::from_invalid(from, reason),
8892 UpgradeError::FromInvalid {
8893 from: from.to_string(),
8894 reason: reason.to_string(),
8895 },
8896 "generated from_invalid ctor must produce byte-equal \
8897 UpgradeError to the open-coded struct-literal wrap on the \
8898 same (&str, &str) fixture",
8899 );
8900 }
8901
8902 #[test]
8903 fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
8904 let from = "0.2.0";
8905 let versao = "0.1.0";
8906 assert_eq!(
8907 UpgradeError::from_not_before_versao(from, versao),
8908 UpgradeError::FromNotBeforeVersao {
8909 from: from.to_string(),
8910 versao: versao.to_string(),
8911 },
8912 "generated from_not_before_versao ctor must produce byte-equal \
8913 UpgradeError to the open-coded struct-literal wrap on the \
8914 same (&str, &str) fixture",
8915 );
8916 }
8917
8918 #[test]
8919 fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
8920 let from = "0.1.0";
8921 let module = "hello-rio";
8922 assert_eq!(
8923 UpgradeError::duplicate_load_module(from, module),
8924 UpgradeError::DuplicateLoadModule {
8925 from: from.to_string(),
8926 module: module.to_string(),
8927 },
8928 "generated duplicate_load_module ctor must produce byte-equal \
8929 UpgradeError to the open-coded struct-literal wrap on the \
8930 same (&str, &str) fixture",
8931 );
8932 }
8933
8934 #[test]
8935 fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
8936 // Cross-axis routing pin: sweep the two constructor input axes
8937 // (`from: &str`, `<axis>: &str`) through distinct-per-axis
8938 // fixtures against every generated arm in the
8939 // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
8940 // lowercase / trim / truncate at codegen time — a silent field
8941 // swap between `from` and the middle `<axis>` field, or a
8942 // `<axis>` axis silently rerouted through the wrong field on any
8943 // one variant — surfaces here rather than at a downstream
8944 // diagnostic-shape mismatch. Peer of the sibling
8945 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8946 // (8e67041) cross-axis pin on the same envelope's sibling
8947 // `{ from: String, script: PathBuf }` two-slot family, and of the
8948 // sibling
8949 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8950 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
8951 // String, <axis>: String }` two-slot envelope. Distinct-per-
8952 // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
8953 // that would still pass a same-fixture-per-axis pin. Both
8954 // `&str`-literal and `&String` (via Deref coercion) carriers
8955 // are exercised because the three wire-up sites hand a mix of
8956 // both (the `from_invalid` site hands `&e.to_string()` — an
8957 // owned `String` — for `reason`; the `duplicate_load_module`
8958 // site hands a `&str` slice for `module`; the
8959 // `from_not_before_versao` site hands the caller-supplied
8960 // `versao: &str` for `versao`).
8961 let from = "0.1.0";
8962 let axis = "distinct-axis-value";
8963 let from_owned: String = from.to_string();
8964 let axis_owned: String = axis.to_string();
8965 for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
8966 assert_eq!(
8967 UpgradeError::from_invalid(from_in, axis_in),
8968 UpgradeError::FromInvalid {
8969 from: from.to_string(),
8970 reason: axis.to_string(),
8971 },
8972 "from_invalid must route `from` → `from`, `axis` → `reason` \
8973 in declared field order",
8974 );
8975 assert_eq!(
8976 UpgradeError::from_not_before_versao(from_in, axis_in),
8977 UpgradeError::FromNotBeforeVersao {
8978 from: from.to_string(),
8979 versao: axis.to_string(),
8980 },
8981 "from_not_before_versao must route `from` → `from`, \
8982 `axis` → `versao` in declared field order",
8983 );
8984 assert_eq!(
8985 UpgradeError::duplicate_load_module(from_in, axis_in),
8986 UpgradeError::DuplicateLoadModule {
8987 from: from.to_string(),
8988 module: axis.to_string(),
8989 },
8990 "duplicate_load_module must route `from` → `from`, \
8991 `axis` → `module` in declared field order",
8992 );
8993 }
8994 }
8995
8996 // Per-variant equivalence + accessor-fidelity + cross-axis pins for
8997 // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
8998 // the paired doc-block above the ctor definition) — the fold of the
8999 // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9000 // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9001 // duplicate gate onto one substrate primitive on the
9002 // [`UpgradeError`] envelope, projecting through the paired
9003 // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9004 // primitive. A byte-mismatched ctor body would trip the equivalence
9005 // pin first, ahead of any downstream diagnostic-shape drift.
9006 //
9007 // Peer of the sibling standalone-ctor equivalence pins on the peer
9008 // one-off variants across caixa-core:
9009 // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9010 // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9011 // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9012 // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9013 // envelope, the sibling
9014 // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9015 // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9016 // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9017 // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9018
9019 #[test]
9020 fn duplicate_from_ctor_matches_struct_literal_wrap() {
9021 // Equivalence pin: the ctor produces byte-equal
9022 // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9023 // struct-literal that read the same `from` field through
9024 // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9025 // addition / reordering / string-conversion tweak on the
9026 // variant. Same equivalence-pin shape as the sibling
9027 // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9028 // (b30edfe) on the paired two-slot `{ caixa, wit }`
9029 // envelope inside `impl AplicacaoSpec`.
9030 let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9031 let lifted = UpgradeError::duplicate_from(&entry);
9032 let struct_literal = UpgradeError::DuplicateFrom {
9033 from: entry.prior_versao().to_string(),
9034 };
9035 assert_eq!(lifted, struct_literal);
9036 }
9037
9038 #[test]
9039 fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9040 // Routing pin sweeping a non-default `:from` value
9041 // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9042 // release and build metadata) through the paired
9043 // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9044 // wrapper-side lowercase / trim / truncate on the one-field
9045 // construction surfaces here rather than at a downstream
9046 // diagnostic-shape drift. Peer of the sibling
9047 // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9048 // (b30edfe) routing pin on the sibling two-slot envelope.
9049 //
9050 // The pre-release + build-metadata carrier value is deliberately
9051 // chosen to exercise the `.to_string()` path against a `:from`
9052 // shape [`semver::Version::PartialEq`] treats as distinct from
9053 // its release-only sibling (per the
9054 // `validate_upgrade_from_treats_pre_release_as_distinct` and
9055 // build-metadata-tightening-note doc-block on
9056 // [`validate_upgrade_from`]) — so any silent normalization at
9057 // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9058 // `.split_once('-')` collapse) would drop bytes from the
9059 // rendered diagnostic and surface here.
9060 let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9061 let built = UpgradeError::duplicate_from(&entry);
9062 match built {
9063 UpgradeError::DuplicateFrom { from } => {
9064 assert_eq!(
9065 from, "1.2.3-rc.4+build.5",
9066 "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9067 preserving pre-release + build-metadata bytes"
9068 );
9069 }
9070 other => panic!("expected DuplicateFrom, got {other:?}"),
9071 }
9072 }
9073
9074 #[test]
9075 fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9076 // Accessor-fidelity pin: the ctor's `from` slot keys off the
9077 // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9078 // the pre-lift open-coded body's field selection), not any
9079 // stringified rendering of the full entry (e.g. the
9080 // `impl Display for UpgradeFromEntry` output, if one were later
9081 // added, or a `format!("{:?}", entry)` debug dump). Pins the
9082 // projection axis so a silent swap at the ctor body — say, a
9083 // future refactor that projects through `entry.instructions()`
9084 // in shape (dropping the `:from` axis entirely) or through a
9085 // whole-entry `format!` — surfaces here rather than at a
9086 // downstream diagnostic mis-attribution far from the duplicate
9087 // gate's owner.
9088 //
9089 // A future consumer that constructs the ctor against a not-yet-
9090 // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9091 // CR admission webhook re-checking a per-`:upgrade-from`-patched
9092 // candidate before the cross-entry duplicate gate re-fires, a
9093 // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9094 // `(:from …)` introduced by a cluster-local `:upgrade-from`
9095 // override) needs the pre-lift projection axis pinned.
9096 //
9097 // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9098 // paired with a distinctive multi-instruction sequence so a
9099 // silent swap that projects through the whole-entry rendering
9100 // instead of the paired scalar accessor would land debug bytes
9101 // from the `:instructions` list into the `from` slot and trip
9102 // the assertion here.
9103 let entry = entry(
9104 "0.2.0-alpha.7",
9105 vec![
9106 UpgradeInstruction::LoadModule {
9107 module: "distinctive-load-target".into(),
9108 },
9109 UpgradeInstruction::StateChange {
9110 script: PathBuf::from("lib/distinctive-migrate.lisp"),
9111 },
9112 UpgradeInstruction::Restart,
9113 ],
9114 );
9115 let built = UpgradeError::duplicate_from(&entry);
9116 match built {
9117 UpgradeError::DuplicateFrom { from } => {
9118 assert_eq!(
9119 from, "0.2.0-alpha.7",
9120 "from slot must project UpgradeFromEntry::prior_versao() \
9121 (not any whole-entry rendering)"
9122 );
9123 }
9124 other => panic!("expected DuplicateFrom, got {other:?}"),
9125 }
9126 }
9127
9128 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9129 // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9130 // ctor (see the paired doc-block above the ctor definition) — the
9131 // fold of the last open-coded three-slot `{ from: String, kind:
9132 // &'static str, module: String }` struct-literal wire-up on
9133 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9134 // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9135 // load-family sticky-latch dispatch onto one substrate primitive.
9136 // A byte-mismatched ctor body would trip the equivalence pin first,
9137 // ahead of any downstream diagnostic-shape drift.
9138 //
9139 // Peer of the sibling standalone-ctor equivalence + routing pins on
9140 // the sibling one-off variants across `UpgradeError`
9141 // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9142 // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9143 // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9144 // the paired one-slot `{ from: String }` envelope) and across
9145 // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9146 // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9147 // String }` `AplicacaoError` envelope).
9148
9149 #[test]
9150 fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9151 // Equivalence pin: the ctor produces byte-equal
9152 // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9153 // open-coded three-field struct-literal on the same `(&str,
9154 // &'static str, &str)` fixture. Guards any future field-
9155 // addition / reordering / string-conversion tweak on the
9156 // variant. Same equivalence-pin shape as the sibling
9157 // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9158 // on the peer one-slot `{ from: String }` envelope.
9159 let from = "0.1.0";
9160 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9161 let module = "hello-rio-old";
9162 assert_eq!(
9163 UpgradeError::purge_without_prior_load(from, kind, module),
9164 UpgradeError::PurgeWithoutPriorLoad {
9165 from: from.to_string(),
9166 kind,
9167 module: module.to_string(),
9168 },
9169 "generated purge_without_prior_load ctor must produce \
9170 byte-equal UpgradeError to the open-coded struct-literal \
9171 wrap on the same (&str, &'static str, &str) fixture",
9172 );
9173 }
9174
9175 #[test]
9176 fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9177 // Cross-axis routing pin: sweep the three constructor input
9178 // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9179 // through distinct-per-axis fixtures across every cleanup-family
9180 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9181 // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9182 // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9183 // wrapper-side lowercase / trim / truncate / silent axis-swap
9184 // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9185 // three-field construction surfaces at assert time rather than
9186 // at a downstream diagnostic consumer that reads the fields
9187 // back and gets a different value than the one it stored. Both
9188 // `&str`-literal and `&String` (via Deref coercion) carriers
9189 // are exercised for `from` / `module` because the sole wire-up
9190 // hands `self.prior_versao()` (a `&str` accessor) and
9191 // `instr.declared_module().expect(…)` (also a `&str`) — the
9192 // ctor must accept both shapes without a pre-conversion.
9193 let kinds: [&'static str; 2] = [
9194 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9195 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9196 ];
9197 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9198 let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9199 for kind in kinds {
9200 for from in froms {
9201 for module in modules {
9202 let from_owned: String = from.to_string();
9203 let module_owned: String = module.to_string();
9204 for (from_in, module_in) in
9205 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9206 {
9207 assert_eq!(
9208 UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9209 UpgradeError::PurgeWithoutPriorLoad {
9210 from: from.to_string(),
9211 kind,
9212 module: module.to_string(),
9213 },
9214 "purge_without_prior_load must route from → from, \
9215 kind → kind, module → module in declared field \
9216 order verbatim on ({from:?}, {kind:?}, {module:?})",
9217 );
9218 }
9219 }
9220 }
9221 }
9222 }
9223
9224 #[test]
9225 fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9226 // End-to-end wire-up pin: build an entry whose declared
9227 // `:instructions` list places a `:soft-purge` (and separately a
9228 // `:purge`) before any `:load-module` so
9229 // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9230 // sticky-latch dispatch surfaces
9231 // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9232 // observed `Err` byte-equals the substrate-primitive
9233 // [`UpgradeError::purge_without_prior_load`] ctor's output on
9234 // the same fixture. A future silent de-lift of the wire-up back
9235 // to the open-coded struct-literal (or a silent axis-swap on
9236 // the three-field construction at the wire-up site) trips at
9237 // caixa-core test time rather than at a downstream diagnostic
9238 // consumer far from the wire-up commit. Same end-to-end-wire-up
9239 // discipline as the sibling
9240 // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9241 // on the peer cross-entry duplicate-`:from` gate; both key off
9242 // exactly one typed dispatch on the substrate primitive.
9243 let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9244 (
9245 "0.1.0",
9246 UpgradeInstruction::SoftPurge {
9247 module: "hello-rio-old".into(),
9248 },
9249 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9250 "hello-rio-old",
9251 ),
9252 (
9253 "1.2.3-rc.1",
9254 UpgradeInstruction::Purge {
9255 module: "cache-v2-ancient".into(),
9256 },
9257 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9258 "cache-v2-ancient",
9259 ),
9260 ];
9261 for (from, instr, kind, module) in cases {
9262 let e = entry(from, vec![instr]);
9263 let observed = e.validate().unwrap_err();
9264 assert_eq!(
9265 observed,
9266 UpgradeError::purge_without_prior_load(from, kind, module),
9267 "validate_purge_ordering must route its refusal through \
9268 UpgradeError::purge_without_prior_load(from, kind, \
9269 module) on a bare-cleanup {kind:?} entry, byte-equal \
9270 to the pre-lift open-coded struct-literal wrap on the \
9271 same fixture",
9272 );
9273 }
9274 }
9275
9276 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9277 // the standalone [`UpgradeError::state_change_after_cleanup`]
9278 // inherent ctor (see the paired doc-block above the ctor
9279 // definition) — the fold of the last open-coded four-slot `{ from:
9280 // String, script: PathBuf, prior_cleanup_kind: &'static str,
9281 // prior_cleanup_module: String }` struct-literal wire-up on
9282 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9283 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9284 // migrate-family sticky-latch dispatch onto one substrate primitive.
9285 // A byte-mismatched ctor body would trip the equivalence pin first,
9286 // ahead of any downstream diagnostic-shape drift. Peer of the
9287 // sibling standalone-ctor equivalence + routing pins on the sibling
9288 // one-off variants across `UpgradeError`
9289 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9290 // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9291 // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9292 // on the paired three-slot `{ from, kind, module }` envelope;
9293 // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9294 // one-slot `{ from }` envelope).
9295
9296 #[test]
9297 fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9298 // Equivalence pin: the ctor produces byte-equal
9299 // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9300 // open-coded four-field struct-literal on the same `(&str,
9301 // &Path, &'static str, &str)` fixture. Guards any future
9302 // field-addition / reordering / string-conversion tweak on the
9303 // variant. Same equivalence-pin shape as the sibling
9304 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9305 // (9752da1) on the peer three-slot envelope.
9306 let from = "0.1.0";
9307 let script = Path::new("lib/m.lisp");
9308 let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9309 let prior_cleanup_module = "x-old";
9310 assert_eq!(
9311 UpgradeError::state_change_after_cleanup(
9312 from,
9313 script,
9314 prior_cleanup_kind,
9315 prior_cleanup_module,
9316 ),
9317 UpgradeError::StateChangeAfterCleanup {
9318 from: from.to_string(),
9319 script: script.to_path_buf(),
9320 prior_cleanup_kind,
9321 prior_cleanup_module: prior_cleanup_module.to_string(),
9322 },
9323 "generated state_change_after_cleanup ctor must produce \
9324 byte-equal UpgradeError to the open-coded struct-literal \
9325 wrap on the same (&str, &Path, &'static str, &str) fixture",
9326 );
9327 }
9328
9329 #[test]
9330 fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9331 // Cross-axis routing pin: sweep the four constructor input
9332 // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9333 // &'static str`, `prior_cleanup_module: &str`) through
9334 // distinct-per-axis fixtures across every cleanup-family
9335 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9336 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9337 // build-metadata, zero), sibling-`.lisp` script-path shapes
9338 // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9339 // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9340 // lowercase / trim / truncate / silent axis-swap
9341 // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9342 // `from`, `prior_cleanup_kind` misrouted onto
9343 // `prior_cleanup_module`) on the four-field construction
9344 // surfaces at assert time rather than at a downstream diagnostic
9345 // consumer that reads the fields back and gets a different value
9346 // than the one it stored. Both `&str`-literal and `&String` (via
9347 // Deref coercion) carriers are exercised for `from` /
9348 // `prior_cleanup_module` because the sole wire-up hands
9349 // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9350 // (also `&str`, from `declared_module().expect(…)`) — the ctor
9351 // must accept both shapes without a pre-conversion. Both
9352 // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9353 // are exercised for `script` because the sole wire-up hands a
9354 // `&PathBuf` sticky-latch projection from `declared_path()`'s
9355 // `Option<&PathBuf>` return — the ctor must accept both shapes
9356 // without a pre-conversion.
9357 let kinds: [&'static str; 2] = [
9358 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9359 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9360 ];
9361 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9362 let scripts: [&str; 3] = [
9363 "m.lisp",
9364 "lib/migrations.lisp",
9365 "lib/migrations/v01/step-1.lisp",
9366 ];
9367 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9368 for kind in kinds {
9369 for from in froms {
9370 for script_str in scripts {
9371 for module in modules {
9372 let from_owned: String = from.to_string();
9373 let module_owned: String = module.to_string();
9374 let script_path = Path::new(script_str);
9375 let script_pathbuf = PathBuf::from(script_str);
9376 for (from_in, module_in, script_in) in [
9377 (from, module, script_path),
9378 (
9379 from_owned.as_str(),
9380 module_owned.as_str(),
9381 script_pathbuf.as_path(),
9382 ),
9383 ] {
9384 assert_eq!(
9385 UpgradeError::state_change_after_cleanup(
9386 from_in, script_in, kind, module_in,
9387 ),
9388 UpgradeError::StateChangeAfterCleanup {
9389 from: from.to_string(),
9390 script: PathBuf::from(script_str),
9391 prior_cleanup_kind: kind,
9392 prior_cleanup_module: module.to_string(),
9393 },
9394 "state_change_after_cleanup must route from → from, \
9395 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9396 prior_cleanup_module → prior_cleanup_module in declared \
9397 field order verbatim on ({from:?}, {script_str:?}, \
9398 {kind:?}, {module:?})",
9399 );
9400 }
9401 }
9402 }
9403 }
9404 }
9405 }
9406
9407 #[test]
9408 fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9409 // End-to-end wire-up pin: build an entry whose declared
9410 // `:instructions` list places a `:soft-purge` (and separately a
9411 // `:purge`) before a `:state-change` so
9412 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9413 // migrate-family sticky-latch dispatch surfaces
9414 // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9415 // observed `Err` byte-equals the substrate-primitive
9416 // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9417 // the same fixture. A future silent de-lift of the wire-up back
9418 // to the open-coded struct-literal (or a silent axis-swap on
9419 // the four-field construction at the wire-up site) trips at
9420 // caixa-core test time rather than at a downstream diagnostic
9421 // consumer far from the wire-up commit. Same end-to-end-wire-up
9422 // discipline as the sibling
9423 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9424 // on the peer load → cleanup ordering gate; both key off
9425 // exactly one typed dispatch on the substrate primitive. Every
9426 // entry here front-loads a `:load-module` so the sole surviving
9427 // ordering refusal is the migrate → cleanup one this gate
9428 // owns — the peer `validate_purge_ordering` load → cleanup gate
9429 // returns `Ok(())` on these fixtures, so the migrate-after-
9430 // cleanup arm is the only path to an `Err`.
9431 let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9432 (
9433 "0.1.0",
9434 UpgradeInstruction::SoftPurge {
9435 module: "hello-rio-old".into(),
9436 },
9437 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9438 "hello-rio-old",
9439 "lib/migrations/v01.lisp",
9440 ),
9441 (
9442 "1.2.3-rc.1",
9443 UpgradeInstruction::Purge {
9444 module: "cache-v2-ancient".into(),
9445 },
9446 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9447 "cache-v2-ancient",
9448 "lib/migrations/v02.lisp",
9449 ),
9450 ];
9451 for (from, cleanup, kind, module, script_str) in cases {
9452 let script = PathBuf::from(script_str);
9453 let e = entry(
9454 from,
9455 vec![
9456 UpgradeInstruction::LoadModule {
9457 module: "hello-rio".into(),
9458 },
9459 cleanup,
9460 UpgradeInstruction::StateChange {
9461 script: script.clone(),
9462 },
9463 ],
9464 );
9465 let observed = e.validate().unwrap_err();
9466 assert_eq!(
9467 observed,
9468 UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9469 "validate_state_change_before_cleanup must route its \
9470 refusal through \
9471 UpgradeError::state_change_after_cleanup(from, script, \
9472 prior_cleanup_kind, prior_cleanup_module) on a \
9473 `:state-change` after a bare-cleanup {kind:?} entry, \
9474 byte-equal to the pre-lift open-coded struct-literal \
9475 wrap on the same fixture",
9476 );
9477 }
9478 }
9479
9480 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9481 // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9482 // (see the paired doc-block above the ctor definition) — the fold of
9483 // the last open-coded three-slot `{ from: String, module: String,
9484 // kinds: Vec<&'static str> }` struct-literal wire-up on
9485 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9486 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9487 // cleanup-family dedup arm onto one substrate primitive. A byte-
9488 // mismatched ctor body would trip the equivalence pin first, ahead of
9489 // any downstream diagnostic-shape drift. Peer of the sibling
9490 // standalone-ctor equivalence + routing pins on the sibling one-off
9491 // variants across `UpgradeError`
9492 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9493 // paired three-slot `{ from, kind, module }` envelope for the sibling
9494 // load → cleanup ordering axis;
9495 // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9496 // the paired four-slot `{ from, script, prior_cleanup_kind,
9497 // prior_cleanup_module }` envelope for the migrate → cleanup
9498 // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9499 // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9500 // `:from` gate).
9501
9502 #[test]
9503 fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9504 // Equivalence pin: the ctor produces byte-equal
9505 // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9506 // three-field struct-literal on the same `(&str, &str,
9507 // Vec<&'static str>)` fixture. Guards any future field-addition /
9508 // reordering / string-conversion tweak on the variant. Same
9509 // equivalence-pin shape as the sibling
9510 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9511 // (9752da1) on the peer three-slot envelope.
9512 let from = "0.1.0";
9513 let module = "x-old";
9514 let kinds: Vec<&'static str> = vec![
9515 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9516 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9517 ];
9518 assert_eq!(
9519 UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9520 UpgradeError::DuplicateCleanup {
9521 from: from.to_string(),
9522 module: module.to_string(),
9523 kinds,
9524 },
9525 "generated duplicate_cleanup ctor must produce byte-equal \
9526 UpgradeError to the open-coded struct-literal wrap on the \
9527 same (&str, &str, Vec<&'static str>) fixture",
9528 );
9529 }
9530
9531 #[test]
9532 fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9533 // Cross-axis routing pin: sweep the three constructor input axes
9534 // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9535 // through distinct-per-axis fixtures across every ordered pair of
9536 // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9537 // four `(prior_kind, kind)` combinations `validate_cleanup_
9538 // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9539 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9540 // build-metadata, zero) + DNS-1123 module shapes (leaf,
9541 // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9542 // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9543 // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9544 // owned `Vec<&'static str>`) on the three-field construction
9545 // surfaces at assert time rather than at a downstream diagnostic
9546 // consumer that reads the fields back and gets a different value
9547 // than the one it stored. Both `&str`-literal and `&String` (via
9548 // Deref coercion) carriers are exercised for `from` / `module`
9549 // because the sole wire-up hands `self.prior_versao()` (a `&str`
9550 // accessor) and `module` (also `&str`, from `declared_module().
9551 // expect(…)`) — the ctor must accept both shapes without a
9552 // pre-conversion.
9553 let all_kinds: [&'static str; 2] = [
9554 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9555 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9556 ];
9557 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9558 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9559 for prior_kind in all_kinds {
9560 for kind in all_kinds {
9561 for from in froms {
9562 for module in modules {
9563 let from_owned: String = from.to_string();
9564 let module_owned: String = module.to_string();
9565 for (from_in, module_in) in
9566 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9567 {
9568 let kinds: Vec<&'static str> = vec![prior_kind, kind];
9569 assert_eq!(
9570 UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
9571 UpgradeError::DuplicateCleanup {
9572 from: from.to_string(),
9573 module: module.to_string(),
9574 kinds,
9575 },
9576 "duplicate_cleanup must route from → from, \
9577 module → module, kinds → kinds in declared \
9578 field order verbatim on ({from:?}, \
9579 {module:?}, [{prior_kind:?}, {kind:?}])",
9580 );
9581 }
9582 }
9583 }
9584 }
9585 }
9586 }
9587
9588 #[test]
9589 fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
9590 // End-to-end wire-up pin: build an entry whose declared
9591 // `:instructions` list front-loads a `:load-module` (so the
9592 // sibling `validate_purge_ordering` load → cleanup gate returns
9593 // `Ok(())` on the fixture) and then places two cleanup
9594 // instructions targeting the same module so
9595 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9596 // cleanup-family dedup arm surfaces
9597 // `UpgradeError::DuplicateCleanup`, then pin that the observed
9598 // `Err` byte-equals the substrate-primitive
9599 // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
9600 // fixture. A future silent de-lift of the wire-up back to the
9601 // open-coded struct-literal (or a silent axis-swap on the three-
9602 // field construction at the wire-up site, or a kinds-pair
9603 // reorder) trips at caixa-core test time rather than at a
9604 // downstream diagnostic consumer far from the wire-up commit.
9605 // Same end-to-end-wire-up discipline as the sibling
9606 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9607 // on the peer load → cleanup ordering gate and
9608 // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
9609 // on the peer migrate → cleanup boundary; all three key off
9610 // exactly one typed dispatch on the substrate primitive.
9611 let cases: [(
9612 &str,
9613 UpgradeInstruction,
9614 UpgradeInstruction,
9615 &str,
9616 [&'static str; 2],
9617 ); 4] = [
9618 (
9619 "0.1.0",
9620 UpgradeInstruction::SoftPurge {
9621 module: "hello-rio-old".into(),
9622 },
9623 UpgradeInstruction::SoftPurge {
9624 module: "hello-rio-old".into(),
9625 },
9626 "hello-rio-old",
9627 [
9628 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9629 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9630 ],
9631 ),
9632 (
9633 "1.2.3-rc.1",
9634 UpgradeInstruction::Purge {
9635 module: "cache-v2-ancient".into(),
9636 },
9637 UpgradeInstruction::Purge {
9638 module: "cache-v2-ancient".into(),
9639 },
9640 "cache-v2-ancient",
9641 [
9642 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9643 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9644 ],
9645 ),
9646 (
9647 "0.2.0-alpha.7+build.5",
9648 UpgradeInstruction::SoftPurge {
9649 module: "x-old".into(),
9650 },
9651 UpgradeInstruction::Purge {
9652 module: "x-old".into(),
9653 },
9654 "x-old",
9655 [
9656 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9657 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9658 ],
9659 ),
9660 (
9661 "0.0.0",
9662 UpgradeInstruction::Purge {
9663 module: "x-old".into(),
9664 },
9665 UpgradeInstruction::SoftPurge {
9666 module: "x-old".into(),
9667 },
9668 "x-old",
9669 [
9670 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9671 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9672 ],
9673 ),
9674 ];
9675 for (from, first, second, module, kinds) in cases {
9676 let e = entry(
9677 from,
9678 vec![
9679 UpgradeInstruction::LoadModule {
9680 module: "hello-rio".into(),
9681 },
9682 first,
9683 second,
9684 ],
9685 );
9686 let observed = e.validate().unwrap_err();
9687 assert_eq!(
9688 observed,
9689 UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
9690 "validate_cleanup_singularity must route its refusal \
9691 through UpgradeError::duplicate_cleanup(from, module, \
9692 kinds) on a two-cleanup {kinds:?} entry targeting the \
9693 same module, byte-equal to the pre-lift open-coded \
9694 struct-literal wrap on the same fixture",
9695 );
9696 }
9697 }
9698
9699 #[test]
9700 fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
9701 // Equivalence pin: the ctor produces byte-equal
9702 // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
9703 // three-field struct-literal on the same `(&str, usize,
9704 // Vec<&'static str>)` fixture. Guards any future field-addition /
9705 // reordering / string-conversion tweak on the variant. Same
9706 // equivalence-pin shape as the sibling
9707 // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
9708 // on the peer three-slot envelope.
9709 let from = "0.1.0";
9710 let restart_count: usize = 1;
9711 let other_kinds: Vec<&'static str> =
9712 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
9713 assert_eq!(
9714 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9715 UpgradeError::RestartNotExclusive {
9716 from: from.to_string(),
9717 restart_count,
9718 other_kinds,
9719 },
9720 "generated restart_not_exclusive ctor must produce byte-equal \
9721 UpgradeError to the open-coded struct-literal wrap on the \
9722 same (&str, usize, Vec<&'static str>) fixture",
9723 );
9724 }
9725
9726 #[test]
9727 fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
9728 // Cross-axis routing pin: sweep the three constructor input axes
9729 // (`from: &str`, `restart_count: usize`, `other_kinds:
9730 // Vec<&'static str>`) through distinct-per-axis fixtures across a
9731 // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
9732 // pre-release + build-metadata, zero) × non-degenerate
9733 // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
9734 // pure-duplication shape, 3 — the deeply-duplicated shape) ×
9735 // ordered `other_kinds` lisp-form lists spanning the four
9736 // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
9737 // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
9738 // empty (the `((:restart) (:restart))` shape), singleton
9739 // (`((:load-module …) (:restart))`), and the full typed sequence
9740 // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
9741 // …) (:restart))`) — so any wrapper-side silent lowercase / trim
9742 // / truncate / silent axis-swap (`from` ↔ swap onto
9743 // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
9744 // duplicate on the four-element owned `Vec<&'static str>`,
9745 // `other_kinds` reorder against declared instruction order) on
9746 // the three-field construction surfaces at assert time rather
9747 // than at a downstream diagnostic consumer that reads the fields
9748 // back and gets a different value than the one it stored. Both
9749 // `&str`-literal and `&String` (via Deref coercion) carriers are
9750 // exercised for `from` because the sole wire-up hands
9751 // `self.prior_versao()` (a `&str` accessor).
9752 let all_typed_kinds: [&'static str; 4] = [
9753 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9754 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9755 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9756 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9757 ];
9758 let other_kinds_matrix: [Vec<&'static str>; 3] =
9759 [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
9760 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9761 let restart_counts: [usize; 3] = [1, 2, 3];
9762 for other_kinds in &other_kinds_matrix {
9763 for restart_count in restart_counts {
9764 for from in froms {
9765 let from_owned: String = from.to_string();
9766 for from_in in [from, from_owned.as_str()] {
9767 assert_eq!(
9768 UpgradeError::restart_not_exclusive(
9769 from_in,
9770 restart_count,
9771 other_kinds.clone(),
9772 ),
9773 UpgradeError::RestartNotExclusive {
9774 from: from.to_string(),
9775 restart_count,
9776 other_kinds: other_kinds.clone(),
9777 },
9778 "restart_not_exclusive must route from → from, \
9779 restart_count → restart_count, other_kinds → \
9780 other_kinds in declared field order verbatim \
9781 on ({from:?}, {restart_count:?}, \
9782 {other_kinds:?})",
9783 );
9784 }
9785 }
9786 }
9787 }
9788 }
9789
9790 #[test]
9791 fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
9792 // End-to-end wire-up pin: sweep the three canonical exclusivity-
9793 // violation shapes the `validate_restart_exclusive` gate can
9794 // refuse — restart + one typed instruction (`restart_count: 1,
9795 // other_kinds: [load-module]`), restart + full typed sequence
9796 // (`restart_count: 1, other_kinds: [load-module, state-change,
9797 // soft-purge, purge]`), and duplicated restart only
9798 // (`restart_count: 2, other_kinds: []`) — and pin that each
9799 // observed `Err` byte-equals the substrate-primitive
9800 // [`UpgradeError::restart_not_exclusive`] ctor's output on the
9801 // same fixture. A future silent de-lift of the wire-up back to
9802 // the open-coded struct-literal (or a silent axis-swap on the
9803 // three-field construction at the wire-up site, or an
9804 // `other_kinds` reorder / drop) trips at caixa-core test time
9805 // rather than at a downstream diagnostic consumer far from the
9806 // wire-up commit. Same end-to-end-wire-up discipline as the
9807 // sibling
9808 // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
9809 // (10a5b48) on the peer per-module cleanup-singularity axis and
9810 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9811 // on the peer load → cleanup ordering gate; all three key off
9812 // exactly one typed dispatch on the substrate primitive.
9813 let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
9814 (
9815 "0.1.0",
9816 vec![
9817 UpgradeInstruction::LoadModule {
9818 module: "hello-rio".into(),
9819 },
9820 UpgradeInstruction::Restart,
9821 ],
9822 1,
9823 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
9824 ),
9825 (
9826 "1.2.3-rc.1",
9827 vec![
9828 UpgradeInstruction::LoadModule {
9829 module: "hello-rio".into(),
9830 },
9831 UpgradeInstruction::StateChange {
9832 script: PathBuf::from("lib/m.lisp"),
9833 },
9834 UpgradeInstruction::SoftPurge {
9835 module: "hello-rio-old".into(),
9836 },
9837 UpgradeInstruction::Purge {
9838 module: "hello-rio-old".into(),
9839 },
9840 UpgradeInstruction::Restart,
9841 ],
9842 1,
9843 vec![
9844 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9845 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9846 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9847 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9848 ],
9849 ),
9850 (
9851 "0.0.0",
9852 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
9853 2,
9854 vec![],
9855 ),
9856 ];
9857 for (from, instructions, restart_count, other_kinds) in cases {
9858 let e = entry(from, instructions);
9859 let observed = e.validate().unwrap_err();
9860 assert_eq!(
9861 observed,
9862 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9863 "validate_restart_exclusive must route its refusal \
9864 through UpgradeError::restart_not_exclusive(from, \
9865 restart_count, other_kinds) on a mixed-`(:restart)` \
9866 entry, byte-equal to the pre-lift open-coded struct-\
9867 literal wrap on the same fixture",
9868 );
9869 }
9870 }
9871
9872 #[test]
9873 fn module_invalid_ctor_matches_struct_literal_wrap() {
9874 // Fail-before-pass-after equivalence pin on
9875 // [`UpgradeError::module_invalid`] — the constructor must
9876 // produce a byte-equal `UpgradeError` to the pre-lift open-
9877 // coded `Self::ModuleInvalid { kind, module: module.to_string(),
9878 // reason }` struct-literal on the same `(:load-module …)` /
9879 // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
9880 // mismatched constructor body (a stray `.trim()`, a rebased
9881 // field order, a `String::new()` reason substitution) would
9882 // trip this pin first, byte-for-byte against the sibling
9883 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
9884 // [`crate::SupervisorError::child_caixa_invalid`] /
9885 // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
9886 // discipline on the peer three-slot `{ *, reason: String }`
9887 // invalid-arm ctor family.
9888 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
9889 let module = "Hello-Rio";
9890 let reason = "must be lowercase alphanumeric or `-`";
9891 assert_eq!(
9892 UpgradeError::module_invalid(kind, module, reason),
9893 UpgradeError::ModuleInvalid {
9894 kind,
9895 module: module.to_string(),
9896 reason: reason.to_string(),
9897 },
9898 "generated module_invalid ctor must produce byte-equal \
9899 UpgradeError to the open-coded struct-literal wrap on the \
9900 same (kind, module, reason) fixture",
9901 );
9902 }
9903
9904 #[test]
9905 fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
9906 // Cross-axis pin: sweep the constructor's `kind: &'static str`
9907 // input across every [`UpgradeInstruction::declared_module`]-
9908 // bearing variant's canonical
9909 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
9910 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
9911 // canonical `":phantom"` fourth arm proving the ctor does not
9912 // silently clamp `kind` to the three-arm roster. The
9913 // `reason: impl Into<String>` bound accepts both `&str`
9914 // literals and the [`String`] the underlying
9915 // [`crate::render::is_dns_1123_label`] predicate returns via
9916 // `.into()`, matching the peer
9917 // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
9918 // sweep on the sibling `:contratos` per-edge envelope.
9919 let module = "Hello-Rio";
9920 let reason = "must be lowercase alphanumeric or `-`";
9921 for kind in [
9922 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9923 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9924 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9925 ":phantom",
9926 ] {
9927 assert_eq!(
9928 UpgradeError::module_invalid(kind, module, reason),
9929 UpgradeError::ModuleInvalid {
9930 kind,
9931 module: module.to_string(),
9932 reason: reason.to_string(),
9933 },
9934 "module_invalid ctor must thread kind={kind:?} verbatim",
9935 );
9936 }
9937 }
9938
9939 #[test]
9940 fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
9941 // End-to-end wire-up pin: [`validate_module`]'s
9942 // [`crate::render::require_valid_dns_1123_label`] invalid-arm
9943 // must emit a diagnostic byte-equal to the ctor's output on the
9944 // same `(kind, module)` fixture — the fold's invariant that
9945 // [`validate_module`]'s cascade reaches the
9946 // [`UpgradeError::ModuleInvalid`] envelope through the
9947 // substrate primitive [`UpgradeError::module_invalid`] rather
9948 // than the pre-lift open-coded struct-literal. Sweep every
9949 // [`UpgradeInstruction::declared_module`]-bearing variant
9950 // against a canonical footgun (`"Hello-Rio"` — the uppercase-
9951 // lead footgun the peer `validate_rejects_non_dns_1123_module`
9952 // test above already carries) so every wire-up on the invalid-
9953 // arm cascade lands on the ctor's output. Matches the peer
9954 // sibling end-to-end pin
9955 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
9956 // carries on `validate_contrato_caixa`'s
9957 // `require_valid_dns_1123_label` invalid-arm.
9958 let module = "Hello-Rio";
9959 let cases: &[(UpgradeInstruction, &'static str)] = &[
9960 (
9961 UpgradeInstruction::LoadModule {
9962 module: module.to_string(),
9963 },
9964 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9965 ),
9966 (
9967 UpgradeInstruction::SoftPurge {
9968 module: module.to_string(),
9969 },
9970 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9971 ),
9972 (
9973 UpgradeInstruction::Purge {
9974 module: module.to_string(),
9975 },
9976 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9977 ),
9978 ];
9979 for (instr, expected_kind) in cases {
9980 let observed = instr.validate().unwrap_err();
9981 let UpgradeError::ModuleInvalid {
9982 reason: observed_reason,
9983 ..
9984 } = &observed
9985 else {
9986 panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
9987 };
9988 assert_eq!(
9989 observed,
9990 UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
9991 "validate_module must route its invalid-arm refusal \
9992 through UpgradeError::module_invalid(kind, module, \
9993 reason) on {instr:?}, byte-equal to the pre-lift open-\
9994 coded struct-literal wrap on the same fixture",
9995 );
9996 }
9997 }
9998
9999 #[test]
10000 fn module_empty_ctor_matches_struct_literal_wrap() {
10001 // Fail-before-pass-after equivalence pin on
10002 // [`UpgradeError::module_empty`] — the constructor must produce
10003 // a byte-equal `UpgradeError` to the pre-lift open-coded
10004 // `Self::ModuleEmpty { kind }` struct-literal on the same
10005 // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10006 // axis-tag fixture. A byte-mismatched constructor body (a stray
10007 // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10008 // one of the three canonical arms, a fixed-slot substitution)
10009 // would trip this pin first, matching the sibling
10010 // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10011 // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10012 // pin discipline on the peer one-slot `{ *: &'static str }`
10013 // empty-arm ctor family.
10014 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10015 assert_eq!(
10016 UpgradeError::module_empty(kind),
10017 UpgradeError::ModuleEmpty { kind },
10018 "generated module_empty ctor must produce byte-equal \
10019 UpgradeError to the open-coded struct-literal wrap on the \
10020 same kind fixture",
10021 );
10022 }
10023
10024 #[test]
10025 fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10026 // Cross-axis pin: sweep the constructor's `kind: &'static str`
10027 // input across every [`UpgradeInstruction::declared_module`]-
10028 // bearing variant's canonical
10029 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10030 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10031 // canonical `":phantom"` fourth arm proving the ctor does not
10032 // silently clamp `kind` to the three-arm roster (a future
10033 // fourth `declared_module`-bearing `UpgradeInstruction` variant
10034 // lands on this ctor without a per-arm rewrite). Matches the
10035 // sibling [`Self::module_invalid`] cross-axis sweep at
10036 // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10037 // on the paired three-slot invalid-arm envelope so both arms of
10038 // the [`validate_module`] two-closure cascade carry the same
10039 // axis-invariance guarantee.
10040 for kind in [
10041 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10042 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10043 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10044 ":phantom",
10045 ] {
10046 assert_eq!(
10047 UpgradeError::module_empty(kind),
10048 UpgradeError::ModuleEmpty { kind },
10049 "module_empty ctor must thread kind={kind:?} verbatim",
10050 );
10051 }
10052 }
10053
10054 #[test]
10055 fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10056 // End-to-end wire-up pin: [`validate_module`]'s
10057 // [`crate::render::require_valid_dns_1123_label`] empty-arm
10058 // must emit a diagnostic byte-equal to the ctor's output on the
10059 // same `(kind, "")` fixture — the fold's invariant that
10060 // [`validate_module`]'s cascade reaches the
10061 // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10062 // primitive [`UpgradeError::module_empty`] rather than the
10063 // pre-lift open-coded struct-literal. Sweep every
10064 // [`UpgradeInstruction::declared_module`]-bearing variant
10065 // against the empty-string module value so every wire-up on the
10066 // empty-arm cascade lands on the ctor's output. Closes the pair
10067 // on the [`validate_module`] two-closure cascade the sibling
10068 // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10069 // (3d0d64a) already anchors on the invalid-arm.
10070 let cases: &[(UpgradeInstruction, &'static str)] = &[
10071 (
10072 UpgradeInstruction::LoadModule {
10073 module: String::new(),
10074 },
10075 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10076 ),
10077 (
10078 UpgradeInstruction::SoftPurge {
10079 module: String::new(),
10080 },
10081 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10082 ),
10083 (
10084 UpgradeInstruction::Purge {
10085 module: String::new(),
10086 },
10087 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10088 ),
10089 ];
10090 for (instr, expected_kind) in cases {
10091 assert_eq!(
10092 instr.validate().unwrap_err(),
10093 UpgradeError::module_empty(expected_kind),
10094 "validate_module must route its empty-arm refusal \
10095 through UpgradeError::module_empty(kind) on {instr:?}, \
10096 byte-equal to the pre-lift open-coded struct-literal \
10097 wrap on the same fixture",
10098 );
10099 }
10100 }
10101}