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 /// Kebab-case lisp form name for this instruction, used as the
1616 /// `:kind` tag in [`UpgradeError::ModuleEmpty`] /
1617 /// [`UpgradeError::ModuleInvalid`] diagnostics so the author can
1618 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)`
1619 /// / `(:purge …)` and fix it in one edit. Mirrors the kebab-case
1620 /// slot tags `BehaviorError::EmptyPath` (b0c8389) and
1621 /// `UpgradeFromEntry`'s `:from` field already carry.
1622 #[must_use]
1623 const fn lisp_form(&self) -> &'static str {
1624 match self {
1625 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1626 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1627 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1628 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1629 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1630 }
1631 }
1632
1633 /// Validate the instruction's typed shape. Path existence is
1634 /// checked separately by [`crate::layout::StandardLayout`].
1635 ///
1636 /// The per-variant scalar the value-shape gates fire against is
1637 /// read through this method's two sibling accessors — the
1638 /// `String`-carrying axis via [`Self::declared_module`] (the
1639 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1640 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1641 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1642 /// variant's tatara-lisp `:script`) — rather than the per-arm
1643 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1644 /// Self::Purge { module }` pattern the module-axis previously
1645 /// open-coded and the per-arm `Self::StateChange { script }` the
1646 /// script-axis previously open-coded. Every scalar this enum
1647 /// carries now flows through one of the two `Option<&…>`
1648 /// accessors, so a future extension of either axis (a fifth
1649 /// module-bearing variant, an operator-side pre-parsed scalar
1650 /// cache the accessors materialize behind the same return
1651 /// contract, an M4 typed sub-slot the accessors could route
1652 /// alongside the existing scalar) migrates as a single edit on
1653 /// the accessor rather than a coordinated rewrite of every
1654 /// downstream value-shape gate. `Restart` (the only variant that
1655 /// carries neither scalar) falls through both `Option` checks and
1656 /// returns `Ok(())` — the terminal-fallback shape the
1657 /// [`Self::Restart`] variant doc pins.
1658 pub fn validate(&self) -> Result<(), UpgradeError> {
1659 if let Some(module) = self.declared_module() {
1660 return validate_module(self.lisp_form(), module);
1661 }
1662 if let Some(script) = self.declared_path() {
1663 // Delegate the four-arm cascade (empty / absolute /
1664 // parent-escape / non-`.lisp`-extension) to the lifted
1665 // [`crate::render::require_sandboxed_lisp_path`] helper —
1666 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1667 // arm-ordering this method previously inlined verbatim,
1668 // now shared with [`crate::BehaviorSpec::validate`]'s
1669 // per-`:on-*`-callback gate so every author-supplied
1670 // tatara-lisp source path on every M2 typed slot consults
1671 // one gate, not two-and-counting verbatim copies of the
1672 // same four-arm cascade. Each closure wraps the tag in
1673 // the same `*Script` variant the original inline code
1674 // raised, so the diagnostic shape every caller depends
1675 // on (the `:state-change :script` self-locating error)
1676 // is preserved by construction. See
1677 // [`crate::render::require_sandboxed_lisp_path`] for the
1678 // smallest-scope-arm-fires-last ordering rationale.
1679 crate::render::require_sandboxed_lisp_path(
1680 script,
1681 || UpgradeError::EmptyScript,
1682 || UpgradeError::absolute_script(script),
1683 || UpgradeError::parent_escape_script(script),
1684 || UpgradeError::non_lisp_extension_script(script),
1685 )?;
1686 }
1687 // `Restart` (the only variant with no `Option<&…>`-carrying
1688 // scalar) falls through both accessor gates and returns
1689 // `Ok(())` — the terminal-fallback shape.
1690 Ok(())
1691 }
1692
1693 /// The `:module` scalar carried by this instruction — the
1694 /// K8s DNS-1123-label OTP-appup caixa-name reference every
1695 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1696 /// variant declares against, and every author expects `feira lint`
1697 /// to name verbatim in per-instruction diagnostics. Returns `None`
1698 /// on [`Self::StateChange`] (which carries a `:script` — closed by
1699 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1700 /// (which carries no data at all, the OTP terminal-fallback
1701 /// shape).
1702 ///
1703 /// Sibling in shape to [`Self::declared_path`] on the second and
1704 /// final scalar-carrying axis of [`UpgradeInstruction`]:
1705 /// `declared_path` closes the `PathBuf`-carrying arm
1706 /// (`StateChange`); `declared_module` closes the `String`-carrying
1707 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1708 /// enum carries now routes through one of the two `Option<&…>`
1709 /// accessors — a caller that doesn't care which variant declared
1710 /// the scalar reads through one `if let Some(…)` rather than a
1711 /// per-variant pattern match. The pair is the enum-variant-
1712 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1713 /// on the M3 side ([`crate::WitContract::source`] /
1714 /// [`crate::WitContract::destination`] /
1715 /// [`crate::WitContract::world_ref`] closing `:contratos`;
1716 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1717 /// closing `:entrada`; [`crate::Membro::nome`] /
1718 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1719 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1720 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1721 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1722 /// closed OTP-shape supervisor family) — those peer accessors
1723 /// return a struct field verbatim; this pair unifies enum-
1724 /// variant-carried scalars into one accessor per typed axis.
1725 ///
1726 /// Byte-for-byte from the typed variant's own `String` storage;
1727 /// no cloning, no re-parsing. A future extension of the axis (an
1728 /// M4 typed sub-slot the module string is derived from, an
1729 /// operator-side pre-parsed caixa-name cache the accessor could
1730 /// materialize behind the same `&str` return contract, a fifth
1731 /// module-bearing OTP-appup variant the enum grows) migrates as
1732 /// a single caixa-core edit rather than a coordinated rewrite
1733 /// of every downstream module-axis consumer (currently
1734 /// [`Self::validate`]'s DNS-1123-label gate through
1735 /// [`validate_module`]; extensible to future consumers on the
1736 /// same axis without further per-variant match sites).
1737 #[must_use]
1738 pub const fn declared_module(&self) -> Option<&str> {
1739 match self {
1740 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1741 Some(module.as_str())
1742 }
1743 Self::StateChange { .. } | Self::Restart => None,
1744 }
1745 }
1746
1747 /// If the instruction references an on-disk path, return it —
1748 /// used by the layout checker to verify the path resolves.
1749 ///
1750 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1751 /// on the `String`-carrying axis: `declared_path` closes the
1752 /// `StateChange` arm's `:script`; `declared_module` closes the
1753 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1754 /// they route every scalar this enum carries through one of two
1755 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1756 /// gates dispatch on the accessor return rather than a per-variant
1757 /// pattern match on the enum shape itself.
1758 ///
1759 /// Four per-`UpgradeInstruction` consumers now key off this
1760 /// accessor's `PathBuf`-carrying axis:
1761 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1762 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1763 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1764 /// within-entry
1765 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1766 /// per-`StateChange` script-projection fan-out, and the cross-slot
1767 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1768 /// :behavior` composition gate's per-`StateChange` detection loop
1769 /// — every downstream consumer of the `PathBuf`-carrying axis
1770 /// reaches through this one dispatch, so a future accessor
1771 /// extension (an M4 typed sub-slot the script path is derived from,
1772 /// an operator-side pre-resolved-path cache the accessor
1773 /// materializes behind the same `Option<&PathBuf>` return contract,
1774 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1775 /// migrates as a single caixa-core edit rather than a coordinated
1776 /// rewrite of four call sites.
1777 #[must_use]
1778 pub const fn declared_path(&self) -> Option<&PathBuf> {
1779 match self {
1780 Self::StateChange { script } => Some(script),
1781 _ => None,
1782 }
1783 }
1784
1785 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1786 /// family arm-discriminator predicate every within-entry cross-
1787 /// instruction cleanup-facing gate keys off — true iff `self` is
1788 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1789 /// named module until no process is running it, then GC) or
1790 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1791 /// module immediately, without waiting for drain), the two OTP
1792 /// two-phase-code-load cleanup arms the closed-set enum's
1793 /// non-terminal / non-migration / non-load variants exhaust.
1794 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1795 /// two-phase-load half, [`Self::StateChange`] on the
1796 /// `gen_server:code_change/3`-analog migration axis,
1797 /// [`Self::Restart`] on the OTP terminal-fallback shape)
1798 /// returns `false`.
1799 ///
1800 /// Prior to this lift the `Self::SoftPurge { module } |
1801 /// Self::Purge { module }` two-arm cleanup-family pattern-
1802 /// match sat inline at three within-entry cross-instruction
1803 /// gate sites, each hand-rolling its own copy of the union
1804 /// with no compile-time link back to the substrate primitive's
1805 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1806 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1807 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1808 /// arriving before a preceding [`Self::LoadModule`]),
1809 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1810 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1811 /// recording the first-encountered cleanup so a subsequent
1812 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1813 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1814 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1815 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1816 /// second cleanup targeting the same `:module`). Three open-
1817 /// coded per-arm-union pattern-matches that expressed no
1818 /// compile-time link back to the substrate primitive. A future
1819 /// fifth cleanup-shaped variant (a `Discard` variant the
1820 /// `code:delete/1` peer inspires that folds under the same
1821 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1822 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1823 /// cool-down policy grows a two-arm shape, an operator-side
1824 /// pre-resolved cleanup-decision cache the predicate could
1825 /// route through the same `bool` return contract) would have
1826 /// had to be threaded through every open-coded per-arm-union
1827 /// pattern-match in lockstep or one gate would silently
1828 /// classify the new arm outside the cleanup family while the
1829 /// peer gates classified it in (or vice versa) — a
1830 /// classification split across the three within-entry cross-
1831 /// instruction gates at build time that lands far from the
1832 /// source [`UpgradeInstruction`] declaration with no field
1833 /// naming which gate carries the drifted arm-set. Lifting the
1834 /// resolution to a typed predicate on the substrate primitive
1835 /// means every downstream cleanup-facing consumer of the
1836 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1837 /// one typed dispatch — the resolver's arm-set migrates as a
1838 /// unit on any future arm addition composing under this
1839 /// predicate's `||` chain.
1840 ///
1841 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1842 /// derive-generated [`Self::is_restart`] terminal-fallback
1843 /// arm-discriminator predicate on the same closed-set
1844 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1845 /// family partition as one typed dispatch on the substrate
1846 /// primitive; `is_restart` on the single-arm terminal-
1847 /// fallback family, `is_cleanup` on the two-arm cleanup
1848 /// family), extended here from the single-arm case onto the
1849 /// two-arm arm-family union case. Composes through the
1850 /// [`gen_platform::IsVariant`]-derive-generated
1851 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1852 /// predicates rather than an open-coded raw `matches!`
1853 /// pattern-match, so a future rebrand on either underlying
1854 /// per-arm classifier flows through this predicate's one
1855 /// body without a coordinated per-consumer rewrite across
1856 /// the three within-entry cross-instruction gates that route
1857 /// through it. Peer of the sibling per-`:contratos`
1858 /// shape-family union predicates [`crate::WitContract::is_http`] /
1859 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
1860 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
1861 /// per-shape WIT-prefix rule the substrate primitive's arm-
1862 /// family partition names as one typed dispatch) — the same
1863 /// "one typed dispatch on the substrate primitive, thin
1864 /// projections at each consumer" discipline extended onto the
1865 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
1866 /// cleanup-family axis.
1867 ///
1868 /// The name `is_cleanup` maps directly onto the canonical
1869 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
1870 /// `code:soft_purge/1` — wait until no process is running v1,
1871 /// then discard. (`code:purge/1` kills v1 immediately if you
1872 /// don't care.)" — the two `code:*_purge/1` operations are
1873 /// the two-phase-load contract's cleanup half, paired under
1874 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
1875 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1876 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
1877 /// reaches for the same "cleanup" vocabulary in identifier +
1878 /// diagnostic form.
1879 #[must_use]
1880 pub const fn is_cleanup(&self) -> bool {
1881 self.is_soft_purge() || self.is_purge()
1882 }
1883}
1884
1885/// Reject upgrade instruction `:module` values that aren't K8s
1886/// DNS-1123 labels. Thin wrapper around
1887/// [`crate::render::is_dns_1123_label`] that maps the shared
1888/// parser-shaped reason into the kind-tagged
1889/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
1890/// diagnostics, so the author can grep their caixa.lisp for the
1891/// offending `(:<kind> <module>)` form and fix it in one edit.
1892///
1893/// The contract — the same DNS-1123 label rule the K8s apiserver
1894/// enforces on every `metadata.name` / Service name / label value the
1895/// module name lands in. Each upgrade instruction's `:module` is a
1896/// reference to a caixa name (the wasm-engine resolves it through the
1897/// same `ComputeUnit` registry the operator manages), so the value must
1898/// match every downstream apiserver-side schema: the per-Servico
1899/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
1900/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
1901/// against the loaded-module table at hot-upgrade dispatch, and the
1902/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
1903/// per-module reference axis. Same trajectory as `:children :caixa`
1904/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
1905/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
1906/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
1907///
1908/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
1909/// variant before this predicate is consulted, mirroring
1910/// `validate_membro_caixa`'s empty-first cascade.
1911fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
1912 // Routes through the shared
1913 // [`crate::render::require_valid_dns_1123_label`] gate the peer
1914 // name axes each land on. The `kind: &'static str` field flows
1915 // through both error variants so the diagnostic names which
1916 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
1917 // offending value came from.
1918 crate::render::require_valid_dns_1123_label(
1919 module,
1920 || UpgradeError::module_empty(kind),
1921 |reason| UpgradeError::module_invalid(kind, module, reason),
1922 )
1923}
1924
1925#[derive(Debug, Error, PartialEq, Eq)]
1926pub enum UpgradeError {
1927 #[error(
1928 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
1929 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
1930 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
1931 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
1932 the running version through `semver::Version::parse` and matches it against each entry's \
1933 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
1934 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
1935 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
1936 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
1937 )]
1938 FromInvalid { from: String, reason: String },
1939 #[error(
1940 "upgrade instruction `{kind}` :module is empty (every appup module reference \
1941 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
1942 the instruction entirely)"
1943 )]
1944 ModuleEmpty { kind: &'static str },
1945 #[error(
1946 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
1947 {reason} (every appup module reference resolves to a caixa name, which lands \
1948 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
1949 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
1950 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
1951 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
1952 `\"cache-v2\"`)"
1953 )]
1954 ModuleInvalid {
1955 kind: &'static str,
1956 module: String,
1957 reason: String,
1958 },
1959 #[error("instruction's :script is empty")]
1960 EmptyScript,
1961 #[error(
1962 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
1963 root (Path::join would otherwise escape the project sandbox)",
1964 script.display()
1965 )]
1966 AbsoluteScript { script: PathBuf },
1967 #[error(
1968 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
1969 above the caixa root",
1970 script.display()
1971 )]
1972 ParentEscapeScript { script: PathBuf },
1973 #[error(
1974 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
1975 wasm-engine instantiator reads every migration script as tatara-lisp source through \
1976 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
1977 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
1978 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
1979 parser error far from the source caixa.lisp, with no field naming the offending \
1980 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
1981 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
1982 `\"lib/migrations/v01-to-v02.lisp\"`).",
1983 script.display()
1984 )]
1985 NonLispExtensionScript { script: PathBuf },
1986 #[error(
1987 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
1988 one matching block per running version (`release_handler:install_release/1` dispatches \
1989 on the loaded `:from` against the currently-running release), so two entries with the \
1990 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
1991 pick either set non-deterministically). Author one path per prior version; if two \
1992 distinct instruction sequences are needed, fold them into one ordered list under the \
1993 single matching `(:from {from:?} :instructions (…))` block."
1994 )]
1995 DuplicateFrom { from: String },
1996 #[error(
1997 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
1998 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
1999 greater than or equal to the caixa's own version is structurally unreachable \
2000 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2001 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2002 is never reached because the operator never runs a version greater than or equal to \
2003 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2004 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2005 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2006 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2007 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2008 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2009 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2010 here as a self-upgrade no-op."
2011 )]
2012 FromNotBeforeVersao { from: String, versao: String },
2013 #[error(
2014 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2015 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2016 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2017 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2018 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2019 component-model world incompatibility, irreversible state shape change), and the \
2020 fallback is terminal by construction (the operator restarts the pod and the new \
2021 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2022 in both directions: if the typed instructions would succeed, `(:restart)` is \
2023 unreached; if they wouldn't, the typed instructions are dead because the operator \
2024 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2025 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2026 never repeated. If two distinct upgrade strategies are needed for the same prior \
2027 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2028 dispatch picks exactly one block per running version) — keep the typed sequence; \
2029 the fallback restart is what the operator does on any typed-sequence failure \
2030 already."
2031 )]
2032 RestartNotExclusive {
2033 from: String,
2034 restart_count: usize,
2035 other_kinds: Vec<&'static str>,
2036 },
2037 #[error(
2038 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2039 `(:load-module …)` in its :instructions list — a state migration is the \
2040 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2041 code, but the operator executes instructions in declared order, so this migration \
2042 runs while the only resident version is still the prior one (which expects the \
2043 pre-migration state shape). Load the new module first: author the canonical \
2044 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2045 resident before its state migration runs.",
2046 script.display(),
2047 script.display()
2048 )]
2049 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2050 #[error(
2051 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2052 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2053 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2054 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2055 then `code:soft_purge/1`), but the operator executes instructions in declared \
2056 order, so this cleanup runs while the only resident version is still the same \
2057 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2058 mid-request), leaving no replacement to route in-flight or future requests \
2059 to. Load the new module first: author the canonical `(:load-module …) \
2060 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2061 before the old code is drained or discarded."
2062 )]
2063 PurgeWithoutPriorLoad {
2064 from: String,
2065 kind: &'static str,
2066 module: String,
2067 },
2068 #[error(
2069 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2070 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2071 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2072 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2073 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2074 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2075 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2076 module for this reason. A second cleanup on the same module is at best redundant (the \
2077 module is already gone after the first cleanup, so the second is a no-op or undefined \
2078 depending on the operator's handling of a non-resident-module purge request) and at \
2079 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2080 wanted a fallback, but the operator runs declared instructions unconditionally — \
2081 fallback on cleanup failure is the operator's job, not authored into the entry). \
2082 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2083 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2084 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2085 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2086 )]
2087 DuplicateCleanup {
2088 from: String,
2089 module: String,
2090 kinds: Vec<&'static str>,
2091 },
2092 #[error(
2093 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2094 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2095 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2096 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2097 dispatch table reads the module name and brings up the corresponding component \
2098 alongside the running version. systools-generated `.relup` files emit at most one \
2099 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2100 {module:?})` instruction has no observable semantic relative to the first (the \
2101 component is already resident) — either dead code (copy-pasted load line) or a typo \
2102 masking a distinct module the author intended to load alongside (renamed both to \
2103 {module:?} by mistake), leaving the second module silently absent from the entry. \
2104 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2105 versions need loading alongside the running one, name them distinctly (e.g. \
2106 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2107 )]
2108 DuplicateLoadModule { from: String, module: String },
2109 #[error(
2110 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2111 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2112 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2113 state shape into the current-version shape: a one-shot transition, not a step that \
2114 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2115 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2116 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2117 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2118 author intended two distinct migration scripts) and at worst silent state corruption \
2119 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2120 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2121 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2122 transitions are needed (e.g. one module's schema *and* another module's projection), \
2123 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2124 script.display(),
2125 script.display(),
2126 script.display(),
2127 script.display()
2128 )]
2129 DuplicateStateChange { from: String, script: PathBuf },
2130 #[error(
2131 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2132 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2133 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2134 current shape, but the prior version's state only exists while the prior code is \
2135 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2136 analogs and drain or discard that prior code. The operator executes instructions in \
2137 declared order, so a cleanup ahead of a state-change has already drained the prior \
2138 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2139 the migration script runs, leaving the script either no-op (no prior-version state \
2140 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2141 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2142 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2143 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2144 strictly between load and cleanup. Author the canonical `(:load-module …) \
2145 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2146 migration runs against the prior-version state before the cleanup drains it.",
2147 script.display(),
2148 script.display()
2149 )]
2150 StateChangeAfterCleanup {
2151 from: String,
2152 script: PathBuf,
2153 prior_cleanup_kind: &'static str,
2154 prior_cleanup_module: String,
2155 },
2156 #[error(
2157 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2158 declare `:behavior :on-state-change` — the per-version migration script is the \
2159 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2160 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2161 realizes the composition by invoking the running gen_server's code_change/3 callback \
2162 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2163 composition into two typed slots, the per-version migration logic in this \
2164 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2165 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2166 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2167 migration during hot upgrades\"). The missing callback leaves the per-version script \
2168 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2169 callback at the migration step, finds it absent, and either fails the upgrade \
2170 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2171 current version stays load-bearing\") or silently skips the migration leaving the \
2172 new code running against unmigrated prior-version state. Add the callback: \
2173 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2174 path) alongside the existing `(:state-change {})` instruction (the per-version \
2175 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2176 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2177 migration — is the canonical shape).",
2178 script.display(),
2179 script.display()
2180 )]
2181 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2182}
2183
2184// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2185// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2186// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2187// two-slot struct-variant wire-up sites at
2188// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2189// / `script` from `instr.declared_path()`),
2190// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2191// (`self.prior_versao()` / `script.as_path()` from
2192// `instr.declared_path()`), and
2193// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2194// / `script` from `instr.declared_path()`) onto one substrate primitive
2195// per typed variant — the paired `{ from: String, script: PathBuf }`
2196// two-slot sibling on [`UpgradeError`] of the peer
2197// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2198// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2199// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2200// (8580068, 4 variants on `{ de, para }`),
2201// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2202// `{ de, para, wit, expected }`),
2203// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2204// variants on `{ <field>: String, reason: String }`), and
2205// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2206// variants on `{ de, para, <field>: String, reason: String }`) on the
2207// sibling `AplicacaoError` envelopes, and the peer
2208// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2209// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2210// (0419438, 4 variants on `{ caixa, kind, slots }`),
2211// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2212// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2213// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2214// `LayoutError` envelopes, plus the peer
2215// [`crate::limits::limits_codec_value_only_ctors!`] /
2216// [`crate::limits::limits_codec_value_byte_ctors!`] /
2217// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2218// wire-ups) on the sibling `LimitsError` envelopes.
2219//
2220// Each of the three wire-up sites on this shape opens the identical
2221// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2222// script: <script>.to_path_buf() }` struct-literal against a local
2223// `prior_versao()` and `declared_path()` accessor pair — the exact
2224// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2225// names as a bug, on the same altitude the peer `SupervisorError` /
2226// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2227// closed on their sibling envelopes. The three variants share one
2228// `{ from: String, script: PathBuf }` shape, so the fold routes each
2229// wire-up site through one dispatch per typed variant.
2230//
2231// The macro below generates one `#[must_use]` inherent constructor per
2232// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2233// Self`, so every wire-up site collapses onto one dispatch:
2234// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2235// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2236// uniform two-field construction (`from.to_string()` /
2237// `script.to_path_buf()`) is spelled once — inside the macro — rather
2238// than at every wire-up site. The `&Path` parameter accepts both
2239// `&Path` (from `script.as_path()` at the uniqueness gate) and
2240// `&PathBuf` (from `instr.declared_path()` at the ordering /
2241// callback-declaration gates, via Deref coercion), so every existing
2242// wire-up threads through the ctor without a pre-conversion.
2243//
2244// Every future consumer that wants to construct one of these three
2245// variants outside the three in-crate `UpgradeFromEntry` /
2246// `validate_state_change_on_state_change_callback` gates (a deferred
2247// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2248// re-checker at hot-upgrade dispatch time, a future
2249// `feira validate --upgrade-from` per-caixa admission verb re-checking
2250// the three axes, a per-`Caixa` overlay resolver rejecting an
2251// ordering / uniqueness / callback-declaration invariant against a
2252// cluster-local snapshot) now reaches each variant through one call
2253// rather than re-inlining the three-line struct-literal in lockstep
2254// with the three in-crate wire-up sites.
2255macro_rules! upgrade_from_script_ctors {
2256 ($($ctor:ident => $variant:ident),* $(,)?) => {
2257 impl UpgradeError {
2258 $(
2259 #[doc = concat!(
2260 "Construct an [`UpgradeError::",
2261 stringify!($variant),
2262 "`] naming the offending `(:from <prior-versao>)` and ",
2263 "`(:state-change <script>)` pair. Folds the uniform ",
2264 "`Self::",
2265 stringify!($variant),
2266 " { from: from.to_string(), script: script.to_path_buf() }` ",
2267 "two-field struct-literal onto one substrate primitive so ",
2268 "every wire-up on this variant reads through one dispatch ",
2269 "rather than the pre-lift three-line open-coded block. The ",
2270 "`from` string threads verbatim from ",
2271 "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2272 "from [`UpgradeInstruction::declared_path`] at the call site."
2273 )]
2274 #[must_use]
2275 pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2276 Self::$variant {
2277 from: from.to_string(),
2278 script: script.to_path_buf(),
2279 }
2280 }
2281 )*
2282 }
2283 };
2284}
2285
2286upgrade_from_script_ctors! {
2287 state_change_without_prior_load => StateChangeWithoutPriorLoad,
2288 duplicate_state_change => DuplicateStateChange,
2289 state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2290}
2291
2292// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2293// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2294// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2295// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2296// onto one substrate primitive per typed variant — the paired
2297// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2298// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2299// `{ from: String, script: PathBuf }`) two-slot family on the same
2300// envelope, and of the peer
2301// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2302// variants on `{ caixa: String }`) and
2303// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2304// `{ nome: String }`) single-slot families on the sibling
2305// `SupervisorError` / `DepError` envelopes, and of the peer
2306// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2307// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2308// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2309// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2310// variants on `{ <field>: String, reason: String }`), and
2311// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2312// variants on `{ de, para, <field>: String, reason: String }`) on the
2313// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2314// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2315// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2316// [`crate::LayoutError::missing_entry`] 1b09f9d;
2317// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2318// `LimitsError` codec families (81c856c), and the sibling
2319// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2320// `{ nome, caminho }`) two-slot family.
2321//
2322// The three wire-up sites this fold closes are the three closures
2323// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2324// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2325// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2326// passed to [`crate::render::require_sandboxed_lisp_path`] at
2327// [`UpgradeInstruction::validate`] — each opens the identical
2328// `UpgradeError::<Variant> { script: script.clone() }` three-line
2329// struct-literal against the same `script: &PathBuf` local threaded
2330// from [`UpgradeInstruction::declared_path`], the exact "same block
2331// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2332// bug. The three variants share one `{ script: PathBuf }` shape, so
2333// the fold routes each closure through one dispatch per typed variant.
2334// The sibling `EmptyScript` unit-variant on the same envelope stays on
2335// its pre-lift open-coded shape — it carries no `script` field (the
2336// offending `:script` value *is* the empty path this variant catches),
2337// so the uniform `fn(script: &Path) -> Self` signature this macro
2338// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2339// closure is already a one-liner. This is the second fold family on
2340// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2341// two-slot family established in 8e67041, which explicitly named this
2342// `{ script: PathBuf }` single-slot family as the next fold to land
2343// on the envelope; per that commit's coverage roster, both of the two
2344// most-populated shapes on `UpgradeError` — the two-slot
2345// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2346//
2347// The macro below generates one `#[must_use]` inherent constructor per
2348// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2349// every closure collapses onto one dispatch:
2350// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2351// struct-literal on the same `&Path` fixture. The uniform one-field
2352// construction (`script.to_path_buf()`) is spelled once — inside the
2353// macro — rather than at every wire-up site. The `&Path` parameter
2354// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2355// `instr.declared_path()` at the three closures, via Deref coercion),
2356// so every existing closure threads through the ctor without a
2357// pre-conversion.
2358//
2359// Every future consumer that wants to construct one of these three
2360// variants outside the three in-crate closures (a deferred
2361// wasm-operator's `install_release/1` per-instruction script-shape
2362// re-checker at hot-upgrade dispatch time, a future
2363// `feira validate --upgrade-from` per-caixa admission verb re-checking
2364// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2365// an author-supplied `:state-change :script` against a cluster-local
2366// snapshot) now reaches each variant through one call rather than
2367// re-inlining the three-line struct-literal in lockstep with the three
2368// in-crate closure sites.
2369macro_rules! upgrade_script_only_ctors {
2370 ($($ctor:ident => $variant:ident),* $(,)?) => {
2371 impl UpgradeError {
2372 $(
2373 #[doc = concat!(
2374 "Construct an [`UpgradeError::",
2375 stringify!($variant),
2376 "`] naming the offending `(:state-change <script>)`. ",
2377 "Folds the uniform `Self::",
2378 stringify!($variant),
2379 " { script: script.to_path_buf() }` one-field ",
2380 "struct-literal onto one substrate primitive so every ",
2381 "closure passed to ",
2382 "[`crate::render::require_sandboxed_lisp_path`] at ",
2383 "[`UpgradeInstruction::validate`] on this variant reads ",
2384 "through one dispatch rather than the pre-lift three-line ",
2385 "open-coded block. The `script` path threads verbatim ",
2386 "from [`UpgradeInstruction::declared_path`] at the call ",
2387 "site."
2388 )]
2389 #[must_use]
2390 pub fn $ctor(script: &std::path::Path) -> Self {
2391 Self::$variant {
2392 script: script.to_path_buf(),
2393 }
2394 }
2395 )*
2396 }
2397 };
2398}
2399
2400upgrade_script_only_ctors! {
2401 absolute_script => AbsoluteScript,
2402 parent_escape_script => ParentEscapeScript,
2403 non_lisp_extension_script => NonLispExtensionScript,
2404}
2405
2406// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2407// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2408// <value>.to_string() }` two-slot struct-variant wire-up sites at
2409// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2410// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2411// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2412// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2413// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2414// self.prior_versao().to_string(), module: module.to_string() });`),
2415// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2416// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2417// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2418// });`) onto one substrate-primitive family per typed variant — the
2419// missing paired two-slot rung on the `UpgradeError`-side four-family
2420// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2421// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2422// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2423// script: PathBuf }`), and mirror-symmetric sibling of the peer
2424// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2425// String, <axis>: String }` fold on the `DepError` envelope — same
2426// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2427// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2428// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2429// carries the offending prior-version `:from` verbatim so the author
2430// can grep their caixa.lisp for the offending `(:from "<value>")` /
2431// `(:load-module …)` / `:versao` block in one edit). The three
2432// variants share the same `{ from: String, <axis>: String }` two-slot
2433// shape: the `from` field names the offending per-`:upgrade-from` block's
2434// prior-version tag the diagnostic points the author back at, and the
2435// middle `<axis>: String` field carries the offending per-envelope axis
2436// value verbatim (`reason` on `FromInvalid` carries the wrapped
2437// `semver::Version::parse` error message that pinpoints why the tag
2438// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2439// own current-`:versao` the entry's `:from` failed to precede; `module`
2440// on `DuplicateLoadModule` carries the caixa name the second
2441// `(:load-module …)` instruction re-loaded within the same entry).
2442// The middle axis-field name differs across variants (`reason` /
2443// `versao` / `module`) so the ctor family below takes the axis field
2444// name as a macro parameter (`$axis:ident`) alongside the ctor +
2445// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2446// -> Self` inherent constructor per typed variant that spells the
2447// uniform two-field construction (`from.to_string()` /
2448// `<axis>.to_string()`) exactly once.
2449//
2450// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2451// variants on `{ from: String, script: PathBuf }`) two-slot family on
2452// the same envelope — both key off the same `from: String` axis at the
2453// same per-`:upgrade-from :from`-owned altitude; this family carries the
2454// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2455// carrier) where the script-slot family carries the owned-`PathBuf`
2456// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2457// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2458// same envelope, of the sibling
2459// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2460// variants on `{ caixa: String }`) and
2461// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2462// `{ nome: String }`) single-slot families on the sibling
2463// `SupervisorError` / `DepError` envelopes, and of the peer
2464// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2465// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2466// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2467// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2468// variants on `{ <field>: String, reason: String }`),
2469// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2470// variants on `{ caixa: String }`),
2471// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2472// on `{ path: String }`), and
2473// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2474// variants on `{ de, para, <field>: String, reason: String }`) on the
2475// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2476// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2477// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2478// [`crate::LayoutError::missing_entry`] 1b09f9d;
2479// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2480// `LimitsError` codec families (81c856c), the sibling
2481// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2482// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2483// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2484// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2485// `{ nome, list: &'static str }`), and
2486// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2487// `{ nome, <axis>: String, reason: String }`) families.
2488//
2489// Each of the three wire-up sites on this shape opens the identical
2490// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2491// <value>.to_string() }` four-line struct-literal against a local
2492// `(prior_versao(), <axis-value>)` pair threaded from
2493// [`UpgradeFromEntry::prior_versao`] (or, at the
2494// [`validate_upgrade_from_against_versao`] site, directly from the
2495// caller-supplied `versao: &str` argument) — the exact "same block
2496// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2497// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2498// / `upgrade_script_only_ctors!` families closed on the sibling
2499// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2500// axis-field discriminators are the only things that vary between them;
2501// the rest of the struct-literal is a byte-for-byte re-inline.
2502//
2503// The macro below generates one `#[must_use]` inherent constructor per
2504// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2505// every wire-up site collapses onto one dispatch:
2506// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2507// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2508// parameters accept `&str` literals and `&String` (via Deref coercion)
2509// so every existing wire-up threads through the ctor without a
2510// pre-conversion.
2511//
2512// Every future consumer that wants to construct one of these three
2513// variants outside the three in-crate `UpgradeFromEntry::validate` /
2514// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2515// gates (a deferred wasm-operator's `install_release/1` per-entry
2516// `:from`-parse / per-`:load-module` singularity / per-entry
2517// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2518// `feira validate --upgrade-from` per-caixa admission verb re-checking
2519// the three axes, a per-`Caixa` overlay resolver rejecting a
2520// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2521// invariant against a cluster-local snapshot) now reaches each variant
2522// through one call rather than re-inlining the four-line struct-literal
2523// in lockstep with the three in-crate wire-up sites.
2524macro_rules! upgrade_from_axis_ctors {
2525 ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2526 impl UpgradeError {
2527 $(
2528 #[doc = concat!(
2529 "Construct an [`UpgradeError::",
2530 stringify!($variant),
2531 "`] naming the offending `(:from <prior-versao>)` and ",
2532 "the offending `:", stringify!($axis), "` axis value. ",
2533 "Folds the uniform `Self::",
2534 stringify!($variant),
2535 " { from: from.to_string(), ",
2536 stringify!($axis),
2537 ": ",
2538 stringify!($axis),
2539 ".to_string() }` two-field struct-literal onto one ",
2540 "substrate primitive so every in-crate wire-up on ",
2541 "this variant reads through one dispatch rather than ",
2542 "the pre-lift four-line open-coded block. Both `from: ",
2543 "&str` and `",
2544 stringify!($axis),
2545 ": &str` parameters accept `&str` literals and ",
2546 "`&String` (via Deref coercion) so every existing ",
2547 "wire-up threads through the ctor without a pre-",
2548 "conversion."
2549 )]
2550 #[must_use]
2551 pub fn $ctor(from: &str, $axis: &str) -> Self {
2552 Self::$variant {
2553 from: from.to_string(),
2554 $axis: $axis.to_string(),
2555 }
2556 }
2557 )*
2558 }
2559 };
2560}
2561
2562upgrade_from_axis_ctors! {
2563 from_invalid => FromInvalid { reason },
2564 from_not_before_versao => FromNotBeforeVersao { versao },
2565 duplicate_load_module => DuplicateLoadModule { module },
2566}
2567
2568// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2569// entry.prior_versao().to_string() }` one-slot struct-literal inside
2570// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2571// one substrate primitive on the [`UpgradeError`] envelope, projecting
2572// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2573// on the substrate primitive. The `DuplicateFrom` variant is the last
2574// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2575// every peer envelope shape (`{ script: PathBuf }` one-slot via
2576// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2577// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2578// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2579// 8e67041) already reads through one substrate-primitive dispatch, so
2580// this fold closes the last one-off single-slot on the envelope.
2581//
2582// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2583// (b30edfe) on the paired [`WitContract`] projection — same
2584// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2585// through the substrate primitive's own scalar accessor rather than
2586// re-inlining the `.to_string()` at the call site. Extended here onto
2587// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2588// M2 companion of the M3 mesh-slot accessors (see
2589// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2590// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2591// 4a32abf, and the [`crate::WitContract::{source, destination,
2592// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2593// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2594//
2595// The one wire-up site this fold closes opens the identical
2596// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2597// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2598// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2599// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2600// names as a bug, on the same altitude the peer `contrato_self_loop`
2601// closed on the sibling `{ caixa: String, wit: String }` two-slot
2602// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2603// parameter accepts the borrowed entry verbatim so the wire-up site
2604// threads through the ctor without a pre-projection — the ctor body
2605// spells the paired `prior_versao().to_string()` projection once.
2606//
2607// Every future consumer that wants to construct this variant outside
2608// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2609// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2610// re-checker at hot-upgrade dispatch time rejecting a second entry
2611// with the same prior-versao tag, a future `feira validate --upgrade-
2612// from` per-caixa admission verb re-running the cross-entry duplicate
2613// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2614// supplied duplicate `(:from "<value>")` against a cluster-local
2615// snapshot — now reaches the variant through one call rather than
2616// re-inlining the three-line struct-literal in lockstep with the one
2617// in-crate wire-up site.
2618impl UpgradeError {
2619 /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2620 /// duplicate `(:from <prior-versao>)` entry, projecting through the
2621 /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2622 /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2623 /// from: entry.prior_versao().to_string() }` one-field struct-literal
2624 /// onto one substrate primitive so every wire-up on this variant
2625 /// reads through one dispatch, matching the sibling
2626 /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2627 /// substrate-primitive-projection ctor's shape on the peer
2628 /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2629 /// parameter accepts the borrowed entry verbatim so the paired
2630 /// `prior_versao().to_string()` projection is spelled once — inside
2631 /// the ctor body — rather than at every wire-up site.
2632 #[must_use]
2633 pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2634 Self::DuplicateFrom {
2635 from: entry.prior_versao().to_string(),
2636 }
2637 }
2638
2639 /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2640 /// offending `(:from <prior-versao>)` entry, the offending cleanup
2641 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2642 /// its `:module` target. Folds the uniform
2643 /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2644 /// module: module.to_string() }` three-field struct-literal onto one
2645 /// substrate primitive so every wire-up on this sole-variant
2646 /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2647 /// through one dispatch rather than the pre-lift seven-line
2648 /// open-coded block.
2649 ///
2650 /// The `from: &str` parameter accepts `&str` literals and `&String`
2651 /// via Deref coercion so the sole in-crate wire-up site threads
2652 /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2653 /// pre-conversion. The `kind: &'static str` parameter accepts the
2654 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2655 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2656 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2657 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2658 /// re-projection at the ctor path. The `module: &str` parameter
2659 /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
2660 /// via `.expect("is_cleanup() implies declared_module() is Some")`
2661 /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
2662 /// `Some` composition pin at
2663 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2664 /// makes the `.expect(…)` structurally infallible at build time.
2665 ///
2666 /// Peer of the sibling one-off standalone-ctor
2667 /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
2668 /// String }` envelope on the same `UpgradeError` envelope, and of
2669 /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
2670 /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
2671 /// variant standalone ctor on the peer `AplicacaoError` envelope.
2672 /// Closes the last unlifted `{ from: String, kind: &'static str,
2673 /// module: String }` three-slot open-coded struct-literal wire-up
2674 /// on the OTP-appup load-before-cleanup ordering axis, sibling of
2675 /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
2676 /// (41d08db, three variants on `{ from: String, <axis>: String }`)
2677 /// on the paired ordering / uniqueness / callback-declaration axes,
2678 /// and of the peer standalone [`UpgradeError::duplicate_from`]
2679 /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
2680 /// `:from` gate. Every future consumer that raises this refusal
2681 /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
2682 /// wasm-operator's `install_release/1` per-entry load-before-cleanup
2683 /// re-checker at hot-upgrade dispatch time, a future
2684 /// `feira validate --upgrade-from` per-caixa admission verb
2685 /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
2686 /// overlay resolver rejecting a cluster-local `:soft-purge` /
2687 /// `:purge` overlay lacking a preceding `:load-module` — reaches
2688 /// the variant through one call rather than re-inlining the
2689 /// seven-line struct-literal in lockstep with the sole in-crate
2690 /// wire-up site.
2691 #[must_use]
2692 pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
2693 Self::PurgeWithoutPriorLoad {
2694 from: from.to_string(),
2695 kind,
2696 module: module.to_string(),
2697 }
2698 }
2699
2700 /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
2701 /// offending `(:from <prior-versao>)` entry, the offending
2702 /// `(:state-change …)` `:script` path, and the prior cleanup
2703 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
2704 /// `:module` target. Folds the uniform
2705 /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
2706 /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
2707 /// prior_cleanup_module.to_string() }` four-field struct-literal
2708 /// onto one substrate primitive so every wire-up on this sole-
2709 /// variant migrate-after-cleanup ordering-refusal envelope reads
2710 /// through one dispatch rather than the pre-lift seven-line open-
2711 /// coded block. Closes the last unlifted `{ from: String, script:
2712 /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
2713 /// String }` four-slot open-coded struct-literal wire-up on the
2714 /// OTP-appup migrate-before-cleanup ordering axis, filling the
2715 /// missing four-slot rung on the `UpgradeError`-side ctor-family
2716 /// ladder alongside the sibling one-slot
2717 /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
2718 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2719 /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
2720 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
2721 /// families, and the one-slot [`upgrade_script_only_ctors!`]
2722 /// (7468ca9) family. Sole in-crate wire-up site is inside
2723 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
2724 /// migrate-family sticky-latch dispatch — the third of three
2725 /// within-entry cross-instruction OTP-appup ordering gates the
2726 /// module doc pins (`validate_state_change_ordering` on the load →
2727 /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
2728 /// `state_change_without_prior_load`; `validate_purge_ordering` on
2729 /// the load → cleanup boundary via `purge_without_prior_load`;
2730 /// `validate_state_change_before_cleanup` on the migrate → cleanup
2731 /// boundary via this ctor — now).
2732 ///
2733 /// The `from: &str` parameter accepts `&str` literals and `&String`
2734 /// via Deref coercion so the sole in-crate wire-up site threads
2735 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2736 /// without a pre-conversion. The `script: &std::path::Path`
2737 /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
2738 /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
2739 /// via Deref coercion) so the wire-up threads the sticky-latch
2740 /// script projection through the ctor without a pre-conversion; the
2741 /// uniform `script.to_path_buf()` one-field construction is spelled
2742 /// once — inside the ctor body — rather than at every wire-up site.
2743 /// The `prior_cleanup_kind: &'static str` parameter accepts the
2744 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2745 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2746 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2747 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2748 /// re-projection at the ctor path. The `prior_cleanup_module: &str`
2749 /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
2750 /// returns via `.expect("is_cleanup() implies declared_module() is
2751 /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
2752 /// is-`Some` composition pin at
2753 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2754 /// makes the `.expect(…)` structurally infallible at build time.
2755 ///
2756 /// Every future consumer that raises this refusal outside
2757 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
2758 /// deferred wasm-operator's `install_release/1` per-entry
2759 /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
2760 /// a future `feira validate --upgrade-from` per-caixa admission verb
2761 /// re-running the migrate-before-cleanup gate on demand, a
2762 /// per-`Caixa` overlay resolver rejecting a cluster-local
2763 /// `:state-change` overlay authored after a `:soft-purge` /
2764 /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
2765 /// webhook re-checking a per-`:upgrade-from`-patched candidate
2766 /// before the migrate-before-cleanup gate re-fires — reaches the
2767 /// variant through one call rather than re-inlining the seven-line
2768 /// struct-literal in lockstep with the sole in-crate wire-up site.
2769 #[must_use]
2770 pub fn state_change_after_cleanup(
2771 from: &str,
2772 script: &std::path::Path,
2773 prior_cleanup_kind: &'static str,
2774 prior_cleanup_module: &str,
2775 ) -> Self {
2776 Self::StateChangeAfterCleanup {
2777 from: from.to_string(),
2778 script: script.to_path_buf(),
2779 prior_cleanup_kind,
2780 prior_cleanup_module: prior_cleanup_module.to_string(),
2781 }
2782 }
2783
2784 /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
2785 /// offending `(:from <prior-versao>)` entry, the colliding `:module`
2786 /// target, and the ordered pair of colliding cleanup `:kind` lisp-
2787 /// forms (`:soft-purge` / `:purge`). Folds the uniform
2788 /// `Self::DuplicateCleanup { from: from.to_string(), module:
2789 /// module.to_string(), kinds }` three-field struct-literal onto one
2790 /// substrate primitive so every wire-up on this sole-variant within-
2791 /// entry per-module cleanup-singularity refusal envelope reads
2792 /// through one dispatch rather than the pre-lift five-line open-coded
2793 /// block. Closes the last unlifted `{ from: String, module: String,
2794 /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
2795 /// wire-up on the OTP-appup per-module cleanup-singularity axis,
2796 /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
2797 /// family ladder alongside the sibling three-slot
2798 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2799 /// ctor on the paired within-entry load → cleanup ordering axis, the
2800 /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
2801 /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
2802 /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
2803 /// standalone ctor on the migrate → cleanup boundary, the two-slot
2804 /// [`upgrade_from_axis_ctors!`] (41d08db) /
2805 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
2806 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
2807 /// Sole in-crate wire-up site is inside
2808 /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
2809 /// cleanup-family dedup arm.
2810 ///
2811 /// The `from: &str` parameter accepts `&str` literals and `&String`
2812 /// via Deref coercion so the sole in-crate wire-up threads
2813 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2814 /// without a pre-conversion. The `module: &str` parameter takes the
2815 /// `&str` [`UpgradeInstruction::declared_module`] returns via
2816 /// `.expect("is_cleanup() implies declared_module() is Some")` at the
2817 /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
2818 /// composition pin at
2819 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2820 /// makes the `.expect(…)` structurally infallible at build time. The
2821 /// `kinds: Vec<&'static str>` parameter takes the ordered pair
2822 /// `vec![prior_kind, kind]` built at the caller from the two
2823 /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
2824 /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2825 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
2826 /// primitive `&'static str` projection the paired three-slot
2827 /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
2828 /// sibling load → cleanup ordering axis.
2829 ///
2830 /// Every future consumer that raises this refusal outside
2831 /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
2832 /// wasm-operator's `install_release/1` per-entry per-module
2833 /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
2834 /// future `feira validate --upgrade-from` per-caixa admission verb
2835 /// re-running the singularity pass on demand, a per-`Caixa` overlay
2836 /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
2837 /// overlay that collides with a base-entry cleanup on the same
2838 /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
2839 /// re-checking a per-`:upgrade-from`-patched candidate before the
2840 /// singularity gate re-fires — reaches the variant through one call
2841 /// rather than re-inlining the five-line struct-literal in lockstep
2842 /// with the sole in-crate wire-up site.
2843 #[must_use]
2844 pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
2845 Self::DuplicateCleanup {
2846 from: from.to_string(),
2847 module: module.to_string(),
2848 kinds,
2849 }
2850 }
2851
2852 /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
2853 /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
2854 /// instruction count, and the ordered list of non-`:restart`
2855 /// instruction lisp-forms the entry mixed with the terminal fallback.
2856 /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
2857 /// restart_count, other_kinds }` three-field struct-literal onto one
2858 /// substrate primitive so every wire-up on this sole-variant within-
2859 /// entry `(:restart)`-exclusivity refusal envelope reads through one
2860 /// dispatch rather than the pre-lift five-line open-coded block. Closes
2861 /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
2862 /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
2863 /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
2864 /// the last-remaining open-coded emission site the sibling
2865 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
2866 /// the natural next lift on the `UpgradeError` envelope. Fills a peer
2867 /// three-slot rung on the `UpgradeError`-side ctor-family ladder
2868 /// alongside the sibling three-slot
2869 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2870 /// ctor on the paired within-entry load → cleanup ordering axis and
2871 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
2872 /// the per-module cleanup-singularity axis, the one-slot
2873 /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
2874 /// cross-entry duplicate-`:from` gate, the four-slot
2875 /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
2876 /// ctor on the migrate → cleanup boundary, the two-slot
2877 /// [`upgrade_from_axis_ctors!`] (41d08db) /
2878 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
2879 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
2880 /// Sole in-crate wire-up site is inside
2881 /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
2882 /// arm.
2883 ///
2884 /// The `from: &str` parameter accepts `&str` literals and `&String`
2885 /// via Deref coercion so the sole in-crate wire-up threads
2886 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2887 /// without a pre-conversion. The `restart_count: usize` parameter
2888 /// takes the observed `(:restart)` occurrence count built at the
2889 /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
2890 /// — the same `IsVariant`-derived arm-discriminator dispatch the
2891 /// paired `other_kinds` projection routes through — so the diagnostic
2892 /// surfaces the duplication mode unambiguously even when `other_kinds`
2893 /// is empty (the `((:restart) (:restart))` shape the sibling
2894 /// `validate_rejects_restart_duplicated` test pins with
2895 /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
2896 /// Vec<&'static str>` parameter takes the ordered list of non-
2897 /// `:restart` instruction lisp-forms built at the caller from
2898 /// `instructions.iter().filter(|i| !i.is_restart()).map(
2899 /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
2900 /// primitive `&'static str` projection the peer three-slot
2901 /// [`UpgradeError::purge_without_prior_load`] /
2902 /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
2903 /// within-entry cleanup axes.
2904 ///
2905 /// Every future consumer that raises this refusal outside
2906 /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
2907 /// wasm-operator's `install_release/1` per-entry `(:restart)`-
2908 /// exclusivity re-checker at hot-upgrade dispatch time, a future
2909 /// `feira validate --upgrade-from` per-caixa admission verb re-running
2910 /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
2911 /// rejecting a cluster-local `(:restart)` overlay that mixes with a
2912 /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
2913 /// CR admission webhook re-checking a per-`:upgrade-from`-patched
2914 /// candidate before the exclusivity gate re-fires — reaches the
2915 /// variant through one call rather than re-inlining the five-line
2916 /// struct-literal in lockstep with the sole in-crate wire-up site.
2917 #[must_use]
2918 pub fn restart_not_exclusive(
2919 from: &str,
2920 restart_count: usize,
2921 other_kinds: Vec<&'static str>,
2922 ) -> Self {
2923 Self::RestartNotExclusive {
2924 from: from.to_string(),
2925 restart_count,
2926 other_kinds,
2927 }
2928 }
2929
2930 /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
2931 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
2932 /// `:purge`), the malformed `:module` value, and the parser-shaped
2933 /// `reason` from
2934 /// [`crate::render::is_dns_1123_label`]. Folds the uniform
2935 /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
2936 /// three-field struct-literal onto one substrate primitive so every
2937 /// wire-up on this variant reads through one dispatch rather than the
2938 /// pre-lift open-coded closure block inside [`validate_module`]'s
2939 /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
2940 ///
2941 /// The `kind: &'static str` parameter accepts the lisp-form
2942 /// [`UpgradeInstruction::lisp_form`] returns for the three
2943 /// [`UpgradeInstruction::declared_module`]-bearing arms —
2944 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
2945 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
2946 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
2947 /// without a per-arm re-projection at the ctor path. The `module: &str`
2948 /// parameter threads the offending author-supplied `:module` value
2949 /// verbatim from [`UpgradeInstruction::declared_module`]. The
2950 /// `reason: impl Into<String>` bound accepts both `&str` literals and
2951 /// the `String` [`crate::render::is_dns_1123_label`] returns via
2952 /// `.into()`, matching the peer
2953 /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
2954 /// [`crate::SupervisorError::child_caixa_invalid`] /
2955 /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
2956 /// three-slot invalid-arm ctor discipline on the sibling
2957 /// DNS-1123-label per-envelope shape.
2958 ///
2959 /// Peer of the sibling standalone-ctor
2960 /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
2961 /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
2962 /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
2963 /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
2964 /// side of a `require_valid_dns_1123_label` two-closure cascade, so
2965 /// [`validate_module`]'s cascade now reads through one substrate
2966 /// primitive on the invalid-arm rather than an open-coded four-line
2967 /// struct-literal in lockstep with the sole in-crate wire-up site.
2968 ///
2969 /// Every future consumer that raises this refusal outside
2970 /// [`validate_module`] — a deferred wasm-operator's
2971 /// `install_release/1` per-instruction `:module` re-validator at
2972 /// hot-upgrade dispatch time re-running the same DNS-1123-label
2973 /// floor against a candidate module reference, a future
2974 /// `feira validate --upgrade-from` per-caixa admission verb
2975 /// re-running the module-shape gate on demand, an M4
2976 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
2977 /// per-`:upgrade-from`-patched candidate before the module-shape
2978 /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
2979 /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
2980 /// overlay against a cluster-local snapshot — now reaches this
2981 /// variant through one call rather than re-inlining the four-line
2982 /// struct-literal in lockstep with the [`validate_module`]
2983 /// closure-form wire-up.
2984 #[must_use]
2985 pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
2986 Self::ModuleInvalid {
2987 kind,
2988 module: module.to_string(),
2989 reason: reason.into(),
2990 }
2991 }
2992
2993 /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
2994 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
2995 /// `:purge`) at which the appup module reference is the empty
2996 /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
2997 /// struct-literal onto one substrate primitive so the sole in-crate
2998 /// closure passed to [`crate::render::require_valid_dns_1123_label`]
2999 /// at [`validate_module`] on this variant reads through one dispatch
3000 /// rather than the pre-lift open-coded block. The `kind` label
3001 /// threads verbatim from the caller-side
3002 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3003 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3004 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3005 /// roster the wire-up feeds through [`validate_module`]'s
3006 /// `kind: &'static str` parameter.
3007 ///
3008 /// Sibling of the paired three-slot [`Self::module_invalid`]
3009 /// (3d0d64a) substrate primitive on the same
3010 /// [`crate::render::require_valid_dns_1123_label`] two-closure
3011 /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3012 /// now both reach the `UpgradeError` envelope through one substrate
3013 /// primitive per typed variant, closing the pair on the OTP-appup
3014 /// per-instruction `:module` caixa-reference axis. Same shape
3015 /// discipline as the peer
3016 /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3017 /// one-slot `{ slot: &'static str }` sibling that closed the peer
3018 /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3019 /// cascade at the `:contratos <slot>` per-edge axis
3020 /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3021 /// "one substrate primitive per typed arm on both sides of a
3022 /// `require_valid_dns_1123_label` two-closure cascade, projecting
3023 /// through the caller-supplied axis-tag" discipline now extended
3024 /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3025 /// of the pair the M3 (`:contratos <slot>`) side already carries.
3026 ///
3027 /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3028 /// :instructions <kind>` tag comes from the
3029 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3030 /// carrying program-lifetime storage, matching the enum-field type
3031 /// and the [`validate_module`] wire-up's per-arm dispatch. A
3032 /// runtime-borrowed `&str` would silently downgrade the label
3033 /// lifetime and let a caller stash a non-`'static` borrow into the
3034 /// returned error. `#[must_use]` fires a compile warning at any
3035 /// wire-up that mistakenly discards the constructed error rather
3036 /// than routing it through `return Err(…)` / `.map_err(…)` / a
3037 /// closure return. `pub const fn` matches the peer per-envelope
3038 /// one-slot `Copy`-scalar ctor family discipline
3039 /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3040 /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3041 /// ctor is usable in `const` position at every wire-up site.
3042 ///
3043 /// Every future consumer that constructs `ModuleEmpty` outside
3044 /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3045 /// closure — a deferred wasm-operator's `install_release/1`
3046 /// per-instruction `:module` re-validator at hot-upgrade dispatch
3047 /// time re-running the same empty-arm floor against a candidate
3048 /// module reference, a future `feira validate --upgrade-from`
3049 /// per-caixa admission verb re-running the empty-module gate on
3050 /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3051 /// re-checking a per-`:upgrade-from`-patched candidate before the
3052 /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3053 /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3054 /// overlay against a cluster-local snapshot — now reaches this
3055 /// variant through one call rather than re-inlining the one-line
3056 /// struct-literal in lockstep with the sole in-crate wire-up site.
3057 #[must_use]
3058 pub const fn module_empty(kind: &'static str) -> Self {
3059 Self::ModuleEmpty { kind }
3060 }
3061}
3062
3063#[cfg(test)]
3064mod tests {
3065 use std::path::Path;
3066
3067 use super::*;
3068
3069 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3070 UpgradeFromEntry {
3071 from: from.into(),
3072 instructions: instrs,
3073 }
3074 }
3075
3076 #[test]
3077 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3078 // Fail-before-pass-after pin on
3079 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3080 // posture. The accessor projects the per-`:upgrade-from :from`
3081 // [`String`] storage through the `pub const fn`
3082 // [`String::as_str`] (const-stable since Rust 1.87, well within
3083 // the workspace MSRV) — any future accidental downgrade to
3084 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3085 // build time with E0015 (`cannot call non-const method`),
3086 // strictly stronger than a runtime `assert!`. Sibling of the
3087 // peer M2/M3 slot family pins on the sibling `const`-eval-
3088 // surface passes ([`crate::Caixa::nome`] /
3089 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3090 // [`crate::aplicacao::Membro::nome`] /
3091 // [`crate::aplicacao::Membro::versao_requirement`],
3092 // [`crate::aplicacao::Entrada::hostname`] /
3093 // [`crate::aplicacao::Entrada::destination`],
3094 // [`crate::supervisor::ChildSpec::nome`] /
3095 // [`crate::supervisor::ChildSpec::versao_requirement`],
3096 // [`crate::dep::Dep::nome`] /
3097 // [`crate::dep::Dep::versao_requirement`], and the
3098 // per-`:contratos`
3099 // [`crate::aplicacao::WitContract::source`] /
3100 // [`crate::aplicacao::WitContract::destination`] /
3101 // [`crate::aplicacao::WitContract::world_ref`] trio the
3102 // sibling pin at 279823b already anchors).
3103 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3104 e.prior_versao()
3105 }
3106 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3107 let e = entry(from, vec![]);
3108 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3109 assert_eq!(e.prior_versao(), from);
3110 }
3111 }
3112
3113 #[test]
3114 fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3115 // Fail-before-pass-after pin on
3116 // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3117 // posture. The accessor destructures the per-`:upgrade-from
3118 // :instructions` `Vec<UpgradeInstruction>` storage through the
3119 // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3120 // 1.66, well within the workspace MSRV) — any future
3121 // accidental downgrade to non-`const` fails
3122 // `instructions_via_const_fn` at caixa-core build time with
3123 // E0015 (`cannot call non-const method`), strictly stronger
3124 // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3125 // slot `Vec → &[T]` slice-return accessor family pin
3126 // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3127 // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3128 // per-`:membros` / per-`:contratos` slice-return axes, and of
3129 // the peer M2 supervisor-tree axis pin
3130 // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3131 // on the per-`:children` slice-return axis.
3132 const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3133 e.instructions()
3134 }
3135 // Sweep both the empty-instructions arm (author-declared
3136 // per-`:from` entry with no migration steps — the degenerate
3137 // shape the appup `restart`-only path folds through) and the
3138 // populated-instructions arm (the canonical OTP-appup shape
3139 // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3140 // chain) so the accessor carries a const-dispatch pin on
3141 // both arms.
3142 let e_empty = entry("0.1.0", vec![]);
3143 assert!(instructions_via_const_fn(&e_empty).is_empty());
3144 assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3145 let e_full = entry(
3146 "0.1.0",
3147 vec![
3148 UpgradeInstruction::LoadModule {
3149 module: "hello-rio".into(),
3150 },
3151 UpgradeInstruction::StateChange {
3152 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3153 },
3154 UpgradeInstruction::SoftPurge {
3155 module: "hello-rio-old".into(),
3156 },
3157 ],
3158 );
3159 assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3160 assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3161 }
3162
3163 #[test]
3164 fn round_trip_load_module() {
3165 let i = UpgradeInstruction::LoadModule {
3166 module: "hello-rio".into(),
3167 };
3168 let json = serde_json::to_string(&i).unwrap();
3169 assert!(json.contains("\"kind\":\"load-module\""));
3170 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3171 assert_eq!(i, back);
3172 }
3173
3174 #[test]
3175 fn round_trip_all_variants() {
3176 let cases = vec![
3177 UpgradeInstruction::LoadModule { module: "x".into() },
3178 UpgradeInstruction::StateChange {
3179 script: PathBuf::from("lib/migrations.lisp"),
3180 },
3181 UpgradeInstruction::SoftPurge {
3182 module: "x-old".into(),
3183 },
3184 UpgradeInstruction::Purge {
3185 module: "x-old".into(),
3186 },
3187 UpgradeInstruction::Restart,
3188 ];
3189 for c in cases {
3190 let json = serde_json::to_string(&c).unwrap();
3191 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3192 assert_eq!(c, back);
3193 }
3194 }
3195
3196 #[test]
3197 fn validate_accepts_well_formed() {
3198 let e = entry(
3199 "0.1.0",
3200 vec![
3201 UpgradeInstruction::LoadModule {
3202 module: "hello-rio".into(),
3203 },
3204 UpgradeInstruction::StateChange {
3205 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3206 },
3207 UpgradeInstruction::SoftPurge {
3208 module: "hello-rio-old".into(),
3209 },
3210 ],
3211 );
3212 e.validate().unwrap();
3213 }
3214
3215 #[test]
3216 fn validate_rejects_non_semver_from() {
3217 let e = entry("not-a-semver", vec![]);
3218 let err = e.validate().unwrap_err();
3219 assert!(
3220 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3221 );
3222 }
3223
3224 #[test]
3225 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3226 // Diagnostic-shape pin: the error names the offending
3227 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3228 // reason, so a `feira lint` run can render the diagnostic
3229 // without re-parsing — the author can grep their caixa.lisp for
3230 // `:from "<value>"` and fix it in one edit. Mirrors the peer
3231 // `versao_invalid_diagnostic_carries_offending_versao` pin on
3232 // the sibling SemVer-2 axis (the top-level `:versao`), the
3233 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3234 // pin on `:membros :versao`, and the peer
3235 // `deps_invalid_diagnostic_carries_offending_value` pin on
3236 // `:deps :versao` — every SemVer-2-parsing slot's invalid
3237 // diagnostic is now structurally equivalent.
3238 let e = entry("v0.1.0", vec![]);
3239 let err = e.validate().unwrap_err();
3240 let UpgradeError::FromInvalid { from, reason } = err else {
3241 panic!("expected FromInvalid variant, got {err:?}");
3242 };
3243 assert_eq!(from, "v0.1.0");
3244 assert!(
3245 !reason.is_empty(),
3246 "FromInvalid `reason` must carry the parser's wording verbatim"
3247 );
3248 }
3249
3250 #[test]
3251 fn prior_versao_returns_from_byte_equal_across_permutations() {
3252 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3253 // accessor across the SemVer-2 shape lattice every consumer
3254 // reaches through it — the numeric-triad canonical shape, a
3255 // pre-release build with a dotted identifier chain, a full-
3256 // metadata build, a large-magnitude triad, and the empty
3257 // string (which reaches this accessor unchanged before any
3258 // validate gate rejects it). Sibling to the peer
3259 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3260 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3261 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3262 // family — extended here onto the first M2 slot scalar-value
3263 // axis. Any silent detour on the accessor (a `.to_string()`
3264 // + retained ownership shape, a canonicalization pass, a
3265 // trim-whitespace on the return path) surfaces as a byte-
3266 // inequality failure here rather than as a downstream error-
3267 // diagnostic drift.
3268 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3269 for from in cases {
3270 let e = entry(from, vec![]);
3271 assert_eq!(
3272 e.prior_versao(),
3273 from,
3274 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3275 );
3276 assert_eq!(
3277 e.prior_versao().len(),
3278 from.len(),
3279 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3280 );
3281 }
3282 }
3283
3284 #[test]
3285 fn prior_versao_borrows_from_from_storage() {
3286 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3287 // a borrow into `self.from`'s heap allocation, never a fresh
3288 // owned copy. Guards against a future silent detour where
3289 // the accessor materializes a `Cow<'_, str>` / `String` /
3290 // `Rc<str>` intermediate — the return path stays zero-cost
3291 // even under a refactor that reshapes the storage. Sibling
3292 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3293 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3294 // (4a32abf) pins — extended onto the M2 slot's first
3295 // scalar-value axis.
3296 let e = entry("0.1.0", vec![]);
3297 assert!(
3298 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3299 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3300 );
3301 }
3302
3303 #[test]
3304 fn validate_parses_prior_versao_through_lifted_accessor() {
3305 // Coherence pin between the accessor and the SemVer-2 parse
3306 // gate: every `:upgrade-from :from` value the validator
3307 // accepts (resp. rejects) must be identical to what
3308 // `Version::parse(entry.prior_versao())` accepts (resp.
3309 // rejects) — the two must remain in lockstep across the
3310 // shape lattice so `validate_upgrade_from`'s
3311 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3312 // assertion holds by construction. If a future extension of
3313 // `prior_versao` reshapes the return (a canonicalization
3314 // pass, a leading/trailing whitespace trim, an empty-to-
3315 // "0.0.0" fallback) it would either loosen the validator
3316 // (silently accepting shapes the parser rejects) or
3317 // tighten the parser's re-parse (silently panicking on
3318 // shapes the validator accepts) — this pin catches either
3319 // shift at caixa-core build time.
3320 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3321 for from in accepted {
3322 let e = entry(from, vec![]);
3323 e.validate().unwrap_or_else(|err| {
3324 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3325 });
3326 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3327 panic!(
3328 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3329 got {err:?}",
3330 );
3331 });
3332 }
3333 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3334 for from in rejected {
3335 let e = entry(from, vec![]);
3336 assert!(
3337 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3338 "validate() must reject {from:?} that Version::parse rejects",
3339 );
3340 assert!(
3341 semver::Version::parse(e.prior_versao()).is_err(),
3342 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3343 );
3344 }
3345 }
3346
3347 #[test]
3348 fn validate_rejects_empty_module() {
3349 // Per-arm coverage: every Module-bearing variant surfaces the
3350 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3351 // so the author can grep their caixa.lisp for `(:load-module
3352 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3353 // edit — same self-locating shape `BehaviorError::EmptyPath`
3354 // (b0c8389) carries on the peer M2 typed slot.
3355 let cases: &[(UpgradeInstruction, &'static str)] = &[
3356 (
3357 UpgradeInstruction::LoadModule {
3358 module: String::new(),
3359 },
3360 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3361 ),
3362 (
3363 UpgradeInstruction::SoftPurge {
3364 module: String::new(),
3365 },
3366 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3367 ),
3368 (
3369 UpgradeInstruction::Purge {
3370 module: String::new(),
3371 },
3372 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3373 ),
3374 ];
3375 for (instr, expected_kind) in cases {
3376 assert_eq!(
3377 instr.validate().unwrap_err(),
3378 UpgradeError::ModuleEmpty {
3379 kind: expected_kind
3380 },
3381 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3382 );
3383 }
3384 }
3385
3386 #[test]
3387 fn validate_rejects_non_dns_1123_module() {
3388 // Every appup `:module` reference is a caixa name (the
3389 // wasm-engine resolves it through the same ComputeUnit
3390 // registry the operator manages), so the value-shape gate
3391 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3392 // the canonical authoring footguns — uppercase letters, `_`
3393 // separator, embedded `.`, leading/trailing `-`, an embedded
3394 // whitespace byte, the >63-byte UUID-shaped slug — across
3395 // every Module-bearing variant; each must surface as
3396 // `ModuleInvalid { kind, module, reason }` carrying the
3397 // offending value verbatim and the parser-shaped reason.
3398 type Build = fn(String) -> UpgradeInstruction;
3399 let footguns: &[&str] = &[
3400 "Hello-Rio",
3401 "hello_rio",
3402 "hello.rio",
3403 "-hello",
3404 "hello-",
3405 "hello rio",
3406 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3407 ];
3408 let variants: &[(Build, &'static str)] = &[
3409 (
3410 |m| UpgradeInstruction::LoadModule { module: m },
3411 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3412 ),
3413 (
3414 |m| UpgradeInstruction::SoftPurge { module: m },
3415 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3416 ),
3417 (
3418 |m| UpgradeInstruction::Purge { module: m },
3419 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3420 ),
3421 ];
3422 for (build, expected_kind) in variants {
3423 for module in footguns {
3424 let instr = build((*module).to_string());
3425 let err = instr.validate().unwrap_err();
3426 match err {
3427 UpgradeError::ModuleInvalid {
3428 kind,
3429 module: m,
3430 reason,
3431 } => {
3432 assert_eq!(
3433 kind, *expected_kind,
3434 ":module footgun on {instr:?} must tag the lisp-form"
3435 );
3436 assert_eq!(
3437 m, *module,
3438 "ModuleInvalid must carry the offending value verbatim"
3439 );
3440 assert!(
3441 !reason.is_empty(),
3442 "ModuleInvalid reason must name the specific violation \
3443 (the predicate's parser-shaped wording from \
3444 `is_dns_1123_label`), got empty"
3445 );
3446 }
3447 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3448 }
3449 }
3450 }
3451 }
3452
3453 #[test]
3454 fn validate_accepts_canonical_module_names() {
3455 // Positive control: every documented authoring shape — bare
3456 // identifier, with hyphens, with digits, the
3457 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3458 // references — passes the gate. Drift here = a future
3459 // tighten that rejects any of these surfaces as a
3460 // test-failure at the predicate boundary, not piecemeal
3461 // across per-instruction call sites.
3462 let canonical: &[&str] = &[
3463 "hello-rio",
3464 "hello-rio-old",
3465 "cache",
3466 "cache-v2",
3467 "x",
3468 "a1",
3469 "0a",
3470 "abc-123-def",
3471 ];
3472 for module in canonical {
3473 UpgradeInstruction::LoadModule {
3474 module: (*module).to_string(),
3475 }
3476 .validate()
3477 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3478 UpgradeInstruction::SoftPurge {
3479 module: (*module).to_string(),
3480 }
3481 .validate()
3482 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3483 UpgradeInstruction::Purge {
3484 module: (*module).to_string(),
3485 }
3486 .validate()
3487 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3488 }
3489 }
3490
3491 #[test]
3492 fn validate_empty_takes_precedence_over_invalid() {
3493 // Empty input is rejected via the narrower `ModuleEmpty`
3494 // diagnostic before the DNS-1123 predicate is consulted, so
3495 // a future tighten that adds another stage between the two
3496 // doesn't accidentally reorder the diagnostic precedence.
3497 // Mirrors the empty-first cascade on every peer DNS-1123
3498 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3499 // `SupervisorSpec::validate`'s child-name arm).
3500 let err = UpgradeInstruction::LoadModule {
3501 module: String::new(),
3502 }
3503 .validate()
3504 .unwrap_err();
3505 assert_eq!(
3506 err,
3507 UpgradeError::ModuleEmpty {
3508 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3509 }
3510 );
3511 }
3512
3513 #[test]
3514 fn validate_rejects_empty_script() {
3515 let i = UpgradeInstruction::StateChange {
3516 script: PathBuf::new(),
3517 };
3518 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3519 }
3520
3521 #[test]
3522 fn validate_rejects_absolute_script() {
3523 let i = UpgradeInstruction::StateChange {
3524 script: PathBuf::from("/etc/migrations.lisp"),
3525 };
3526 assert!(matches!(
3527 i.validate().unwrap_err(),
3528 UpgradeError::AbsoluteScript { .. }
3529 ));
3530 }
3531
3532 #[test]
3533 fn validate_rejects_parent_escape_script() {
3534 let i = UpgradeInstruction::StateChange {
3535 script: PathBuf::from("../sibling/migrations.lisp"),
3536 };
3537 assert!(matches!(
3538 i.validate().unwrap_err(),
3539 UpgradeError::ParentEscapeScript { .. }
3540 ));
3541 // mid-path `..` is also caught
3542 let i2 = UpgradeInstruction::StateChange {
3543 script: PathBuf::from("lib/../../escaped.lisp"),
3544 };
3545 assert!(matches!(
3546 i2.validate().unwrap_err(),
3547 UpgradeError::ParentEscapeScript { .. }
3548 ));
3549 }
3550
3551 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3552 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3553 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3554 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3555 // consumer; the file-type contract is identical, so the per-axis
3556 // test grid is mirrored leg-for-leg.
3557
3558 #[test]
3559 fn validate_rejects_no_extension_script() {
3560 // Fail-before-pass-after: the canonical "I declared the
3561 // migration script but forgot the `.lisp` extension"
3562 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3563 // The wasm-engine's `tatara_lisp::read` consumer needs a
3564 // file-type contract beyond the structural-shape gate; a
3565 // no-extension path past `is_sandboxed_relative_path` would
3566 // surface a parser-shaped diagnostic at hot-upgrade migration
3567 // time far from the source caixa.lisp.
3568 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3569 let i = UpgradeInstruction::StateChange {
3570 script: PathBuf::from(relpath),
3571 };
3572 let err = i.validate().unwrap_err();
3573 assert!(
3574 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3575 if s == Path::new(relpath)),
3576 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3577 carrying the offending path verbatim, got {err:?}"
3578 );
3579 }
3580 }
3581
3582 #[test]
3583 fn validate_rejects_non_lisp_extension_script() {
3584 // Wrong-extension sweep across common authoring footguns: the
3585 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3586 // drag in from the workspace tree, the `.rs` shape that an
3587 // IDE auto-complete might propose, the `.lisp.bak` shape an
3588 // editor might leave behind, and the `.lispx` near-miss that
3589 // a typo would produce. Each must surface as
3590 // `NonLispExtensionScript` carrying the offending path
3591 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3592 // rejects all of these at hot-upgrade migration time, and
3593 // the gate lifts that contract to validate time. Mirrors the
3594 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3595 // the `:behavior :on-*` axis leg-for-leg — same downstream
3596 // consumer, same accepted set, same per-axis test grid.
3597 let footguns: &[&str] = &[
3598 "lib/migrations.rs",
3599 "lib/migrations.txt",
3600 "lib/migrations.md",
3601 "lib/migrations.json",
3602 "lib/migrations.yaml",
3603 "lib/migrations.toml",
3604 "lib/migrations.lisp.bak",
3605 "lib/migrations.lispx",
3606 "lib/migrations.lis",
3607 ];
3608 for relpath in footguns {
3609 let i = UpgradeInstruction::StateChange {
3610 script: PathBuf::from(relpath),
3611 };
3612 let err = i.validate().unwrap_err();
3613 assert!(
3614 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3615 if s == Path::new(relpath)),
3616 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3617 carrying the offending path verbatim, got {err:?}"
3618 );
3619 }
3620 }
3621
3622 #[test]
3623 fn validate_rejects_uppercase_lisp_extension_script() {
3624 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3625 // case-folded shapes a case-insensitive volume's existence
3626 // check would match the on-disk file — but the
3627 // canonical-form codec emits lowercase `.lisp` verbatim, so
3628 // a case-folded shape mismatches the round-trip-stable
3629 // canonical form (THEORY.md §V.2.7 render-determinism).
3630 // Same case-sensitive discipline the byte-size / duration
3631 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3632 // and every other shape-gate predicate in `render.rs` (label
3633 // / scheme / unit boundaries). Mirrors the peer
3634 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3635 for relpath in [
3636 "lib/migrations.LISP",
3637 "lib/migrations.Lisp",
3638 "lib/migrations.LiSp",
3639 "lib/migrations.lISP",
3640 ] {
3641 let i = UpgradeInstruction::StateChange {
3642 script: PathBuf::from(relpath),
3643 };
3644 let err = i.validate().unwrap_err();
3645 assert!(
3646 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3647 if s == Path::new(relpath)),
3648 "case-folded `.lisp` extension {relpath:?} must surface as \
3649 NonLispExtensionScript (strict lowercase, canonical-form \
3650 round-trip pin), got {err:?}"
3651 );
3652 }
3653 }
3654
3655 #[test]
3656 fn validate_accepts_canonical_lisp_extension_scripts() {
3657 // Positive-control sweep across every canonical in-tree
3658 // authoring shape: bare filename, standard `lib/`
3659 // subdirectory, deeply-nested migrations subdirectory,
3660 // explicit current-dir-relative prefix, mid-path `./`
3661 // segment, multi-dot stem (the version-suffix shape
3662 // `lib/migrations/v.0.1.lisp` an author might use to encode
3663 // the migration's `:from` version into the filename). Drift
3664 // here = a future tightening that rejects any of these
3665 // surfaces as a test-failure at the per-axis validator
3666 // boundary, not piecemeal across renderer / layout-checker
3667 // call sites. Mirrors the peer `BehaviorSpec` positive-set
3668 // sweep (c97815a).
3669 let canonical: &[&str] = &[
3670 "lib/migrations.lisp",
3671 "lib/migrations/v01-to-v02.lisp",
3672 "migrations.lisp",
3673 "a.lisp",
3674 "./lib/migrations.lisp",
3675 "lib/./migrations.lisp",
3676 "lib/migrations/v.0.1.lisp",
3677 ];
3678 for relpath in canonical {
3679 UpgradeInstruction::StateChange {
3680 script: PathBuf::from(relpath),
3681 }
3682 .validate()
3683 .unwrap_or_else(|e| {
3684 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3685 });
3686 }
3687 }
3688
3689 #[test]
3690 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3691 // Cross-arm precedence pin: a script that is *both*
3692 // sandbox-escaping (Empty / Absolute / ParentEscape) and
3693 // non-`.lisp` must surface the more-fundamental
3694 // sandbox-shape diagnostic first — the canonical fix
3695 // collapses both into "pin a relative `.lisp` path under the
3696 // caixa root", and the `.lisp` remediation would be
3697 // misleading when the offending path can never resolve under
3698 // the caixa root anyway. Mirrors the peer
3699 // `BehaviorError` cross-arm precedence (c97815a) and the
3700 // sibling `LimitsError`
3701 // (`MemoryZero` → `MemoryBelowWasm32Page` →
3702 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3703 // smallest-scope-arm-fires-last posture.
3704 let i_empty = UpgradeInstruction::StateChange {
3705 script: PathBuf::new(),
3706 };
3707 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3708 let i_abs = UpgradeInstruction::StateChange {
3709 script: PathBuf::from("/etc/migrations.txt"),
3710 };
3711 assert!(
3712 matches!(
3713 i_abs.validate().unwrap_err(),
3714 UpgradeError::AbsoluteScript { .. }
3715 ),
3716 "absolute + non-`.lisp` must surface AbsoluteScript first"
3717 );
3718 let i_esc = UpgradeInstruction::StateChange {
3719 script: PathBuf::from("../sibling/migrations.rs"),
3720 };
3721 assert!(
3722 matches!(
3723 i_esc.validate().unwrap_err(),
3724 UpgradeError::ParentEscapeScript { .. }
3725 ),
3726 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3727 );
3728 }
3729
3730 #[test]
3731 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3732 // Diagnostic-shape pin: the surfaced error message names the
3733 // offending path verbatim (so the author can grep their
3734 // caixa.lisp for the literal value), the `.lisp` extension
3735 // is named in the remediation, and the downstream consumer
3736 // (`tatara_lisp::read` at hot-upgrade migration time) is
3737 // named so the author can trace the contract back to its
3738 // source. Same self-locating shape every per-axis variant
3739 // carries (`BehaviorError::NonLispExtension`, c97815a;
3740 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3741 let bad = PathBuf::from("lib/migrations.txt");
3742 let err = UpgradeInstruction::StateChange {
3743 script: bad.clone(),
3744 }
3745 .validate()
3746 .unwrap_err();
3747 let msg = err.to_string();
3748 assert!(
3749 msg.contains("lib/migrations.txt"),
3750 "diagnostic must name the offending path verbatim, got {msg:?}"
3751 );
3752 assert!(
3753 msg.contains(".lisp"),
3754 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
3755 );
3756 assert!(
3757 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
3758 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
3759 );
3760 match err {
3761 UpgradeError::NonLispExtensionScript { script } => {
3762 assert_eq!(
3763 script, bad,
3764 "variant must carry the offending path verbatim"
3765 );
3766 }
3767 other => panic!("expected NonLispExtensionScript, got {other:?}"),
3768 }
3769 }
3770
3771 #[test]
3772 fn declared_path_only_for_state_change() {
3773 let load = UpgradeInstruction::LoadModule { module: "x".into() };
3774 assert!(load.declared_path().is_none());
3775 let mig = UpgradeInstruction::StateChange {
3776 script: PathBuf::from("lib/m.lisp"),
3777 };
3778 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
3779 }
3780
3781 #[test]
3782 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
3783 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3784 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
3785 // predicate: [`UpgradeInstruction::Restart`] is the only variant
3786 // that satisfies `.is_restart()`; every module-bearing arm
3787 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
3788 // `StateChange` arm all return `false`. This pin makes the
3789 // partition invariant load-bearing at caixa-core test time so a
3790 // future derive regression (a hole that returns `false` for
3791 // `Restart` too, or a byte-collision that flips a second variant
3792 // to `true`) trips here rather than laundering the arm at
3793 // [`Self::validate_restart_exclusive`]'s paired positive /
3794 // negated filter sites (a hole flips restart-count to 0 →
3795 // vacuous OK; a collision flips restart-count > 1 → false
3796 // `RestartNotExclusive` on an entry the author declared without
3797 // any `(:restart)`). Peer of the sibling
3798 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
3799 // pin on the M0 `CaixaKind` axis.
3800 let cases: &[(UpgradeInstruction, bool)] = &[
3801 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3802 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3803 (UpgradeInstruction::Purge { module: "c".into() }, false),
3804 (
3805 UpgradeInstruction::StateChange {
3806 script: PathBuf::from("lib/m.lisp"),
3807 },
3808 false,
3809 ),
3810 (UpgradeInstruction::Restart, true),
3811 ];
3812 for (variant, expected) in cases {
3813 assert_eq!(
3814 variant.is_restart(),
3815 *expected,
3816 "UpgradeInstruction::{variant:?}.is_restart() must \
3817 return {expected} (partition invariant on the \
3818 IsVariant-derived arm-discriminator predicate)"
3819 );
3820 }
3821 }
3822
3823 #[test]
3824 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
3825 // Byte-identity pin on the paired positive / negated
3826 // `.is_restart()` filters at
3827 // [`Self::validate_restart_exclusive`] against the pre-lift
3828 // `matches!(i, UpgradeInstruction::Restart)` /
3829 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
3830 // consumer of the gate previously coupled to inline. Asserts
3831 // the two projections agree byte-for-byte on every arm of the
3832 // enum, so a future derive regression that flipped either
3833 // predicate's arm-set would surface here at caixa-core test
3834 // time rather than at
3835 // [`Self::validate_restart_exclusive`]'s per-entry restart-
3836 // count / other-kinds tabulation far from the derive site.
3837 // Same peer-shape pin every sibling
3838 // `IsVariant`-derive-routed gate carries on the substrate's
3839 // closed-set typed-enum surface.
3840 let cases: Vec<UpgradeInstruction> = vec![
3841 UpgradeInstruction::LoadModule { module: "a".into() },
3842 UpgradeInstruction::SoftPurge { module: "b".into() },
3843 UpgradeInstruction::Purge { module: "c".into() },
3844 UpgradeInstruction::StateChange {
3845 script: PathBuf::from("lib/m.lisp"),
3846 },
3847 UpgradeInstruction::Restart,
3848 ];
3849 for instr in &cases {
3850 let via_predicate = instr.is_restart();
3851 let via_matches = matches!(instr, UpgradeInstruction::Restart);
3852 assert_eq!(
3853 via_predicate, via_matches,
3854 "UpgradeInstruction::{instr:?}: is_restart() must \
3855 byte-equal matches!(_, UpgradeInstruction::Restart) — \
3856 the pre-lift open-coded pattern and the \
3857 IsVariant-derived predicate are the same axis, \
3858 one typed dispatch"
3859 );
3860 }
3861 }
3862
3863 #[test]
3864 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
3865 // The fail-before-pass-after pin on the lifted
3866 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
3867 // arm-discriminator predicate:
3868 // [`UpgradeInstruction::SoftPurge`] and
3869 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
3870 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
3871 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
3872 // on the paired two-phase-load half,
3873 // [`UpgradeInstruction::StateChange`] on the
3874 // `gen_server:code_change/3`-analog migration axis,
3875 // [`UpgradeInstruction::Restart`] on the OTP terminal-
3876 // fallback shape) returns `false`. This pin makes the
3877 // partition invariant load-bearing at caixa-core test time
3878 // so a future accessor regression (a hole that returns
3879 // `false` for `SoftPurge` or `Purge`, or a byte-collision
3880 // that flips `LoadModule` / `StateChange` / `Restart` to
3881 // `true`) trips here rather than laundering the arm at the
3882 // three within-entry cross-instruction cleanup-facing gates
3883 // ([`UpgradeFromEntry::validate_purge_ordering`],
3884 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
3885 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
3886 // hole would silently accept a cleanup-shaped entry the
3887 // three gates should refuse; a collision would fire a
3888 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
3889 // `DuplicateCleanup` refusal on a well-shaped
3890 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
3891 // `Restart` arm the three gates should pass through. Peer
3892 // of the sibling
3893 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3894 // pin on the single-arm terminal-fallback partition —
3895 // extended here from the single-arm case onto the two-arm
3896 // cleanup-family union case.
3897 let cases: &[(UpgradeInstruction, bool)] = &[
3898 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3899 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
3900 (UpgradeInstruction::Purge { module: "c".into() }, true),
3901 (
3902 UpgradeInstruction::StateChange {
3903 script: PathBuf::from("lib/m.lisp"),
3904 },
3905 false,
3906 ),
3907 (UpgradeInstruction::Restart, false),
3908 ];
3909 for (variant, expected) in cases {
3910 assert_eq!(
3911 variant.is_cleanup(),
3912 *expected,
3913 "UpgradeInstruction::{variant:?}.is_cleanup() must \
3914 return {expected} (partition invariant on the \
3915 lifted OTP-appup two-arm cleanup-family arm-\
3916 discriminator predicate)"
3917 );
3918 }
3919 }
3920
3921 #[test]
3922 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
3923 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
3924 // composition against the two [`gen_platform::IsVariant`]-
3925 // derive-generated per-variant classifiers it routes through
3926 // — the accessor's one body must byte-equal
3927 // `self.is_soft_purge() || self.is_purge()` across every arm
3928 // of the closed-set enum, so a future silent detour that
3929 // reintroduced a raw `matches!` pattern or that stopped
3930 // composing through the derive-generated per-variant
3931 // predicates (an accidental `self.is_soft_purge()` on its
3932 // own — silently dropping the `Purge` arm; an accidental
3933 // `self.is_purge() || self.is_state_change()` — silently
3934 // folding the migration arm into the cleanup family; a
3935 // typo `&&` for the union `||` — silently classifying no
3936 // arm as cleanup) trips here at caixa-core test time
3937 // rather than laundering the arm at the three within-entry
3938 // cross-instruction cleanup-facing gates. Same peer-shape
3939 // pin the sibling
3940 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3941 // carries on the paired terminal-fallback axis.
3942 let cases: Vec<UpgradeInstruction> = vec![
3943 UpgradeInstruction::LoadModule { module: "a".into() },
3944 UpgradeInstruction::SoftPurge { module: "b".into() },
3945 UpgradeInstruction::Purge { module: "c".into() },
3946 UpgradeInstruction::StateChange {
3947 script: PathBuf::from("lib/m.lisp"),
3948 },
3949 UpgradeInstruction::Restart,
3950 ];
3951 for instr in &cases {
3952 let via_predicate = instr.is_cleanup();
3953 let via_composition = instr.is_soft_purge() || instr.is_purge();
3954 assert_eq!(
3955 via_predicate, via_composition,
3956 "UpgradeInstruction::{instr:?}: is_cleanup() must \
3957 byte-equal is_soft_purge() || is_purge() — the \
3958 lifted union predicate and its per-variant \
3959 composition are the same axis, one typed dispatch"
3960 );
3961 }
3962 }
3963
3964 #[test]
3965 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
3966 // Composition-pin the load-bearing invariant every consumer
3967 // that routes through `is_cleanup()` + `declared_module()`
3968 // relies on: any [`UpgradeInstruction`] value whose
3969 // `.is_cleanup()` returns `true` must have a `Some(_)`
3970 // `.declared_module()`. This makes the three within-entry
3971 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
3972 // implies declared_module() is Some")` structurally
3973 // infallible at build time — a future refactor that added
3974 // a cleanup-shaped variant carrying no `:module` would trip
3975 // here rather than panic at
3976 // [`UpgradeFromEntry::validate_purge_ordering`] /
3977 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
3978 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
3979 // runtime on the offending author's caixa.lisp.
3980 let cases: Vec<UpgradeInstruction> = vec![
3981 UpgradeInstruction::LoadModule { module: "a".into() },
3982 UpgradeInstruction::SoftPurge { module: "b".into() },
3983 UpgradeInstruction::Purge { module: "c".into() },
3984 UpgradeInstruction::StateChange {
3985 script: PathBuf::from("lib/m.lisp"),
3986 },
3987 UpgradeInstruction::Restart,
3988 ];
3989 for instr in &cases {
3990 if instr.is_cleanup() {
3991 assert!(
3992 instr.declared_module().is_some(),
3993 "UpgradeInstruction::{instr:?}: is_cleanup() \
3994 must imply declared_module().is_some() — the \
3995 three within-entry cross-instruction cleanup-\
3996 facing gates rely on this invariant to route \
3997 the cleanup-target :module scalar through the \
3998 sibling declared_module accessor without a \
3999 pattern-bound `module` binding"
4000 );
4001 }
4002 }
4003 }
4004
4005 #[test]
4006 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4007 // Composition-pin the load-bearing invariant
4008 // [`UpgradeFromEntry::validate_load_singularity`] relies on
4009 // when routing the per-instruction load-family arm-discriminator
4010 // through the sibling
4011 // [`UpgradeInstruction::is_load_module`] +
4012 // [`UpgradeInstruction::declared_module`] accessor pair: any
4013 // [`UpgradeInstruction`] value whose `.is_load_module()`
4014 // returns `true` must have a `Some(_)` `.declared_module()`.
4015 // This makes the gate's `.expect("is_load_module() implies
4016 // declared_module() is Some")` structurally infallible at
4017 // build time — a future refactor that added a load-shaped
4018 // variant carrying no `:module` would trip here rather than
4019 // panic at [`UpgradeFromEntry::validate_load_singularity`]
4020 // at runtime on the offending author's caixa.lisp. Sibling
4021 // of the peer
4022 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4023 // composition pin on the two-arm cleanup-family axis — same
4024 // "predicate implies accessor" discipline extended onto the
4025 // single-arm load-family axis, closes the load-vs-cleanup
4026 // pair on the substrate primitive's typed dispatch discipline.
4027 let cases: Vec<UpgradeInstruction> = vec![
4028 UpgradeInstruction::LoadModule { module: "a".into() },
4029 UpgradeInstruction::SoftPurge { module: "b".into() },
4030 UpgradeInstruction::Purge { module: "c".into() },
4031 UpgradeInstruction::StateChange {
4032 script: PathBuf::from("lib/m.lisp"),
4033 },
4034 UpgradeInstruction::Restart,
4035 ];
4036 for instr in &cases {
4037 if instr.is_load_module() {
4038 assert!(
4039 instr.declared_module().is_some(),
4040 "UpgradeInstruction::{instr:?}: is_load_module() \
4041 must imply declared_module().is_some() — the \
4042 within-entry load-singularity gate relies on this \
4043 invariant to route the load-target :module scalar \
4044 through the sibling declared_module accessor \
4045 without a pattern-bound `module` binding"
4046 );
4047 }
4048 }
4049 }
4050
4051 #[test]
4052 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4053 {
4054 // Byte-identity pin on the
4055 // [`UpgradeFromEntry::validate_load_singularity`] load-family
4056 // dispatch against the pre-lift
4057 // `match instr { UpgradeInstruction::LoadModule { module } =>
4058 // module.as_str(), _ => continue }` open-coded pattern-match
4059 // the site previously carried. Asserts the two projections
4060 // agree byte-for-byte on every arm of the enum — the
4061 // arm-discriminator via `is_load_module()` and the `:module`
4062 // scalar via `declared_module()` — so a future derive
4063 // regression that flipped the predicate's arm-set (a hole
4064 // returning `false` for [`UpgradeInstruction::LoadModule`], a
4065 // byte-collision flipping a second variant to `true`) or an
4066 // accessor extension that promoted an additional variant onto
4067 // the `String`-carrying axis would trip here at caixa-core
4068 // test time rather than laundering the arm at the gate's
4069 // per-entry load-singularity scan far from the derive site.
4070 // Peer of the sibling
4071 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4072 // byte-identity pin on the paired ordering-side load-family
4073 // sticky-latch dispatch (both consumers now agree on one
4074 // typed dispatch for the load-family axis) and the peer
4075 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4076 // pin on the migration-family script-projection axis — the
4077 // three within-entry per-instruction-class singularity gates
4078 // now share one byte-identity pin apiece against their
4079 // respective substrate-primitive typed dispatches.
4080 //
4081 // Three-arm projective coverage:
4082 // (a) `LoadModule` modules project through
4083 // `declared_module()` byte-equal to the raw
4084 // `module.as_str()` field access;
4085 // (b) a duplicate-`LoadModule` input trips the gate on the
4086 // second occurrence with `DuplicateLoadModule` carrying
4087 // the offending module verbatim;
4088 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4089 // `StateChange` / `Restart`) leaves the gate vacuous
4090 // with `Ok(())` — the `!instr.is_load_module()`
4091 // `continue` fall-through pins.
4092 //
4093 // Fail-before-pass-after verified locally: swapping the
4094 // production `if !instr.is_load_module() { continue; } let
4095 // module = instr.declared_module().expect(…);` back to `let
4096 // module = match instr { UpgradeInstruction::LoadModule
4097 // { module } => module.as_str(), _ => continue, };` keeps
4098 // arms (a)-(c) passing but silently detaches the gate from
4099 // the accessor's typed dispatch — any future
4100 // `is_load_module` / `declared_module` extension (a hole in
4101 // either predicate, a promotion of an additional variant
4102 // onto the `String`-carrying axis, an operator-side
4103 // pre-parsed caixa-name cache the accessor materializes)
4104 // would then silently disagree between this gate's raw
4105 // pattern-match and the peer per-`UpgradeInstruction`
4106 // consumers that route through the accessor pair.
4107
4108 // (a) LoadModule projection byte-equal via
4109 // is_load_module() + declared_module().
4110 let lm = UpgradeInstruction::LoadModule {
4111 module: "hello-rio".into(),
4112 };
4113 assert!(
4114 lm.is_load_module(),
4115 "LoadModule must satisfy is_load_module() — the gate's \
4116 load-family arm-discriminator relies on this partition"
4117 );
4118 assert_eq!(
4119 lm.declared_module(),
4120 Some("hello-rio"),
4121 "declared_module() must project the LoadModule :module \
4122 byte-equal to the raw field access — accessor divergence \
4123 would silently detach the gate from the projection every \
4124 peer per-`UpgradeInstruction` consumer routes through"
4125 );
4126
4127 // (b) Duplicate-LoadModule input trips the gate.
4128 let dup = entry(
4129 "0.1.0",
4130 vec![
4131 UpgradeInstruction::LoadModule { module: "x".into() },
4132 UpgradeInstruction::LoadModule { module: "x".into() },
4133 ],
4134 );
4135 assert_eq!(
4136 dup.validate_load_singularity(),
4137 Err(UpgradeError::DuplicateLoadModule {
4138 from: "0.1.0".into(),
4139 module: "x".into(),
4140 }),
4141 "duplicate LoadModule modules within one entry must fire \
4142 DuplicateLoadModule byte-identical to the pre-lift \
4143 pattern-match shape"
4144 );
4145
4146 // (c) Non-LoadModule-only input leaves the gate vacuous.
4147 let no_load = entry(
4148 "0.1.0",
4149 vec![
4150 UpgradeInstruction::StateChange {
4151 script: PathBuf::from("lib/m.lisp"),
4152 },
4153 UpgradeInstruction::Restart,
4154 ],
4155 );
4156 assert_eq!(
4157 no_load.validate_load_singularity(),
4158 Ok(()),
4159 "non-LoadModule-only entries must leave the load-\
4160 singularity gate vacuous — the `!is_load_module()` \
4161 continue fall-through pins"
4162 );
4163 }
4164
4165 #[test]
4166 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4167 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4168 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4169 // predicate: [`UpgradeInstruction::LoadModule`] is the only
4170 // variant that satisfies `.is_load_module()`; every cleanup arm
4171 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4172 // and the terminal-fallback arm (`Restart`) all return `false`.
4173 // This pin makes the partition invariant load-bearing at
4174 // caixa-core test time so a future derive regression (a hole
4175 // that returns `false` for `LoadModule` too, or a byte-collision
4176 // that flips a second variant to `true`) trips here rather than
4177 // laundering the arm at
4178 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4179 // dispatch — a hole would silently keep `loaded = false` through
4180 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4181 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4182 // a collision would flip `loaded = true` on a well-shaped
4183 // cleanup-only entry and silently swallow the load-less
4184 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4185 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4186 // and
4187 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4188 // pins on the paired terminal-fallback and cleanup-family
4189 // arm-discriminator axes — closes the last unlifted `matches!`-
4190 // based arm-discriminator axis on the OTP-appup closed-set
4191 // typed enum.
4192 let cases: &[(UpgradeInstruction, bool)] = &[
4193 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4194 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4195 (UpgradeInstruction::Purge { module: "c".into() }, false),
4196 (
4197 UpgradeInstruction::StateChange {
4198 script: PathBuf::from("lib/m.lisp"),
4199 },
4200 false,
4201 ),
4202 (UpgradeInstruction::Restart, false),
4203 ];
4204 for (variant, expected) in cases {
4205 assert_eq!(
4206 variant.is_load_module(),
4207 *expected,
4208 "UpgradeInstruction::{variant:?}.is_load_module() must \
4209 return {expected} (partition invariant on the \
4210 IsVariant-derived arm-discriminator predicate)"
4211 );
4212 }
4213 }
4214
4215 #[test]
4216 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4217 // Byte-identity pin on the [`Self::validate_purge_ordering`]
4218 // load-family sticky-latch dispatch against the pre-lift
4219 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4220 // predicate the site previously open-coded. Asserts the two
4221 // projections agree byte-for-byte on every arm of the enum, so
4222 // a future derive regression that flipped the predicate's
4223 // arm-set would surface here at caixa-core test time rather
4224 // than at [`Self::validate_purge_ordering`]'s per-entry
4225 // load-before-cleanup ordering scan far from the derive site.
4226 // Same peer-shape pin the sibling
4227 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4228 // carries on the paired terminal-fallback axis and the
4229 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4230 // carries on the two-arm cleanup-family axis — the third and
4231 // final byte-identity pin closes the substrate primitive's
4232 // arm-discriminator dispatch discipline on the OTP-appup
4233 // closed-set typed enum.
4234 let cases: Vec<UpgradeInstruction> = vec![
4235 UpgradeInstruction::LoadModule { module: "a".into() },
4236 UpgradeInstruction::SoftPurge { module: "b".into() },
4237 UpgradeInstruction::Purge { module: "c".into() },
4238 UpgradeInstruction::StateChange {
4239 script: PathBuf::from("lib/m.lisp"),
4240 },
4241 UpgradeInstruction::Restart,
4242 ];
4243 for instr in &cases {
4244 let via_predicate = instr.is_load_module();
4245 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4246 assert_eq!(
4247 via_predicate, via_matches,
4248 "UpgradeInstruction::{instr:?}: is_load_module() must \
4249 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4250 {{ .. }}) — the pre-lift open-coded pattern and the \
4251 IsVariant-derived predicate are the same axis, one \
4252 typed dispatch"
4253 );
4254 }
4255 }
4256
4257 #[test]
4258 fn declared_module_only_for_module_bearing_variants() {
4259 // Pinned partition of the `UpgradeInstruction` closed-set
4260 // variant space against the sibling of the peer
4261 // `declared_path` accessor: every OTP-appup module-bearing
4262 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4263 // `:module` string byte-for-byte through the lifted
4264 // `declared_module` accessor; every non-module-bearing variant
4265 // (`StateChange` on the peer `:script`-carrying axis;
4266 // `Restart` on the OTP terminal-fallback data-less axis)
4267 // returns `None`. Mirrors the peer
4268 // `declared_path_only_for_state_change` pin — the pair now
4269 // closes both scalar-carrying axes on the enum on one lifted
4270 // `Option<&…>` accessor apiece.
4271 let load = UpgradeInstruction::LoadModule {
4272 module: "hello-rio".into(),
4273 };
4274 assert_eq!(load.declared_module(), Some("hello-rio"));
4275 let soft = UpgradeInstruction::SoftPurge {
4276 module: "hello-rio-old".into(),
4277 };
4278 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4279 let hard = UpgradeInstruction::Purge {
4280 module: "hello-rio-ancient".into(),
4281 };
4282 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4283 let mig = UpgradeInstruction::StateChange {
4284 script: PathBuf::from("lib/m.lisp"),
4285 };
4286 assert!(mig.declared_module().is_none());
4287 assert!(UpgradeInstruction::Restart.declared_module().is_none());
4288 }
4289
4290 #[test]
4291 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4292 // Byte-identity pin on the two-accessor partition: every
4293 // `UpgradeInstruction` variant returns `Some` from *exactly
4294 // one* of {`declared_module`, `declared_path`} (the two
4295 // module-bearing / script-carrying axes) or from *neither*
4296 // (the OTP terminal-fallback `Restart` shape). No variant
4297 // returns `Some` from both — the two axes are disjoint by
4298 // construction, and this pin closes the disjointness at the
4299 // test surface so a future variant that leaks a scalar across
4300 // both axes fails at build time. Mirrors the peer
4301 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4302 // discipline on the `BehaviorSpec` per-slot family.
4303 let cases: Vec<UpgradeInstruction> = vec![
4304 UpgradeInstruction::LoadModule { module: "a".into() },
4305 UpgradeInstruction::SoftPurge { module: "b".into() },
4306 UpgradeInstruction::Purge { module: "c".into() },
4307 UpgradeInstruction::StateChange {
4308 script: PathBuf::from("lib/m.lisp"),
4309 },
4310 UpgradeInstruction::Restart,
4311 ];
4312 for instr in &cases {
4313 let has_module = instr.declared_module().is_some();
4314 let has_path = instr.declared_path().is_some();
4315 assert!(
4316 !(has_module && has_path),
4317 "no variant may declare both a module and a path — offending: {instr:?}"
4318 );
4319 match instr {
4320 UpgradeInstruction::LoadModule { .. }
4321 | UpgradeInstruction::SoftPurge { .. }
4322 | UpgradeInstruction::Purge { .. } => {
4323 assert!(has_module && !has_path, "module axis: {instr:?}");
4324 }
4325 UpgradeInstruction::StateChange { .. } => {
4326 assert!(!has_module && has_path, "script axis: {instr:?}");
4327 }
4328 UpgradeInstruction::Restart => {
4329 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4330 }
4331 }
4332 }
4333 }
4334
4335 #[test]
4336 fn entry_with_chain_of_versions() {
4337 // Middle entry pairs a `:load-module` with the trailing
4338 // `:soft-purge` so it satisfies the within-entry purge-ordering
4339 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4340 // preceding `:load-module`, mirroring the state-change-ordering
4341 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4342 // test is *cross-entry* `:from` values; the within-entry shape
4343 // is incidental — keeping it canonical (`:load-module` before
4344 // `:soft-purge`) leaves the chain assertion load-bearing.
4345 let entries = vec![
4346 entry(
4347 "0.1.0",
4348 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4349 ),
4350 entry(
4351 "0.1.5",
4352 vec![
4353 UpgradeInstruction::LoadModule { module: "x".into() },
4354 UpgradeInstruction::SoftPurge {
4355 module: "x-old".into(),
4356 },
4357 ],
4358 ),
4359 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4360 ];
4361 for e in &entries {
4362 e.validate().unwrap();
4363 }
4364 let json = serde_json::to_string(&entries).unwrap();
4365 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4366 assert_eq!(entries, back);
4367 }
4368
4369 #[test]
4370 fn empty_instructions_list_is_valid() {
4371 let e = entry("0.1.0", vec![]);
4372 e.validate().unwrap();
4373 }
4374
4375 #[test]
4376 fn json_uses_kebab_case_kind_tags() {
4377 let i = UpgradeInstruction::SoftPurge {
4378 module: "x-old".into(),
4379 };
4380 let json = serde_json::to_string(&i).unwrap();
4381 assert!(json.contains("\"kind\":\"soft-purge\""));
4382 let i2 = UpgradeInstruction::StateChange {
4383 script: PathBuf::from("m.lisp"),
4384 };
4385 let json2 = serde_json::to_string(&i2).unwrap();
4386 assert!(json2.contains("\"kind\":\"state-change\""));
4387 }
4388
4389 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4390
4391 #[test]
4392 fn validate_upgrade_from_accepts_disjoint_versions() {
4393 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4394 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4395 // (and `entry_with_chain_of_versions` above) passes the cross-
4396 // entry gate. Different `:from` per entry is the intended
4397 // shape; the gate must not regress this baseline. Middle entry
4398 // pairs `:load-module` with `:soft-purge` to satisfy the
4399 // within-entry purge-ordering gate (see
4400 // `entry_with_chain_of_versions` for the same shape).
4401 let entries = vec![
4402 entry(
4403 "0.1.0",
4404 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4405 ),
4406 entry(
4407 "0.1.5",
4408 vec![
4409 UpgradeInstruction::LoadModule { module: "x".into() },
4410 UpgradeInstruction::SoftPurge {
4411 module: "x-old".into(),
4412 },
4413 ],
4414 ),
4415 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4416 ];
4417 validate_upgrade_from(&entries).unwrap();
4418 }
4419
4420 #[test]
4421 fn validate_upgrade_from_accepts_empty_list() {
4422 // Absent `:upgrade-from` (the bare `feira init` shape) — the
4423 // gate must trivially pass an empty list. Mirrors the per-axis
4424 // "empty list passes" positive control on every peer typed-
4425 // graph gate (`validate_membros` empty list, `validate_placement`
4426 // requires non-empty clusters but only after a `Placement`
4427 // exists, etc.).
4428 validate_upgrade_from(&[]).unwrap();
4429 }
4430
4431 #[test]
4432 fn validate_upgrade_from_rejects_duplicate_from() {
4433 // Fail-before-pass-after pin: two entries with the same parsed-
4434 // semver `:from` are an ambiguous edge in the typed upgrade
4435 // graph (OTP appup picks at most one matching block per running
4436 // version; with two matching blocks the operator picks either
4437 // set non-deterministically — author intent is one path per
4438 // prior version). Same set-not-multiset discipline as
4439 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4440 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4441 // `:entrada :paths` (eb3456d) — now extended onto the fifth
4442 // typed-graph axis.
4443 let entries = vec![
4444 entry(
4445 "0.1.0",
4446 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4447 ),
4448 entry(
4449 "0.1.0",
4450 vec![
4451 UpgradeInstruction::LoadModule { module: "x".into() },
4452 UpgradeInstruction::SoftPurge {
4453 module: "x-old".into(),
4454 },
4455 ],
4456 ),
4457 ];
4458 let err = validate_upgrade_from(&entries).unwrap_err();
4459 assert_eq!(
4460 err,
4461 UpgradeError::DuplicateFrom {
4462 from: "0.1.0".into()
4463 },
4464 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4465 offending value verbatim"
4466 );
4467 }
4468
4469 #[test]
4470 fn validate_upgrade_from_treats_pre_release_as_distinct() {
4471 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4472 // equal under semver (pre-release version is part of the
4473 // identity), so they're distinct upgrade paths and must not
4474 // collide. A future tightening that collapses pre-release into
4475 // the release version surfaces here.
4476 let entries = vec![
4477 entry("1.0.0", vec![UpgradeInstruction::Restart]),
4478 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4479 ];
4480 validate_upgrade_from(&entries).unwrap();
4481 }
4482
4483 #[test]
4484 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4485 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4486 // compares build metadata (it derives equality across all
4487 // fields including `pre` + `build`), so `1.0.0+build1` and
4488 // `1.0.0+build2` are *not* duplicates from the gate's
4489 // perspective — the operator may treat the build-metadata
4490 // suffix as a tiebreaker even though the semver spec says
4491 // build metadata is ignored for precedence
4492 // (https://semver.org/#spec-item-10). Pin the conservative
4493 // behavior here so a future switch to a build-metadata-
4494 // stripping comparator surfaces as a test failure first; that
4495 // change would require coordinating with the wasm-operator's
4496 // `:from`-match dispatch step, which is the load-bearing
4497 // semantic we'd be mirroring.
4498 let entries = vec![
4499 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4500 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4501 ];
4502 validate_upgrade_from(&entries).unwrap();
4503 }
4504
4505 #[test]
4506 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4507 // Order pin: a malformed `:from` on the second entry surfaces
4508 // its `FromInvalid` diagnostic, not a (less-useful)
4509 // `DuplicateFrom`. The per-entry shape pass runs *inline*
4510 // before the duplicate-key insert — parallel to
4511 // `child_versao_invalid_fires_before_duplicate_check`
4512 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4513 // (9888b13). Without this pin a future shortcut that runs the
4514 // cross-entry gate first would surface a duplicate diagnostic
4515 // on a string that isn't even parsable as a version.
4516 let entries = vec![
4517 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4518 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4519 ];
4520 let err = validate_upgrade_from(&entries).unwrap_err();
4521 assert!(
4522 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4523 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4524 );
4525 }
4526
4527 #[test]
4528 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4529 // Symmetric arm: a malformed shape on the *first* entry of a
4530 // duplicate pair surfaces its per-entry diagnostic too (not
4531 // the duplicate diagnostic that would otherwise fire on the
4532 // second entry). Pinned separately so a future shortcut that
4533 // walks the duplicate-check ahead of the per-entry pass for the
4534 // first entry only — easy regression to introduce — surfaces
4535 // here.
4536 let entries = vec![
4537 entry(
4538 "0.1.0",
4539 vec![UpgradeInstruction::LoadModule {
4540 module: String::new(),
4541 }],
4542 ),
4543 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4544 ];
4545 let err = validate_upgrade_from(&entries).unwrap_err();
4546 assert_eq!(
4547 err,
4548 UpgradeError::ModuleEmpty {
4549 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4550 },
4551 "malformed instruction on the first entry of a duplicate pair must surface its \
4552 per-entry diagnostic before the duplicate gate fires, got {err:?}"
4553 );
4554 }
4555
4556 #[test]
4557 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4558 // Diagnostic-shape pin: when three entries carry the same
4559 // `:from`, the gate reports the *first* collision (the second
4560 // entry) and stops — the third entry's duplicate is masked by
4561 // the first surfaced one. Mirrors
4562 // `validate_duplicate_child_diagnostic_names_first_collision`
4563 // (dbf50a9) on the supervisor axis.
4564 let entries = vec![
4565 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4566 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4567 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4568 ];
4569 let err = validate_upgrade_from(&entries).unwrap_err();
4570 assert_eq!(
4571 err,
4572 UpgradeError::DuplicateFrom {
4573 from: "0.1.0".into()
4574 }
4575 );
4576 }
4577
4578 #[test]
4579 fn validate_upgrade_from_single_entry_never_duplicates() {
4580 // Boundary control: a list of one entry can never produce a
4581 // duplicate, regardless of `:from` value (any single-element
4582 // set is trivially without duplicates). Pin this so a future
4583 // off-by-one in the seen-set insert doesn't accidentally flag
4584 // a single entry as duplicating itself.
4585 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4586 validate_upgrade_from(&entries).unwrap();
4587 }
4588
4589 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4590
4591 #[test]
4592 fn versao_gate_accepts_strict_upgrade() {
4593 // Positive control: the canonical "chain prior versions →
4594 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4595 // `:from` strictly less than the current `:versao` under
4596 // SemVer-2 precedence. The gate must not regress this baseline.
4597 let entries = vec![
4598 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4599 entry("0.1.5", vec![UpgradeInstruction::Restart]),
4600 entry("0.1.9", vec![UpgradeInstruction::Restart]),
4601 ];
4602 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4603 }
4604
4605 #[test]
4606 fn versao_gate_accepts_empty_entries() {
4607 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4608 // the gate is a no-op when the entries list is empty. Mirrors
4609 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4610 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4611 }
4612
4613 #[test]
4614 fn versao_gate_rejects_equal_from() {
4615 // Self-upgrade no-op: declaring `:from "0.2.0"` while
4616 // `:versao "0.2.0"` means "upgrade from myself to myself" —
4617 // the operator's dispatch either skips silently or
4618 // trivially "succeeds" with no observable state change.
4619 // Reject as the canonical "I forgot to bump :versao when
4620 // adding this entry" footgun.
4621 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4622 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4623 assert_eq!(
4624 err,
4625 UpgradeError::FromNotBeforeVersao {
4626 from: "0.2.0".into(),
4627 versao: "0.2.0".into(),
4628 },
4629 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4630 values verbatim, got {err:?}"
4631 );
4632 }
4633
4634 #[test]
4635 fn versao_gate_rejects_downgrade_from() {
4636 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4637 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4638 // the operator's `:from`-match dispatch can never reach (it
4639 // never runs a version >= the current one). Reject as the
4640 // canonical "I copy-pasted from the next minor version and
4641 // forgot to bump :versao" footgun.
4642 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4643 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4644 assert_eq!(
4645 err,
4646 UpgradeError::FromNotBeforeVersao {
4647 from: "0.3.0".into(),
4648 versao: "0.2.0".into(),
4649 }
4650 );
4651 }
4652
4653 #[test]
4654 fn versao_gate_accepts_prerelease_before_release() {
4655 // SemVer §11 precedence: pre-release versions are *less than*
4656 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4657 // FROM an RC TO the GA release is the canonical authoring
4658 // shape — must pass. A regression that collapses pre-release
4659 // into the release version (treating them as equal) surfaces
4660 // here as a false-positive rejection.
4661 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4662 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4663 }
4664
4665 #[test]
4666 fn versao_gate_rejects_release_after_prerelease() {
4667 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4668 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4669 // the typical "I'm on an RC of a release that already
4670 // shipped" footgun. The gate names both values verbatim
4671 // so the author can grep for either side and fix in one
4672 // edit.
4673 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4674 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4675 assert_eq!(
4676 err,
4677 UpgradeError::FromNotBeforeVersao {
4678 from: "0.2.0".into(),
4679 versao: "0.2.0-rc.1".into(),
4680 }
4681 );
4682 }
4683
4684 #[test]
4685 fn versao_gate_rejects_build_metadata_only_difference() {
4686 // SemVer §11 explicitly excludes build metadata from
4687 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4688 // *equal* under [`semver::Version::cmp`]. From the
4689 // operator's `:from`-match dispatch perspective this is a
4690 // self-upgrade no-op (no semantic transition between the
4691 // two), so the gate rejects it — *unlike* the peer
4692 // duplicate-`:from` gate which uses derived `PartialEq` and
4693 // treats build-metadata variants as distinct dispatch keys.
4694 // The two gates' different equality notions are deliberate:
4695 // duplicate-check is conservative (preserves operator-side
4696 // tiebreaking surface), precedence-check is permissive
4697 // (matches operator-side dispatch semantic).
4698 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4699 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4700 assert_eq!(
4701 err,
4702 UpgradeError::FromNotBeforeVersao {
4703 from: "0.2.0+build.1".into(),
4704 versao: "0.2.0".into(),
4705 }
4706 );
4707 }
4708
4709 #[test]
4710 fn versao_gate_silently_passes_on_unparseable_versao() {
4711 // Defensive arm: a malformed `:versao` (gated by the
4712 // narrower `ManifestError::VersaoInvalid` surface at the
4713 // load-bearing call site) must not regress into a
4714 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4715 // the precedence error over an unparseable `:versao` would
4716 // mask the more actionable root cause (the author meant to
4717 // type `"0.2.0"`, not `"v0.2.0"`).
4718 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4719 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4720 }
4721
4722 #[test]
4723 fn versao_gate_silently_passes_on_unparseable_from() {
4724 // Symmetric defensive arm: a malformed `:from` is gated by
4725 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4726 // upstream at the LayoutInvariants call site. Surfacing the
4727 // precedence error over an unparseable `:from` from this
4728 // gate alone would mask the narrower `FromInvalid`
4729 // diagnostic that's expected to lead — same fall-through
4730 // posture as the unparseable-`:versao` arm above. The
4731 // wiring in `LayoutInvariants::verify` runs
4732 // `validate_upgrade_from` *before* this gate, so in practice
4733 // an unparseable `:from` surfaces as `FromInvalid` first
4734 // and this gate is never reached on that input.
4735 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4736 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4737 }
4738
4739 #[test]
4740 fn versao_gate_reports_first_offending_entry() {
4741 // Determinism pin: with multiple offending entries the gate
4742 // surfaces the *first* one in declaration order — same
4743 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4744 // on the peer gate. Walks the entries in order; first
4745 // failing `:from >= :versao` short-circuits.
4746 let entries = vec![
4747 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4748 entry("0.3.0", vec![UpgradeInstruction::Restart]),
4749 entry("0.4.0", vec![UpgradeInstruction::Restart]),
4750 ];
4751 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4752 assert_eq!(
4753 err,
4754 UpgradeError::FromNotBeforeVersao {
4755 from: "0.3.0".into(),
4756 versao: "0.2.0".into(),
4757 },
4758 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
4759 );
4760 }
4761
4762 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
4763
4764 #[test]
4765 fn validate_rejects_restart_mixed_with_load_module() {
4766 // The "I'll try the typed path *then* restart anyway" footgun:
4767 // an instructions list with `(:restart)` plus `(:load-module …)`
4768 // is dead code in both directions (succeed → restart discards
4769 // the work that just succeeded, defeating the typed sequence's
4770 // whole point; fail → restart never reached because the entry
4771 // already failed). The gate names the offending entry's `:from`
4772 // verbatim plus the kebab-case lisp-form of every non-`:restart`
4773 // peer so the author can grep their caixa.lisp for either side
4774 // and fix in one edit.
4775 let e = entry(
4776 "0.1.0",
4777 vec![
4778 UpgradeInstruction::LoadModule {
4779 module: "hello-rio".into(),
4780 },
4781 UpgradeInstruction::Restart,
4782 ],
4783 );
4784 let err = e.validate().unwrap_err();
4785 assert_eq!(
4786 err,
4787 UpgradeError::RestartNotExclusive {
4788 from: "0.1.0".into(),
4789 restart_count: 1,
4790 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
4791 },
4792 "restart + load-module mix must surface as RestartNotExclusive naming the \
4793 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
4794 );
4795 }
4796
4797 #[test]
4798 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
4799 // Sweep the typed-sequence universe — every non-`:restart`
4800 // variant alongside `:restart` — and assert every typed
4801 // instruction's lisp-form appears in `other_kinds` in
4802 // declaration order. The author should be able to grep for
4803 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
4804 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
4805 // mapping surfaces here.
4806 let e = entry(
4807 "0.1.0",
4808 vec![
4809 UpgradeInstruction::LoadModule {
4810 module: "hello-rio".into(),
4811 },
4812 UpgradeInstruction::StateChange {
4813 script: PathBuf::from("lib/m.lisp"),
4814 },
4815 UpgradeInstruction::SoftPurge {
4816 module: "hello-rio-old".into(),
4817 },
4818 UpgradeInstruction::Purge {
4819 module: "hello-rio-old".into(),
4820 },
4821 UpgradeInstruction::Restart,
4822 ],
4823 );
4824 let err = e.validate().unwrap_err();
4825 assert_eq!(
4826 err,
4827 UpgradeError::RestartNotExclusive {
4828 from: "0.1.0".into(),
4829 restart_count: 1,
4830 other_kinds: vec![
4831 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
4832 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
4833 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4834 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4835 ],
4836 },
4837 );
4838 }
4839
4840 #[test]
4841 fn validate_rejects_restart_duplicated() {
4842 // `((:restart) (:restart))` — multiple Restart variants in one
4843 // entry. The fallback is a single semantic (restart the pod;
4844 // the new version comes up fresh); repeating it is at best
4845 // redundant, at worst suggests the author thought the second
4846 // would re-trigger after the first. The gate reports
4847 // `restart_count: 2` so the diagnostic surfaces the duplication
4848 // mode unambiguously even when `other_kinds` is empty.
4849 let e = entry(
4850 "0.1.0",
4851 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
4852 );
4853 let err = e.validate().unwrap_err();
4854 assert_eq!(
4855 err,
4856 UpgradeError::RestartNotExclusive {
4857 from: "0.1.0".into(),
4858 restart_count: 2,
4859 other_kinds: vec![],
4860 },
4861 );
4862 }
4863
4864 #[test]
4865 fn validate_accepts_sole_restart() {
4866 // Positive control: the canonical "this prior version's typed
4867 // upgrade is impossible — restart" authoring shape from the
4868 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
4869 // is the entry's whole instructions list and the only valid
4870 // Restart-bearing shape.
4871 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
4872 e.validate().unwrap();
4873 }
4874
4875 #[test]
4876 fn validate_accepts_typed_sequence_without_restart() {
4877 // Positive control: the canonical typed hot-upgrade authoring
4878 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
4879 // `:state-change` → `:soft-purge`. Absent `:restart` is the
4880 // only shape that lets the sequence run to completion under
4881 // the wasm-operator's `:from`-match dispatch. Drift here =
4882 // a future tighten that rejects any canonical typed-only shape
4883 // surfaces as a regression at this gate.
4884 let e = entry(
4885 "0.1.0",
4886 vec![
4887 UpgradeInstruction::LoadModule {
4888 module: "hello-rio".into(),
4889 },
4890 UpgradeInstruction::StateChange {
4891 script: PathBuf::from("lib/m.lisp"),
4892 },
4893 UpgradeInstruction::SoftPurge {
4894 module: "hello-rio-old".into(),
4895 },
4896 ],
4897 );
4898 e.validate().unwrap();
4899 }
4900
4901 // ── within-entry state-change-ordering invariant ───────────────────
4902
4903 #[test]
4904 fn validate_rejects_state_change_without_load() {
4905 // Fail-before-pass-after pin: a `:state-change` migrates state
4906 // into the newly-loaded code (gen_server:code_change/3 analog),
4907 // so an entry that runs it with no preceding `:load-module`
4908 // migrates state into code that was never loaded. The operator
4909 // runs instructions in declared order, so this is a build error,
4910 // not a runtime surprise (CAIXA-SDLC §III).
4911 let e = entry(
4912 "0.1.0",
4913 vec![UpgradeInstruction::StateChange {
4914 script: PathBuf::from("lib/m.lisp"),
4915 }],
4916 );
4917 let err = e.validate().unwrap_err();
4918 assert_eq!(
4919 err,
4920 UpgradeError::StateChangeWithoutPriorLoad {
4921 from: "0.1.0".into(),
4922 script: PathBuf::from("lib/m.lisp"),
4923 },
4924 "a `:state-change` with no preceding `:load-module` must surface as \
4925 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
4926 );
4927 }
4928
4929 #[test]
4930 fn validate_rejects_state_change_before_load() {
4931 // Right-instructions-wrong-order: the load is present but runs
4932 // *after* the migration. Because the operator executes in
4933 // declared order, the migration runs before the new code is
4934 // resident — the same incoherence as the missing-load case.
4935 let e = entry(
4936 "0.1.0",
4937 vec![
4938 UpgradeInstruction::StateChange {
4939 script: PathBuf::from("lib/m.lisp"),
4940 },
4941 UpgradeInstruction::LoadModule {
4942 module: "hello-rio".into(),
4943 },
4944 ],
4945 );
4946 let err = e.validate().unwrap_err();
4947 assert!(
4948 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4949 "a `:state-change` ahead of its `:load-module` must surface as \
4950 StateChangeWithoutPriorLoad, got {err:?}"
4951 );
4952 }
4953
4954 #[test]
4955 fn validate_accepts_state_change_after_load() {
4956 // Positive control: the canonical `(:load-module …)
4957 // (:state-change …)` order validates. The load need not name
4958 // the same module the migration targets (StateChange carries a
4959 // script, not a module ref), so any preceding `:load-module`
4960 // satisfies "new code is resident before its migration runs".
4961 let e = entry(
4962 "0.1.0",
4963 vec![
4964 UpgradeInstruction::LoadModule {
4965 module: "hello-rio".into(),
4966 },
4967 UpgradeInstruction::StateChange {
4968 script: PathBuf::from("lib/m.lisp"),
4969 },
4970 ],
4971 );
4972 e.validate().unwrap();
4973 }
4974
4975 #[test]
4976 fn validate_accepts_multiple_state_changes_after_one_load() {
4977 // A single leading `:load-module` covers every subsequent
4978 // `:state-change` — the `loaded` latch stays set once the new
4979 // code is resident.
4980 let e = entry(
4981 "0.1.0",
4982 vec![
4983 UpgradeInstruction::LoadModule {
4984 module: "hello-rio".into(),
4985 },
4986 UpgradeInstruction::StateChange {
4987 script: PathBuf::from("lib/m1.lisp"),
4988 },
4989 UpgradeInstruction::StateChange {
4990 script: PathBuf::from("lib/m2.lisp"),
4991 },
4992 ],
4993 );
4994 e.validate().unwrap();
4995 }
4996
4997 #[test]
4998 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
4999 {
5000 // Byte-identity pin on the
5001 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5002 // migrate ordering dispatch against the pre-lift
5003 // `match instr { UpgradeInstruction::LoadModule { .. } =>
5004 // loaded = true, UpgradeInstruction::StateChange { script } if
5005 // !loaded => …, _ => {} }` open-coded pattern-match the site
5006 // previously carried. Asserts the two projections agree
5007 // byte-for-byte on every arm of the enum — the load-family
5008 // arm-discriminator via `is_load_module()` and the migration-
5009 // family `:script` scalar via `declared_path()` — so a future
5010 // derive regression that flipped the predicate's arm-set (a
5011 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5012 // a byte-collision flipping a second variant to `true`) or an
5013 // accessor extension that promoted an additional variant onto
5014 // the `PathBuf`-carrying axis would trip here at caixa-core
5015 // test time rather than laundering the arm at the gate's
5016 // per-entry ordering scan far from the derive site.
5017 //
5018 // Peer of the sibling
5019 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5020 // (c9ce91d) pin on the peer within-entry per-instruction-class
5021 // singularity gate's load-family + `String`-carrying dispatch,
5022 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5023 // (580d0f1) pin on the paired load → cleanup ordering gate's
5024 // load-family sticky-latch dispatch, and the
5025 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5026 // pin on the peer within-entry per-instruction-class singularity
5027 // gate's migration-family script-projection dispatch — closes
5028 // the last unlifted `match`-shaped per-arm-hand-rolled load-
5029 // family arm-discriminator + migration-family script-projection
5030 // pair inside `impl UpgradeFromEntry`. The four within-entry
5031 // ordering / singularity gates now share one byte-identity pin
5032 // apiece against their respective substrate-primitive typed
5033 // dispatches on the OTP-appup closed-set enum.
5034 //
5035 // Three-arm projective coverage:
5036 // (a) `LoadModule` satisfies `is_load_module()`, so the
5037 // sticky-latch advances byte-equal to the pre-lift
5038 // `UpgradeInstruction::LoadModule { .. }` arm; every
5039 // other variant leaves the latch untouched;
5040 // (b) a `((:state-change …))`-only entry (no preceding load)
5041 // trips the gate on the first `StateChange` with
5042 // `StateChangeWithoutPriorLoad` carrying the offending
5043 // script verbatim — the migration-family script surfaces
5044 // through `declared_path()` byte-equal to the raw
5045 // `StateChange { script }` pattern-bound field;
5046 // (c) a `((:load-module …) (:state-change …))` entry leaves
5047 // the gate vacuous with `Ok(())` — the `loaded = true`
5048 // latch on the first arm satisfies the `!loaded` guard
5049 // negation on the second, so the `declared_path()`
5050 // `Some(script)` fall-through does not fire — and a
5051 // non-`StateChange`-non-`LoadModule` sequence
5052 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5053 // the gate vacuous because `declared_path()` is `None`
5054 // on all three of those arms.
5055 //
5056 // Fail-before-pass-after verified locally: swapping the
5057 // production `if instr.is_load_module() { loaded = true; }
5058 // else if !loaded && let Some(script) = instr.declared_path()
5059 // { … }` back to `match instr { UpgradeInstruction::LoadModule
5060 // { .. } => loaded = true, UpgradeInstruction::StateChange
5061 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5062 // passing but silently detaches the gate from the accessor's
5063 // typed dispatch — any future `is_load_module` / `declared_path`
5064 // extension (a hole in either predicate, a promotion of an
5065 // additional variant onto either axis, an operator-side
5066 // pre-resolved-path cache the accessor materializes) would
5067 // then silently disagree between this gate's raw pattern-match
5068 // and the peer per-`UpgradeInstruction` consumers that route
5069 // through the accessor pair.
5070
5071 // (a) is_load_module() partitions the arm-set byte-equal to
5072 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5073 // { .. })` and declared_path() surfaces the StateChange
5074 // `:script` byte-equal to the raw field access.
5075 let lm = UpgradeInstruction::LoadModule {
5076 module: "hello-rio".into(),
5077 };
5078 assert!(
5079 lm.is_load_module(),
5080 "LoadModule must satisfy is_load_module() — the gate's \
5081 load-family sticky-latch relies on this partition"
5082 );
5083 assert!(
5084 lm.declared_path().is_none(),
5085 "LoadModule must not carry a declared_path — the gate's \
5086 else-if migration-family arm must not fire on load arms"
5087 );
5088 let sc = UpgradeInstruction::StateChange {
5089 script: PathBuf::from("lib/m.lisp"),
5090 };
5091 assert!(
5092 !sc.is_load_module(),
5093 "StateChange must not satisfy is_load_module() — the gate's \
5094 sticky-latch must not advance on migration arms"
5095 );
5096 assert_eq!(
5097 sc.declared_path().map(std::path::PathBuf::as_path),
5098 Some(PathBuf::from("lib/m.lisp").as_path()),
5099 "declared_path() must project the StateChange :script \
5100 byte-equal to the raw field access — accessor divergence \
5101 would silently detach the gate from the projection every \
5102 peer per-`UpgradeInstruction` consumer routes through"
5103 );
5104
5105 // (b) A `((:state-change …))`-only entry trips
5106 // StateChangeWithoutPriorLoad byte-identical to the
5107 // pre-lift match-pattern shape.
5108 let no_prior_load = entry(
5109 "0.1.0",
5110 vec![UpgradeInstruction::StateChange {
5111 script: PathBuf::from("lib/m.lisp"),
5112 }],
5113 );
5114 assert_eq!(
5115 no_prior_load.validate_state_change_ordering(),
5116 Err(UpgradeError::StateChangeWithoutPriorLoad {
5117 from: "0.1.0".into(),
5118 script: PathBuf::from("lib/m.lisp"),
5119 }),
5120 "a `:state-change` with no preceding `:load-module` must fire \
5121 StateChangeWithoutPriorLoad carrying the offending script \
5122 verbatim through the declared_path() accessor"
5123 );
5124
5125 // (c) `((:load-module …) (:state-change …))` leaves the gate
5126 // vacuous; so does a non-StateChange-non-LoadModule
5127 // sequence (SoftPurge / Purge / Restart alone).
5128 let load_before_migrate = entry(
5129 "0.1.0",
5130 vec![
5131 UpgradeInstruction::LoadModule {
5132 module: "hello-rio".into(),
5133 },
5134 UpgradeInstruction::StateChange {
5135 script: PathBuf::from("lib/m.lisp"),
5136 },
5137 ],
5138 );
5139 assert_eq!(
5140 load_before_migrate.validate_state_change_ordering(),
5141 Ok(()),
5142 "load-before-migrate entries must leave the ordering gate \
5143 vacuous — the `loaded = true` sticky-latch on the first arm \
5144 satisfies the `!loaded` guard negation on the else-if arm"
5145 );
5146 for instr in [
5147 UpgradeInstruction::SoftPurge {
5148 module: "x-old".into(),
5149 },
5150 UpgradeInstruction::Purge {
5151 module: "x-old".into(),
5152 },
5153 UpgradeInstruction::Restart,
5154 ] {
5155 let e = entry("0.1.0", vec![instr.clone()]);
5156 assert_eq!(
5157 e.validate_state_change_ordering(),
5158 Ok(()),
5159 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5160 leave the ordering gate vacuous — declared_path() is None \
5161 on every non-StateChange arm, so the else-if migration-\
5162 family arm never fires"
5163 );
5164 }
5165 }
5166
5167 #[test]
5168 fn validate_state_change_ordering_fires_after_restart_exclusive() {
5169 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5170 // shape is *both* state-change-without-load and restart-mixed.
5171 // The more-fundamental `RestartNotExclusive` must win (a valid
5172 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5173 // entry should reach the ordering gate). Guards the call order
5174 // in `validate` against silent reordering.
5175 let e = entry(
5176 "0.1.0",
5177 vec![
5178 UpgradeInstruction::StateChange {
5179 script: PathBuf::from("lib/m.lisp"),
5180 },
5181 UpgradeInstruction::Restart,
5182 ],
5183 );
5184 let err = e.validate().unwrap_err();
5185 assert!(
5186 matches!(err, UpgradeError::RestartNotExclusive { .. }),
5187 "restart-mixed must surface before the ordering gate, got {err:?}"
5188 );
5189 }
5190
5191 // ── within-entry purge-ordering invariant ──────────────────────────
5192
5193 #[test]
5194 fn validate_rejects_soft_purge_without_load() {
5195 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5196 // module after the new one is resident (OTP's two-phase code
5197 // load — code:load_module/1 then code:soft_purge/1), so an
5198 // entry that runs it with no preceding `:load-module` drains
5199 // the live module with no replacement. The operator runs
5200 // instructions in declared order, so this is a build error,
5201 // not a runtime surprise (CAIXA-SDLC §III).
5202 let e = entry(
5203 "0.1.0",
5204 vec![UpgradeInstruction::SoftPurge {
5205 module: "x-old".into(),
5206 }],
5207 );
5208 let err = e.validate().unwrap_err();
5209 assert_eq!(
5210 err,
5211 UpgradeError::PurgeWithoutPriorLoad {
5212 from: "0.1.0".into(),
5213 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5214 module: "x-old".into(),
5215 },
5216 "a `:soft-purge` with no preceding `:load-module` must surface as \
5217 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5218 );
5219 }
5220
5221 #[test]
5222 fn validate_rejects_purge_without_load() {
5223 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5224 // the more catastrophic peer of `:soft-purge`; same gate, same
5225 // shape, kind-tag differs so the author can grep their
5226 // caixa.lisp for the offending `(:purge …)` form.
5227 let e = entry(
5228 "0.1.0",
5229 vec![UpgradeInstruction::Purge {
5230 module: "x-old".into(),
5231 }],
5232 );
5233 let err = e.validate().unwrap_err();
5234 assert_eq!(
5235 err,
5236 UpgradeError::PurgeWithoutPriorLoad {
5237 from: "0.1.0".into(),
5238 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5239 module: "x-old".into(),
5240 },
5241 );
5242 }
5243
5244 #[test]
5245 fn validate_rejects_soft_purge_before_load() {
5246 // Right-instructions-wrong-order: the load is present but runs
5247 // *after* the purge. Because the operator executes in declared
5248 // order, the cleanup drains the old code before the new code
5249 // is resident — same incoherence as the missing-load case,
5250 // leaving a window during which neither version is available.
5251 let e = entry(
5252 "0.1.0",
5253 vec![
5254 UpgradeInstruction::SoftPurge {
5255 module: "x-old".into(),
5256 },
5257 UpgradeInstruction::LoadModule { module: "x".into() },
5258 ],
5259 );
5260 let err = e.validate().unwrap_err();
5261 assert!(
5262 matches!(
5263 err,
5264 UpgradeError::PurgeWithoutPriorLoad {
5265 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5266 ..
5267 }
5268 ),
5269 "a `:soft-purge` ahead of its `:load-module` must surface as \
5270 PurgeWithoutPriorLoad, got {err:?}"
5271 );
5272 }
5273
5274 #[test]
5275 fn validate_rejects_purge_before_load() {
5276 // Symmetric arm on the `:purge` variant — the kind tag
5277 // distinguishes the diagnostic so the author lands on the
5278 // offending form directly.
5279 let e = entry(
5280 "0.1.0",
5281 vec![
5282 UpgradeInstruction::Purge {
5283 module: "x-old".into(),
5284 },
5285 UpgradeInstruction::LoadModule { module: "x".into() },
5286 ],
5287 );
5288 let err = e.validate().unwrap_err();
5289 assert!(
5290 matches!(
5291 err,
5292 UpgradeError::PurgeWithoutPriorLoad {
5293 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5294 ..
5295 }
5296 ),
5297 "a `:purge` ahead of its `:load-module` must surface as \
5298 PurgeWithoutPriorLoad, got {err:?}"
5299 );
5300 }
5301
5302 #[test]
5303 fn validate_accepts_soft_purge_after_load() {
5304 // Positive control: the canonical `(:load-module …)
5305 // (:soft-purge …)` order validates. The load need not name the
5306 // same module the purge targets — the cleanup typically targets
5307 // the *old* module name (e.g. `"x-old"`) and the load brings up
5308 // the *new* one (`"x"`); the gate only requires that *some*
5309 // `:load-module` precedes the purge, so the new code is resident
5310 // before the old one is drained.
5311 let e = entry(
5312 "0.1.0",
5313 vec![
5314 UpgradeInstruction::LoadModule { module: "x".into() },
5315 UpgradeInstruction::SoftPurge {
5316 module: "x-old".into(),
5317 },
5318 ],
5319 );
5320 e.validate().unwrap();
5321 }
5322
5323 #[test]
5324 fn validate_accepts_multiple_purges_after_one_load() {
5325 // A single leading `:load-module` covers every subsequent
5326 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5327 // the new code is resident. Same shape as
5328 // `validate_accepts_multiple_state_changes_after_one_load` on
5329 // the peer ordering gate.
5330 let e = entry(
5331 "0.1.0",
5332 vec![
5333 UpgradeInstruction::LoadModule { module: "x".into() },
5334 UpgradeInstruction::SoftPurge {
5335 module: "x-old".into(),
5336 },
5337 UpgradeInstruction::Purge {
5338 module: "x-oldest".into(),
5339 },
5340 ],
5341 );
5342 e.validate().unwrap();
5343 }
5344
5345 #[test]
5346 fn validate_purge_ordering_fires_after_state_change_ordering() {
5347 // Diagnostic-precedence pin: an entry like `((:state-change …)
5348 // (:soft-purge …))` is *both* state-change-without-load and
5349 // purge-without-load. The state-change gate must win — it's
5350 // the load-bearing semantic on this ordering contract, and
5351 // surfacing the purge diagnostic first would mask the more-
5352 // fundamental migration-against-stale-code defect. Guards the
5353 // call order in `validate` against silent reordering.
5354 let e = entry(
5355 "0.1.0",
5356 vec![
5357 UpgradeInstruction::StateChange {
5358 script: PathBuf::from("lib/m.lisp"),
5359 },
5360 UpgradeInstruction::SoftPurge {
5361 module: "x-old".into(),
5362 },
5363 ],
5364 );
5365 let err = e.validate().unwrap_err();
5366 assert!(
5367 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5368 "state-change-without-load must surface before purge-without-load, got {err:?}"
5369 );
5370 }
5371
5372 #[test]
5373 fn validate_purge_ordering_fires_after_per_instr_shape() {
5374 // Order pin: a malformed `:module` value on a `:soft-purge` (an
5375 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5376 // diagnostic *before* the within-entry purge-ordering gate fires.
5377 // The per-instruction shape pass walks the list inline before
5378 // the ordering checks, so the narrower self-locating diagnostic
5379 // surfaces first — mirrors the empty-first cascade on every peer
5380 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5381 // per_instr_shape` pin on the sibling ordering gate.
5382 let e = entry(
5383 "0.1.0",
5384 vec![UpgradeInstruction::SoftPurge {
5385 module: String::new(),
5386 }],
5387 );
5388 let err = e.validate().unwrap_err();
5389 assert_eq!(
5390 err,
5391 UpgradeError::ModuleEmpty {
5392 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5393 },
5394 "malformed instruction must surface its kind-tagged diagnostic before the \
5395 purge-ordering gate fires, got {err:?}"
5396 );
5397 }
5398
5399 #[test]
5400 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5401 // The whole-list entry-point surfaces the per-entry ordering
5402 // error (mirrors
5403 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5404 // the gate is reachable from the LayoutInvariants call site, not
5405 // only from a direct `entry.validate()`.
5406 let entries = vec![entry(
5407 "0.1.0",
5408 vec![UpgradeInstruction::Purge {
5409 module: "x-old".into(),
5410 }],
5411 )];
5412 let err = validate_upgrade_from(&entries).unwrap_err();
5413 assert!(
5414 matches!(
5415 err,
5416 UpgradeError::PurgeWithoutPriorLoad {
5417 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5418 ..
5419 }
5420 ),
5421 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5422 );
5423 }
5424
5425 #[test]
5426 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5427 // The whole-list entry-point surfaces the per-entry ordering
5428 // error (mirrors `validate_restart_exclusive_threads_through_…`):
5429 // the gate is reachable from the LayoutInvariants call site, not
5430 // only from a direct `entry.validate()`.
5431 let entries = vec![entry(
5432 "0.1.0",
5433 vec![UpgradeInstruction::StateChange {
5434 script: PathBuf::from("lib/m.lisp"),
5435 }],
5436 )];
5437 let err = validate_upgrade_from(&entries).unwrap_err();
5438 assert!(
5439 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5440 "validate_upgrade_from must thread the ordering error, got {err:?}"
5441 );
5442 }
5443
5444 // ── within-entry cleanup-singularity invariant ─────────────────────
5445
5446 #[test]
5447 fn validate_rejects_duplicate_soft_purge_for_same_module() {
5448 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5449 // its target module (code:soft_purge/1 analog); after the
5450 // first the module is gone, so a second `:soft-purge` of the
5451 // same module is at best a no-op and at worst undefined
5452 // (depending on the operator's handling of a non-resident-
5453 // module purge). Author one cleanup per module.
5454 let e = entry(
5455 "0.1.0",
5456 vec![
5457 UpgradeInstruction::LoadModule { module: "x".into() },
5458 UpgradeInstruction::SoftPurge {
5459 module: "x-old".into(),
5460 },
5461 UpgradeInstruction::SoftPurge {
5462 module: "x-old".into(),
5463 },
5464 ],
5465 );
5466 let err = e.validate().unwrap_err();
5467 assert_eq!(
5468 err,
5469 UpgradeError::DuplicateCleanup {
5470 from: "0.1.0".into(),
5471 module: "x-old".into(),
5472 kinds: vec![
5473 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5474 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5475 ],
5476 },
5477 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5478 module + both kinds in declaration order, got {err:?}"
5479 );
5480 }
5481
5482 #[test]
5483 fn validate_rejects_duplicate_purge_for_same_module() {
5484 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5485 // the more catastrophic peer of `:soft-purge`; same gate, same
5486 // shape, kind-tag distinguishes so the author can grep their
5487 // caixa.lisp for the offending `(:purge …)` form.
5488 let e = entry(
5489 "0.1.0",
5490 vec![
5491 UpgradeInstruction::LoadModule { module: "x".into() },
5492 UpgradeInstruction::Purge {
5493 module: "x-old".into(),
5494 },
5495 UpgradeInstruction::Purge {
5496 module: "x-old".into(),
5497 },
5498 ],
5499 );
5500 let err = e.validate().unwrap_err();
5501 assert_eq!(
5502 err,
5503 UpgradeError::DuplicateCleanup {
5504 from: "0.1.0".into(),
5505 module: "x-old".into(),
5506 kinds: vec![
5507 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5508 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5509 ],
5510 },
5511 );
5512 }
5513
5514 #[test]
5515 fn validate_rejects_soft_purge_then_purge_for_same_module() {
5516 // Soft-then-hard footgun: the author wrote "drain, and if
5517 // drain doesn't clean up, force-discard", but the operator
5518 // runs declared instructions unconditionally — the `:purge`
5519 // fires whether the `:soft-purge` already discarded the
5520 // module or not, so the imagined fallback semantic is
5521 // missing. Fallback on cleanup failure is the operator's
5522 // job, not authored into the entry. Both kinds carry in
5523 // declaration order so the author can grep for either side
5524 // and pick one.
5525 let e = entry(
5526 "0.1.0",
5527 vec![
5528 UpgradeInstruction::LoadModule { module: "x".into() },
5529 UpgradeInstruction::SoftPurge {
5530 module: "x-old".into(),
5531 },
5532 UpgradeInstruction::Purge {
5533 module: "x-old".into(),
5534 },
5535 ],
5536 );
5537 let err = e.validate().unwrap_err();
5538 assert_eq!(
5539 err,
5540 UpgradeError::DuplicateCleanup {
5541 from: "0.1.0".into(),
5542 module: "x-old".into(),
5543 kinds: vec![
5544 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5545 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5546 ],
5547 },
5548 );
5549 }
5550
5551 #[test]
5552 fn validate_rejects_purge_then_soft_purge_for_same_module() {
5553 // Reversed-ordering arm: `:purge` discards immediately; the
5554 // trailing `:soft-purge` has no module to drain. The kinds
5555 // list reflects declaration order so the diagnostic locates
5556 // both forms in the source.
5557 let e = entry(
5558 "0.1.0",
5559 vec![
5560 UpgradeInstruction::LoadModule { module: "x".into() },
5561 UpgradeInstruction::Purge {
5562 module: "x-old".into(),
5563 },
5564 UpgradeInstruction::SoftPurge {
5565 module: "x-old".into(),
5566 },
5567 ],
5568 );
5569 let err = e.validate().unwrap_err();
5570 assert_eq!(
5571 err,
5572 UpgradeError::DuplicateCleanup {
5573 from: "0.1.0".into(),
5574 module: "x-old".into(),
5575 kinds: vec![
5576 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5577 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5578 ],
5579 },
5580 );
5581 }
5582
5583 #[test]
5584 fn validate_accepts_distinct_cleanup_modules() {
5585 // Positive control: `:soft-purge` and `:purge` on *different*
5586 // modules pass the gate. Mirrors
5587 // `validate_accepts_multiple_purges_after_one_load` — the
5588 // cleanup-singularity gate is keyed on (module), not on
5589 // (kind, module) pair, so distinct old-version names render
5590 // distinct cleanup targets and don't collide. Sweep both
5591 // same-class (two `:soft-purge` distinct modules) and cross-
5592 // class (`:soft-purge` then `:purge` distinct modules) so a
5593 // future tighten to a kind-only key (which would over-fire on
5594 // distinct modules) surfaces here.
5595 let two_soft = entry(
5596 "0.1.0",
5597 vec![
5598 UpgradeInstruction::LoadModule { module: "x".into() },
5599 UpgradeInstruction::SoftPurge {
5600 module: "x-old".into(),
5601 },
5602 UpgradeInstruction::SoftPurge {
5603 module: "x-older".into(),
5604 },
5605 ],
5606 );
5607 two_soft.validate().unwrap();
5608 let mixed = entry(
5609 "0.1.0",
5610 vec![
5611 UpgradeInstruction::LoadModule { module: "x".into() },
5612 UpgradeInstruction::SoftPurge {
5613 module: "x-old".into(),
5614 },
5615 UpgradeInstruction::Purge {
5616 module: "x-oldest".into(),
5617 },
5618 ],
5619 );
5620 mixed.validate().unwrap();
5621 }
5622
5623 #[test]
5624 fn validate_accepts_single_cleanup_per_module() {
5625 // Boundary control: a list with exactly one `:soft-purge` and
5626 // one `:purge` (distinct modules, the canonical "drain one,
5627 // hard-discard the other" shape) is the gate's identity
5628 // element. Pin so a future off-by-one in the duplicate-detection
5629 // scan doesn't accidentally flag a single occurrence as
5630 // duplicating itself — mirrors
5631 // `validate_upgrade_from_single_entry_never_duplicates` on
5632 // the peer cross-entry duplicate axis.
5633 let e = entry(
5634 "0.1.0",
5635 vec![
5636 UpgradeInstruction::LoadModule { module: "x".into() },
5637 UpgradeInstruction::SoftPurge {
5638 module: "x-old".into(),
5639 },
5640 UpgradeInstruction::Purge {
5641 module: "y-old".into(),
5642 },
5643 ],
5644 );
5645 e.validate().unwrap();
5646 }
5647
5648 #[test]
5649 fn validate_cleanup_singularity_fires_after_purge_ordering() {
5650 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5651 // (:soft-purge "x"))` is *both* purge-without-load and
5652 // duplicate-cleanup. The more-fundamental ordering gate must
5653 // win — the missing-load defect is load-bearing (the canonical
5654 // OTP shape requires the new code be resident before any
5655 // cleanup runs), and surfacing the duplicate diagnostic first
5656 // would mask the no-replacement-window defect the ordering
5657 // gate exists to close. Guards the call order in `validate`
5658 // against silent reordering. Same posture as
5659 // `validate_purge_ordering_fires_after_state_change_ordering`
5660 // on the sibling ordering gate.
5661 let e = entry(
5662 "0.1.0",
5663 vec![
5664 UpgradeInstruction::SoftPurge {
5665 module: "x-old".into(),
5666 },
5667 UpgradeInstruction::SoftPurge {
5668 module: "x-old".into(),
5669 },
5670 ],
5671 );
5672 let err = e.validate().unwrap_err();
5673 assert!(
5674 matches!(
5675 err,
5676 UpgradeError::PurgeWithoutPriorLoad {
5677 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5678 ..
5679 }
5680 ),
5681 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5682 );
5683 }
5684
5685 #[test]
5686 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5687 // Order pin: a malformed `:module` value on a `:soft-purge`
5688 // (an empty string) surfaces its narrower kind-tagged
5689 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5690 // singularity gate fires. The per-instruction shape pass walks
5691 // the list inline before the singularity check, so the
5692 // narrower self-locating diagnostic surfaces first — mirrors
5693 // the empty-first cascade on every peer DNS-1123 gate and the
5694 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5695 // the sibling ordering gate.
5696 //
5697 // Two empty-string `:soft-purge` would *otherwise* duplicate
5698 // (both modules are the same empty string), so this pin
5699 // double-locks the precedence: the per-instr shape gate must
5700 // win on the first malformed instruction before the duplicate
5701 // scan even reaches the second.
5702 let e = entry(
5703 "0.1.0",
5704 vec![
5705 UpgradeInstruction::LoadModule { module: "x".into() },
5706 UpgradeInstruction::SoftPurge {
5707 module: String::new(),
5708 },
5709 UpgradeInstruction::SoftPurge {
5710 module: String::new(),
5711 },
5712 ],
5713 );
5714 let err = e.validate().unwrap_err();
5715 assert_eq!(
5716 err,
5717 UpgradeError::ModuleEmpty {
5718 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5719 },
5720 "malformed instruction must surface its kind-tagged diagnostic before the \
5721 cleanup-singularity gate fires, got {err:?}"
5722 );
5723 }
5724
5725 #[test]
5726 fn validate_cleanup_singularity_reports_first_collision() {
5727 // Determinism pin: with three cleanups of the same module the
5728 // gate reports the *first* collision (the second occurrence)
5729 // and stops — the third's duplicate is masked by the first
5730 // surfaced one. Mirrors
5731 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5732 // on the peer cross-entry duplicate axis.
5733 let e = entry(
5734 "0.1.0",
5735 vec![
5736 UpgradeInstruction::LoadModule { module: "x".into() },
5737 UpgradeInstruction::SoftPurge {
5738 module: "x-old".into(),
5739 },
5740 UpgradeInstruction::SoftPurge {
5741 module: "x-old".into(),
5742 },
5743 UpgradeInstruction::Purge {
5744 module: "x-old".into(),
5745 },
5746 ],
5747 );
5748 let err = e.validate().unwrap_err();
5749 assert_eq!(
5750 err,
5751 UpgradeError::DuplicateCleanup {
5752 from: "0.1.0".into(),
5753 module: "x-old".into(),
5754 kinds: vec![
5755 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5756 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5757 ],
5758 },
5759 "the first colliding pair must surface, not the later `:purge` collision"
5760 );
5761 }
5762
5763 #[test]
5764 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
5765 // The whole-list entry-point surfaces the per-entry singularity
5766 // error (mirrors
5767 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
5768 // the gate is reachable from the LayoutInvariants call site,
5769 // not only from a direct `entry.validate()`.
5770 let entries = vec![entry(
5771 "0.1.0",
5772 vec![
5773 UpgradeInstruction::LoadModule { module: "x".into() },
5774 UpgradeInstruction::SoftPurge {
5775 module: "x-old".into(),
5776 },
5777 UpgradeInstruction::Purge {
5778 module: "x-old".into(),
5779 },
5780 ],
5781 )];
5782 let err = validate_upgrade_from(&entries).unwrap_err();
5783 assert!(
5784 matches!(err, UpgradeError::DuplicateCleanup { .. }),
5785 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
5786 );
5787 }
5788
5789 #[test]
5790 fn validate_rejects_duplicate_load_module_for_same_module() {
5791 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
5792 // §II.4): each module is loaded exactly once per upgrade entry,
5793 // the operator's dispatch table reads the module name to bind
5794 // the wasm component, and a second `(:load-module "x")` re-reads
5795 // the same module name and re-binds the same component — a
5796 // no-op the second time. systools-generated `.relup` files emit
5797 // at most one `load_module` per module per upgrade step for
5798 // this reason. Author one `(:load-module "x")` per old module.
5799 let e = entry(
5800 "0.1.0",
5801 vec![
5802 UpgradeInstruction::LoadModule { module: "x".into() },
5803 UpgradeInstruction::LoadModule { module: "x".into() },
5804 ],
5805 );
5806 let err = e.validate().unwrap_err();
5807 assert_eq!(
5808 err,
5809 UpgradeError::DuplicateLoadModule {
5810 from: "0.1.0".into(),
5811 module: "x".into(),
5812 },
5813 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
5814 the module, got {err:?}"
5815 );
5816 }
5817
5818 #[test]
5819 fn validate_accepts_distinct_load_modules() {
5820 // Positive control: `:load-module` instructions on *different*
5821 // modules pass the gate. Mirrors
5822 // `validate_accepts_distinct_cleanup_modules` on the sibling
5823 // singularity axis — the load-singularity gate is keyed on
5824 // (module), so distinct module names render distinct load
5825 // targets and don't collide. Sweep both the bare two-load shape
5826 // and the canonical load-pair-with-cleanup shape so a future
5827 // tighten that over-fires on distinct loads surfaces here.
5828 let two_loads = entry(
5829 "0.1.0",
5830 vec![
5831 UpgradeInstruction::LoadModule { module: "x".into() },
5832 UpgradeInstruction::LoadModule { module: "y".into() },
5833 ],
5834 );
5835 two_loads.validate().unwrap();
5836 let with_cleanup = entry(
5837 "0.1.0",
5838 vec![
5839 UpgradeInstruction::LoadModule { module: "x".into() },
5840 UpgradeInstruction::LoadModule { module: "y".into() },
5841 UpgradeInstruction::SoftPurge {
5842 module: "x-old".into(),
5843 },
5844 UpgradeInstruction::SoftPurge {
5845 module: "y-old".into(),
5846 },
5847 ],
5848 );
5849 with_cleanup.validate().unwrap();
5850 }
5851
5852 #[test]
5853 fn validate_accepts_single_load_per_module() {
5854 // Boundary control: a list with exactly one `:load-module`
5855 // followed by the canonical `:state-change` + `:soft-purge`
5856 // sequence (the module-doc OTP shape) is the gate's identity
5857 // element. Pin so a future off-by-one in the duplicate-
5858 // detection scan doesn't accidentally flag a single occurrence
5859 // as duplicating itself — mirrors
5860 // `validate_accepts_single_cleanup_per_module` on the sibling
5861 // singularity axis.
5862 let e = entry(
5863 "0.1.0",
5864 vec![
5865 UpgradeInstruction::LoadModule { module: "x".into() },
5866 UpgradeInstruction::StateChange {
5867 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5868 },
5869 UpgradeInstruction::SoftPurge {
5870 module: "x-old".into(),
5871 },
5872 ],
5873 );
5874 e.validate().unwrap();
5875 }
5876
5877 #[test]
5878 fn validate_load_singularity_fires_after_state_change_ordering() {
5879 // Diagnostic-precedence pin: an entry like `((:state-change
5880 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
5881 // state-change-without-load and duplicate-load. The more-
5882 // fundamental ordering gate must win — the missing-load defect
5883 // is load-bearing (the migration runs against unloaded code),
5884 // and surfacing the duplicate diagnostic first would mask the
5885 // migrate-into-unloaded-code defect the ordering gate exists
5886 // to close. Guards the call order in `validate` against silent
5887 // reordering. Same posture as
5888 // `validate_cleanup_singularity_fires_after_purge_ordering`
5889 // on the sibling singularity gate.
5890 let e = entry(
5891 "0.1.0",
5892 vec![
5893 UpgradeInstruction::StateChange {
5894 script: PathBuf::from("lib/m.lisp"),
5895 },
5896 UpgradeInstruction::LoadModule { module: "x".into() },
5897 UpgradeInstruction::LoadModule { module: "x".into() },
5898 ],
5899 );
5900 let err = e.validate().unwrap_err();
5901 assert!(
5902 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5903 "state-change-without-load must surface before duplicate-load, got {err:?}"
5904 );
5905 }
5906
5907 #[test]
5908 fn validate_load_singularity_fires_after_purge_ordering() {
5909 // Diagnostic-precedence pin: an entry like `((:soft-purge
5910 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
5911 // purge-without-load and duplicate-load. The more-fundamental
5912 // ordering gate must win — the missing-load defect is load-
5913 // bearing (the cleanup runs against no-replacement-window),
5914 // and surfacing the duplicate diagnostic first would mask the
5915 // drain-to-nothing defect the ordering gate exists to close.
5916 // Sibling of
5917 // `validate_cleanup_singularity_fires_after_purge_ordering` on
5918 // the load-singularity axis.
5919 let e = entry(
5920 "0.1.0",
5921 vec![
5922 UpgradeInstruction::SoftPurge {
5923 module: "x-old".into(),
5924 },
5925 UpgradeInstruction::LoadModule { module: "x".into() },
5926 UpgradeInstruction::LoadModule { module: "x".into() },
5927 ],
5928 );
5929 let err = e.validate().unwrap_err();
5930 assert!(
5931 matches!(
5932 err,
5933 UpgradeError::PurgeWithoutPriorLoad {
5934 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5935 ..
5936 }
5937 ),
5938 "purge-without-load must surface before duplicate-load, got {err:?}"
5939 );
5940 }
5941
5942 #[test]
5943 fn validate_load_singularity_fires_after_per_instr_shape() {
5944 // Order pin: a malformed `:module` value on a `:load-module`
5945 // (an empty string) surfaces its narrower kind-tagged
5946 // `ModuleEmpty` diagnostic *before* the within-entry load-
5947 // singularity gate fires. The per-instruction shape pass walks
5948 // the list inline before the singularity check, so the
5949 // narrower self-locating diagnostic surfaces first — mirrors
5950 // the empty-first cascade on every peer DNS-1123 gate and the
5951 // `validate_cleanup_singularity_fires_after_per_instr_shape`
5952 // pin on the sibling singularity gate.
5953 //
5954 // Two empty-string `:load-module` would *otherwise* duplicate
5955 // (both modules are the same empty string), so this pin
5956 // double-locks the precedence: the per-instr shape gate must
5957 // win on the first malformed instruction before the duplicate
5958 // scan even reaches the second.
5959 let e = entry(
5960 "0.1.0",
5961 vec![
5962 UpgradeInstruction::LoadModule {
5963 module: String::new(),
5964 },
5965 UpgradeInstruction::LoadModule {
5966 module: String::new(),
5967 },
5968 ],
5969 );
5970 let err = e.validate().unwrap_err();
5971 assert_eq!(
5972 err,
5973 UpgradeError::ModuleEmpty {
5974 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5975 },
5976 "malformed instruction must surface its kind-tagged diagnostic before the \
5977 load-singularity gate fires, got {err:?}"
5978 );
5979 }
5980
5981 #[test]
5982 fn validate_load_singularity_fires_before_cleanup_singularity() {
5983 // Diagnostic-precedence pin: an entry that violates *both*
5984 // singularities — duplicate load on "x" *and* duplicate cleanup
5985 // on "y-old" — must surface the load-side diagnostic first.
5986 // The load axis precedes the cleanup axis in the canonical OTP
5987 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
5988 // in [`UpgradeInstruction`] declaration order (LoadModule
5989 // before SoftPurge/Purge), so the load-side singularity is the
5990 // load-bearing diagnostic when both fire — the cleanup-side
5991 // duplicate is meaningless either way without a coherent load.
5992 // Guards the call order in `validate`: `validate_load_singularity`
5993 // runs before `validate_cleanup_singularity`.
5994 let e = entry(
5995 "0.1.0",
5996 vec![
5997 UpgradeInstruction::LoadModule { module: "x".into() },
5998 UpgradeInstruction::LoadModule { module: "x".into() },
5999 UpgradeInstruction::SoftPurge {
6000 module: "y-old".into(),
6001 },
6002 UpgradeInstruction::SoftPurge {
6003 module: "y-old".into(),
6004 },
6005 ],
6006 );
6007 let err = e.validate().unwrap_err();
6008 assert_eq!(
6009 err,
6010 UpgradeError::DuplicateLoadModule {
6011 from: "0.1.0".into(),
6012 module: "x".into(),
6013 },
6014 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6015 );
6016 }
6017
6018 #[test]
6019 fn validate_load_singularity_reports_first_collision() {
6020 // Determinism pin: with three loads of the same module the gate
6021 // reports the *first* collision (the second occurrence) and
6022 // stops — the third's duplicate is masked by the first surfaced
6023 // one. Mirrors
6024 // `validate_cleanup_singularity_reports_first_collision` on the
6025 // sibling singularity axis and every peer duplicate gate's
6026 // first-collision discipline.
6027 let e = entry(
6028 "0.1.0",
6029 vec![
6030 UpgradeInstruction::LoadModule { module: "x".into() },
6031 UpgradeInstruction::LoadModule { module: "x".into() },
6032 UpgradeInstruction::LoadModule { module: "x".into() },
6033 ],
6034 );
6035 let err = e.validate().unwrap_err();
6036 assert_eq!(
6037 err,
6038 UpgradeError::DuplicateLoadModule {
6039 from: "0.1.0".into(),
6040 module: "x".into(),
6041 },
6042 "the first colliding occurrence must surface, not the later third-load collision"
6043 );
6044 }
6045
6046 #[test]
6047 fn validate_load_singularity_threads_through_validate_upgrade_from() {
6048 // The whole-list entry-point surfaces the per-entry singularity
6049 // error (mirrors
6050 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6051 // the gate is reachable from the LayoutInvariants call site,
6052 // not only from a direct `entry.validate()`.
6053 let entries = vec![entry(
6054 "0.1.0",
6055 vec![
6056 UpgradeInstruction::LoadModule { module: "x".into() },
6057 UpgradeInstruction::LoadModule { module: "x".into() },
6058 ],
6059 )];
6060 let err = validate_upgrade_from(&entries).unwrap_err();
6061 assert!(
6062 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6063 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6064 );
6065 }
6066
6067 // ── within-entry state-change-singularity invariant ────────────────
6068
6069 #[test]
6070 fn validate_rejects_duplicate_state_change_for_same_script() {
6071 // `StateChange` is the `gen_server:code_change/3` analog
6072 // (INSPIRATIONS §II.4): the script folds the prior-version
6073 // state shape into the current-version shape — a one-shot
6074 // transition, not a step that composes with itself. OTP's
6075 // release_handler invokes `code_change/3` exactly once per
6076 // upgrade per gen_server; systools-generated `.relup` files
6077 // emit at most one `code_change` per gen_server per upgrade
6078 // step for this reason. A second `(:state-change "m.lisp")`
6079 // re-runs the same fold on the already-migrated state — at
6080 // best a no-op and at worst silent state corruption from
6081 // double-applied non-idempotent transforms (`add column`,
6082 // `increment counter`, `rename field`). Author one
6083 // `(:state-change "m.lisp")` per migration script per entry.
6084 let e = entry(
6085 "0.1.0",
6086 vec![
6087 UpgradeInstruction::LoadModule { module: "x".into() },
6088 UpgradeInstruction::StateChange {
6089 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6090 },
6091 UpgradeInstruction::StateChange {
6092 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6093 },
6094 ],
6095 );
6096 let err = e.validate().unwrap_err();
6097 assert_eq!(
6098 err,
6099 UpgradeError::DuplicateStateChange {
6100 from: "0.1.0".into(),
6101 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6102 },
6103 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6104 the script, got {err:?}"
6105 );
6106 }
6107
6108 #[test]
6109 fn validate_accepts_distinct_state_change_scripts() {
6110 // Positive control: `:state-change` instructions on *different*
6111 // scripts pass the gate. Mirrors
6112 // `validate_accepts_distinct_cleanup_modules` /
6113 // `validate_accepts_distinct_load_modules` on the sibling
6114 // singularity axes — the state-change-singularity gate is keyed
6115 // on the script PathBuf, so distinct scripts render distinct
6116 // migration targets and don't collide. Sweep both the bare two-
6117 // migration shape and the canonical load-pair-with-cleanup shape
6118 // so a future tighten that over-fires on distinct scripts
6119 // surfaces here. This positive control is the gate-level peer of
6120 // `validate_accepts_multiple_state_changes_after_one_load` (the
6121 // ordering-gate positive control on distinct scripts), pinned
6122 // here independently so a future refactor that decouples the
6123 // gates can't accidentally drop coverage on either.
6124 let two_migrations = entry(
6125 "0.1.0",
6126 vec![
6127 UpgradeInstruction::LoadModule { module: "x".into() },
6128 UpgradeInstruction::StateChange {
6129 script: PathBuf::from("lib/m1.lisp"),
6130 },
6131 UpgradeInstruction::StateChange {
6132 script: PathBuf::from("lib/m2.lisp"),
6133 },
6134 ],
6135 );
6136 two_migrations.validate().unwrap();
6137 let with_cleanup = entry(
6138 "0.1.0",
6139 vec![
6140 UpgradeInstruction::LoadModule { module: "x".into() },
6141 UpgradeInstruction::StateChange {
6142 script: PathBuf::from("lib/m1.lisp"),
6143 },
6144 UpgradeInstruction::StateChange {
6145 script: PathBuf::from("lib/m2.lisp"),
6146 },
6147 UpgradeInstruction::SoftPurge {
6148 module: "x-old".into(),
6149 },
6150 ],
6151 );
6152 with_cleanup.validate().unwrap();
6153 }
6154
6155 #[test]
6156 fn validate_accepts_single_state_change_per_script() {
6157 // Boundary control: a list with exactly one `:state-change`
6158 // wrapped by the canonical `:load-module` + `:soft-purge`
6159 // sequence (the module-doc OTP shape) is the gate's identity
6160 // element. Pin so a future off-by-one in the duplicate-
6161 // detection scan doesn't accidentally flag a single occurrence
6162 // as duplicating itself — mirrors
6163 // `validate_accepts_single_load_per_module` /
6164 // `validate_accepts_single_cleanup_per_module` on the sibling
6165 // singularity axes.
6166 let e = entry(
6167 "0.1.0",
6168 vec![
6169 UpgradeInstruction::LoadModule { module: "x".into() },
6170 UpgradeInstruction::StateChange {
6171 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6172 },
6173 UpgradeInstruction::SoftPurge {
6174 module: "x-old".into(),
6175 },
6176 ],
6177 );
6178 e.validate().unwrap();
6179 }
6180
6181 #[test]
6182 fn validate_state_change_singularity_fires_after_state_change_ordering() {
6183 // Diagnostic-precedence pin: an entry like `((:state-change
6184 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6185 // without-load and duplicate-state-change. The more-fundamental
6186 // ordering gate must win — the missing-load defect is load-
6187 // bearing (the migration runs against unloaded code), and
6188 // surfacing the duplicate diagnostic first would mask the
6189 // migrate-into-unloaded-code defect the ordering gate exists to
6190 // close. Guards the call order in `validate` against silent
6191 // reordering. Same posture as
6192 // `validate_load_singularity_fires_after_state_change_ordering`
6193 // on the sibling singularity gate.
6194 //
6195 // Two same-script `:state-change` would *otherwise* duplicate
6196 // (both scripts collide on the very first `:state-change`-
6197 // without-load encountered), so this pin double-locks the
6198 // precedence: the ordering gate must win on the first un-loaded
6199 // `:state-change` before the singularity scan even reaches the
6200 // second.
6201 let e = entry(
6202 "0.1.0",
6203 vec![
6204 UpgradeInstruction::StateChange {
6205 script: PathBuf::from("lib/m.lisp"),
6206 },
6207 UpgradeInstruction::StateChange {
6208 script: PathBuf::from("lib/m.lisp"),
6209 },
6210 ],
6211 );
6212 let err = e.validate().unwrap_err();
6213 assert!(
6214 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6215 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6216 );
6217 }
6218
6219 #[test]
6220 fn validate_state_change_singularity_fires_after_purge_ordering() {
6221 // Diagnostic-precedence pin: an entry like `((:soft-purge
6222 // "x-old") (:load-module "x") (:state-change "m.lisp")
6223 // (:state-change "m.lisp"))` is *both* purge-without-load and
6224 // duplicate-state-change. The more-fundamental ordering gate
6225 // must win — the missing-load defect (a cleanup that drains the
6226 // only resident version to nothing) is load-bearing, and
6227 // surfacing the duplicate diagnostic first would mask the
6228 // drain-to-nothing defect the ordering gate exists to close.
6229 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6230 // on the state-change-singularity axis.
6231 let e = entry(
6232 "0.1.0",
6233 vec![
6234 UpgradeInstruction::SoftPurge {
6235 module: "x-old".into(),
6236 },
6237 UpgradeInstruction::LoadModule { module: "x".into() },
6238 UpgradeInstruction::StateChange {
6239 script: PathBuf::from("lib/m.lisp"),
6240 },
6241 UpgradeInstruction::StateChange {
6242 script: PathBuf::from("lib/m.lisp"),
6243 },
6244 ],
6245 );
6246 let err = e.validate().unwrap_err();
6247 assert!(
6248 matches!(
6249 err,
6250 UpgradeError::PurgeWithoutPriorLoad {
6251 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6252 ..
6253 }
6254 ),
6255 "purge-without-load must surface before duplicate-state-change, got {err:?}"
6256 );
6257 }
6258
6259 #[test]
6260 fn validate_state_change_singularity_fires_after_per_instr_shape() {
6261 // Order pin: a malformed `:script` value on a `:state-change`
6262 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6263 // *before* the within-entry state-change-singularity gate fires.
6264 // The per-instruction shape pass walks the list inline before
6265 // the singularity check, so the narrower self-locating
6266 // diagnostic surfaces first — mirrors the empty-first cascade on
6267 // every peer path-shape gate and the
6268 // `validate_load_singularity_fires_after_per_instr_shape` /
6269 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6270 // pins on the sibling singularity gates.
6271 //
6272 // Two empty-path `:state-change` would *otherwise* duplicate
6273 // (both scripts are the same empty PathBuf), so this pin double-
6274 // locks the precedence: the per-instr shape gate must win on the
6275 // first malformed instruction before the duplicate scan even
6276 // reaches the second.
6277 let e = entry(
6278 "0.1.0",
6279 vec![
6280 UpgradeInstruction::LoadModule { module: "x".into() },
6281 UpgradeInstruction::StateChange {
6282 script: PathBuf::new(),
6283 },
6284 UpgradeInstruction::StateChange {
6285 script: PathBuf::new(),
6286 },
6287 ],
6288 );
6289 let err = e.validate().unwrap_err();
6290 assert_eq!(
6291 err,
6292 UpgradeError::EmptyScript,
6293 "malformed instruction must surface its narrower diagnostic before the \
6294 state-change-singularity gate fires, got {err:?}"
6295 );
6296 }
6297
6298 #[test]
6299 fn validate_state_change_singularity_fires_after_load_singularity() {
6300 // Diagnostic-precedence pin: an entry that violates *both*
6301 // singularities — duplicate load on "x" *and* duplicate
6302 // state-change on "m.lisp" — must surface the load-side
6303 // diagnostic first. The load axis precedes the migration axis
6304 // in the canonical OTP sequence (`code:load_module/1` then
6305 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6306 // declaration order (LoadModule before StateChange), so the
6307 // load-side singularity is the load-bearing diagnostic when
6308 // both fire — the migration-side duplicate is meaningless
6309 // either way without a coherent load. Guards the call order in
6310 // `validate`: `validate_load_singularity` runs before
6311 // `validate_state_change_singularity`.
6312 let e = entry(
6313 "0.1.0",
6314 vec![
6315 UpgradeInstruction::LoadModule { module: "x".into() },
6316 UpgradeInstruction::LoadModule { module: "x".into() },
6317 UpgradeInstruction::StateChange {
6318 script: PathBuf::from("lib/m.lisp"),
6319 },
6320 UpgradeInstruction::StateChange {
6321 script: PathBuf::from("lib/m.lisp"),
6322 },
6323 ],
6324 );
6325 let err = e.validate().unwrap_err();
6326 assert_eq!(
6327 err,
6328 UpgradeError::DuplicateLoadModule {
6329 from: "0.1.0".into(),
6330 module: "x".into(),
6331 },
6332 "duplicate-load must surface before duplicate-state-change, got {err:?}"
6333 );
6334 }
6335
6336 #[test]
6337 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6338 // Diagnostic-precedence pin: an entry that violates *both*
6339 // singularities — duplicate state-change on "m.lisp" *and*
6340 // duplicate cleanup on "y-old" — must surface the migration-
6341 // side diagnostic first. The migration axis precedes the
6342 // cleanup axis in the canonical OTP sequence
6343 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6344 // [`UpgradeInstruction`] declaration order (StateChange before
6345 // SoftPurge/Purge), so the migration-side singularity is the
6346 // load-bearing diagnostic when both fire — the cleanup-side
6347 // duplicate is irrelevant once the migration has corrupted
6348 // state by double-applying. Guards the call order in
6349 // `validate`: `validate_state_change_singularity` runs before
6350 // `validate_cleanup_singularity`.
6351 let e = entry(
6352 "0.1.0",
6353 vec![
6354 UpgradeInstruction::LoadModule { module: "x".into() },
6355 UpgradeInstruction::StateChange {
6356 script: PathBuf::from("lib/m.lisp"),
6357 },
6358 UpgradeInstruction::StateChange {
6359 script: PathBuf::from("lib/m.lisp"),
6360 },
6361 UpgradeInstruction::SoftPurge {
6362 module: "y-old".into(),
6363 },
6364 UpgradeInstruction::SoftPurge {
6365 module: "y-old".into(),
6366 },
6367 ],
6368 );
6369 let err = e.validate().unwrap_err();
6370 assert_eq!(
6371 err,
6372 UpgradeError::DuplicateStateChange {
6373 from: "0.1.0".into(),
6374 script: PathBuf::from("lib/m.lisp"),
6375 },
6376 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6377 );
6378 }
6379
6380 #[test]
6381 fn validate_state_change_singularity_reports_first_collision() {
6382 // Determinism pin: with three state-changes on the same script
6383 // the gate reports the *first* collision (the second
6384 // occurrence) and stops — the third's duplicate is masked by
6385 // the first surfaced one. Mirrors
6386 // `validate_load_singularity_reports_first_collision` /
6387 // `validate_cleanup_singularity_reports_first_collision` on the
6388 // sibling singularity axes and every peer duplicate gate's
6389 // first-collision discipline.
6390 let e = entry(
6391 "0.1.0",
6392 vec![
6393 UpgradeInstruction::LoadModule { module: "x".into() },
6394 UpgradeInstruction::StateChange {
6395 script: PathBuf::from("lib/m.lisp"),
6396 },
6397 UpgradeInstruction::StateChange {
6398 script: PathBuf::from("lib/m.lisp"),
6399 },
6400 UpgradeInstruction::StateChange {
6401 script: PathBuf::from("lib/m.lisp"),
6402 },
6403 ],
6404 );
6405 let err = e.validate().unwrap_err();
6406 assert_eq!(
6407 err,
6408 UpgradeError::DuplicateStateChange {
6409 from: "0.1.0".into(),
6410 script: PathBuf::from("lib/m.lisp"),
6411 },
6412 "the first colliding occurrence must surface, not the later third-migration collision"
6413 );
6414 }
6415
6416 #[test]
6417 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6418 // The whole-list entry-point surfaces the per-entry singularity
6419 // error (mirrors
6420 // `validate_load_singularity_threads_through_validate_upgrade_from`
6421 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6422 // the gate is reachable from the LayoutInvariants call site,
6423 // not only from a direct `entry.validate()`.
6424 let entries = vec![entry(
6425 "0.1.0",
6426 vec![
6427 UpgradeInstruction::LoadModule { module: "x".into() },
6428 UpgradeInstruction::StateChange {
6429 script: PathBuf::from("lib/m.lisp"),
6430 },
6431 UpgradeInstruction::StateChange {
6432 script: PathBuf::from("lib/m.lisp"),
6433 },
6434 ],
6435 )];
6436 let err = validate_upgrade_from(&entries).unwrap_err();
6437 assert!(
6438 matches!(err, UpgradeError::DuplicateStateChange { .. }),
6439 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6440 );
6441 }
6442
6443 #[test]
6444 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6445 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6446 // per-instruction `StateChange`-arm script-path projection must
6447 // route through the sibling lifted
6448 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6449 // accessor, not the raw
6450 // `match instr { UpgradeInstruction::StateChange { script } =>
6451 // script.as_path(), _ => continue }` open-coded pattern-match
6452 // the gate previously carried.
6453 //
6454 // Structurally: the gate's projection accept-set is the union
6455 // of every [`UpgradeInstruction`] variant for which
6456 // `declared_path().is_some()` — today exactly
6457 // [`UpgradeInstruction::StateChange`] per the sibling
6458 // `declared_path_only_for_state_change` pin, so a
6459 // duplicate-scripts input trips `DuplicateStateChange` and a
6460 // non-`StateChange` input (module-bearing / terminal) leaves
6461 // `seen` empty and the gate returns `Ok(())` byte-identical to
6462 // the pattern-match shape.
6463 //
6464 // Byte-equal today (`declared_path` returns `Some(script)` iff
6465 // `StateChange`, byte-for-byte from the variant's own storage);
6466 // the pin catches any future accessor extension that promotes
6467 // an additional variant onto the `PathBuf`-carrying axis — the
6468 // gate then fires on duplicate scripts from that variant too,
6469 // and the singularity discipline the sibling
6470 // `validate_load_singularity` / `validate_cleanup_singularity`
6471 // gates share on the `String`-carrying axis's per-variant
6472 // consumers extends to the promoted variant by construction.
6473 //
6474 // Peer of the sibling four per-`UpgradeInstruction` consumers
6475 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6476 // sandbox-path fan-out, the layout-side per-`StateChange`
6477 // script-existence fan-out at
6478 // `caixa-core/src/layout.rs:1017`, the cross-slot
6479 // [`validate_upgrade_from_against_behavior`] gate's per-
6480 // `StateChange` detection loop, the peer
6481 // [`UpgradeInstruction::declared_module`] `String`-axis
6482 // per-variant unifier) — this gate now shares one typed
6483 // dispatch on the substrate primitive's `PathBuf`-carrying
6484 // axis with those consumers, so a future rebrand on the axis
6485 // migrates as a single caixa-core edit rather than a
6486 // coordinated rewrite of five call sites.
6487 //
6488 // Three-arm projective coverage:
6489 // (a) `StateChange` scripts project through `declared_path()`
6490 // byte-equal to the raw `script.as_path()` field access;
6491 // (b) a duplicate-`StateChange` input trips the gate on the
6492 // second occurrence with `DuplicateStateChange` carrying
6493 // the offending script verbatim;
6494 // (c) a non-`StateChange`-only input (`LoadModule` /
6495 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6496 // vacuous with `Ok(())` — the `declared_path().is_none()`
6497 // arm's `continue` fall-through pins.
6498 //
6499 // Fail-before-pass-after verified locally: swapping the
6500 // production `let Some(script) = instr.declared_path() else {
6501 // continue };` back to `let script = match instr {
6502 // UpgradeInstruction::StateChange { script } =>
6503 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6504 // passing but silently detaches the gate from the accessor's
6505 // typed dispatch — any future `declared_path` extension
6506 // (promotion of an additional variant onto the axis, an
6507 // operator-side pre-resolved-path cache the accessor
6508 // materializes) would then silently disagree between this
6509 // gate's raw pattern-match and the peer four sibling consumers
6510 // that route through the accessor.
6511 use std::path::PathBuf;
6512
6513 // (a) StateChange projection byte-equal via declared_path.
6514 let sc = UpgradeInstruction::StateChange {
6515 script: PathBuf::from("lib/m.lisp"),
6516 };
6517 assert_eq!(
6518 sc.declared_path().map(std::path::PathBuf::as_path),
6519 Some(PathBuf::from("lib/m.lisp").as_path()),
6520 "declared_path() must project the StateChange :script byte-equal to the raw \
6521 field access — accessor divergence would silently detach the gate from the \
6522 projection every peer per-`UpgradeInstruction` consumer routes through"
6523 );
6524
6525 // (b) Duplicate-StateChange input trips the gate.
6526 let dup = entry(
6527 "0.1.0",
6528 vec![
6529 UpgradeInstruction::LoadModule { module: "x".into() },
6530 UpgradeInstruction::StateChange {
6531 script: PathBuf::from("lib/m.lisp"),
6532 },
6533 UpgradeInstruction::StateChange {
6534 script: PathBuf::from("lib/m.lisp"),
6535 },
6536 ],
6537 );
6538 assert_eq!(
6539 dup.validate_state_change_singularity(),
6540 Err(UpgradeError::DuplicateStateChange {
6541 from: "0.1.0".into(),
6542 script: PathBuf::from("lib/m.lisp"),
6543 }),
6544 "duplicate StateChange scripts must trip the gate on the second occurrence \
6545 through the declared_path accessor's Some(script) arm"
6546 );
6547
6548 // (c) Non-StateChange-only inputs leave the gate vacuous.
6549 for instrs in [
6550 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6551 vec![
6552 UpgradeInstruction::LoadModule { module: "x".into() },
6553 UpgradeInstruction::SoftPurge {
6554 module: "x-old".into(),
6555 },
6556 ],
6557 vec![
6558 UpgradeInstruction::LoadModule { module: "x".into() },
6559 UpgradeInstruction::Purge {
6560 module: "x-old".into(),
6561 },
6562 ],
6563 vec![UpgradeInstruction::Restart],
6564 ] {
6565 for instr in &instrs {
6566 assert!(
6567 instr.declared_path().is_none(),
6568 "non-StateChange variants must project None through declared_path — \
6569 accessor divergence would let this gate silently fire on a duplicate \
6570 module reference far from any :state-change site"
6571 );
6572 }
6573 let e = entry("0.1.0", instrs);
6574 assert_eq!(
6575 e.validate_state_change_singularity(),
6576 Ok(()),
6577 "the state-change-singularity gate must return Ok(()) on an entry whose \
6578 instructions all project None through declared_path — the accessor's \
6579 continue arm the pattern-match's `_ => continue` previously carried"
6580 );
6581 }
6582 }
6583
6584 // ── within-entry state-change-before-cleanup ordering invariant ──
6585
6586 #[test]
6587 fn validate_rejects_state_change_after_soft_purge() {
6588 // Fail-before-pass-after pin: `:state-change` is the
6589 // gen_server:code_change/3 analog and folds the prior-version
6590 // state shape into the current shape; `:soft-purge` drains the
6591 // prior code. The operator runs instructions in declared order,
6592 // so a `:soft-purge` ahead of a `:state-change` drains the
6593 // prior module before the migration callback runs against the
6594 // state it held — the canonical OTP error mode
6595 // "`code_change/3` invoked on a purged module" the
6596 // release_handler closes by always ordering the migration
6597 // before the cleanup.
6598 let e = entry(
6599 "0.1.0",
6600 vec![
6601 UpgradeInstruction::LoadModule { module: "x".into() },
6602 UpgradeInstruction::SoftPurge {
6603 module: "x-old".into(),
6604 },
6605 UpgradeInstruction::StateChange {
6606 script: PathBuf::from("lib/m.lisp"),
6607 },
6608 ],
6609 );
6610 let err = e.validate().unwrap_err();
6611 assert_eq!(
6612 err,
6613 UpgradeError::StateChangeAfterCleanup {
6614 from: "0.1.0".into(),
6615 script: PathBuf::from("lib/m.lisp"),
6616 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6617 prior_cleanup_module: "x-old".into(),
6618 },
6619 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6620 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6621 );
6622 }
6623
6624 #[test]
6625 fn validate_rejects_state_change_after_purge() {
6626 // Per-arm coverage: `:purge` (immediate discard, no drain) is
6627 // the more catastrophic peer of `:soft-purge` on the cleanup
6628 // axis; same gate, same shape, the `prior_cleanup_kind` field
6629 // distinguishes the diagnostic so the author can grep their
6630 // caixa.lisp for the offending `(:purge …)` form.
6631 let e = entry(
6632 "0.1.0",
6633 vec![
6634 UpgradeInstruction::LoadModule { module: "x".into() },
6635 UpgradeInstruction::Purge {
6636 module: "x-old".into(),
6637 },
6638 UpgradeInstruction::StateChange {
6639 script: PathBuf::from("lib/m.lisp"),
6640 },
6641 ],
6642 );
6643 let err = e.validate().unwrap_err();
6644 assert_eq!(
6645 err,
6646 UpgradeError::StateChangeAfterCleanup {
6647 from: "0.1.0".into(),
6648 script: PathBuf::from("lib/m.lisp"),
6649 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6650 prior_cleanup_module: "x-old".into(),
6651 },
6652 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6653 `prior_cleanup_kind: \":purge\"`, got {err:?}"
6654 );
6655 }
6656
6657 #[test]
6658 fn validate_accepts_state_change_before_cleanup() {
6659 // Positive control: the canonical `(:load-module …)
6660 // (:state-change …) (:soft-purge …)` order validates — the
6661 // exact shape the module doc example and `validate_accepts_
6662 // well_formed` already pin, restated here on the new gate's
6663 // identity element so a future shortcut that runs the
6664 // singularity gates first doesn't silently mask a regression
6665 // here.
6666 let e = entry(
6667 "0.1.0",
6668 vec![
6669 UpgradeInstruction::LoadModule { module: "x".into() },
6670 UpgradeInstruction::StateChange {
6671 script: PathBuf::from("lib/m.lisp"),
6672 },
6673 UpgradeInstruction::SoftPurge {
6674 module: "x-old".into(),
6675 },
6676 ],
6677 );
6678 e.validate().unwrap();
6679 }
6680
6681 #[test]
6682 fn validate_accepts_cleanup_without_state_change() {
6683 // Empty-set identity: an entry that carries no `:state-change`
6684 // at all has nothing to order against the cleanup, so the gate
6685 // passes regardless of how the cleanups are placed (after the
6686 // single required `:load-module`). Mirrors the
6687 // `validate_accepts_multiple_purges_after_one_load` positive
6688 // control on the peer purge-ordering gate; metadata-only
6689 // upgrades with cleanup-but-no-migration land here.
6690 let e = entry(
6691 "0.1.0",
6692 vec![
6693 UpgradeInstruction::LoadModule { module: "x".into() },
6694 UpgradeInstruction::SoftPurge {
6695 module: "x-old".into(),
6696 },
6697 UpgradeInstruction::Purge {
6698 module: "x-oldest".into(),
6699 },
6700 ],
6701 );
6702 e.validate().unwrap();
6703 }
6704
6705 #[test]
6706 fn validate_accepts_state_change_without_cleanup() {
6707 // Empty-set identity on the dual axis: an entry that carries no
6708 // cleanup at all has nothing to order against the state-change,
6709 // so the gate passes — additive-upgrade shapes (load new code,
6710 // migrate state, leave old code resident for in-flight callers
6711 // to drain naturally) land here.
6712 let e = entry(
6713 "0.1.0",
6714 vec![
6715 UpgradeInstruction::LoadModule { module: "x".into() },
6716 UpgradeInstruction::StateChange {
6717 script: PathBuf::from("lib/m.lisp"),
6718 },
6719 ],
6720 );
6721 e.validate().unwrap();
6722 }
6723
6724 #[test]
6725 fn validate_accepts_multiple_state_changes_before_cleanup() {
6726 // Coverage: every state-change must precede every cleanup, not
6727 // just the first. A chain `(load) (sc) (sc) (sp)` is the
6728 // canonical "two distinct migration scripts on a chained
6729 // upgrade" shape (one module's schema *and* another's
6730 // projection per the DuplicateStateChange diagnostic), and
6731 // it must pass when each state-change has distinct script
6732 // paths. Pinned here so a future shortcut that only checks
6733 // the first state-change doesn't silently accept a
6734 // `(load) (sc-1) (sp) (sc-2)` regression.
6735 let e = entry(
6736 "0.1.0",
6737 vec![
6738 UpgradeInstruction::LoadModule { module: "x".into() },
6739 UpgradeInstruction::StateChange {
6740 script: PathBuf::from("lib/m1.lisp"),
6741 },
6742 UpgradeInstruction::StateChange {
6743 script: PathBuf::from("lib/m2.lisp"),
6744 },
6745 UpgradeInstruction::SoftPurge {
6746 module: "x-old".into(),
6747 },
6748 ],
6749 );
6750 e.validate().unwrap();
6751 }
6752
6753 #[test]
6754 fn validate_rejects_state_change_sandwiched_between_cleanups() {
6755 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
6756 // (sp-2)` violates the gate because the state-change runs
6757 // after the first cleanup. The reported `prior_cleanup_*`
6758 // names the *first* cleanup (the load-bearing one), not the
6759 // last — mirrors every peer first-collision diagnostic
6760 // posture on this module (`validate_state_change_ordering`,
6761 // `validate_purge_ordering`, `validate_load_singularity`,
6762 // `validate_state_change_singularity`,
6763 // `validate_cleanup_singularity` all report the first
6764 // colliding instruction, not the last).
6765 let e = entry(
6766 "0.1.0",
6767 vec![
6768 UpgradeInstruction::LoadModule { module: "x".into() },
6769 UpgradeInstruction::SoftPurge {
6770 module: "x-old".into(),
6771 },
6772 UpgradeInstruction::StateChange {
6773 script: PathBuf::from("lib/m.lisp"),
6774 },
6775 UpgradeInstruction::Purge {
6776 module: "y-old".into(),
6777 },
6778 ],
6779 );
6780 let err = e.validate().unwrap_err();
6781 assert_eq!(
6782 err,
6783 UpgradeError::StateChangeAfterCleanup {
6784 from: "0.1.0".into(),
6785 script: PathBuf::from("lib/m.lisp"),
6786 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6787 prior_cleanup_module: "x-old".into(),
6788 },
6789 "the first cleanup the state-change follows must surface (not the trailing one), \
6790 got {err:?}"
6791 );
6792 }
6793
6794 #[test]
6795 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
6796 // Diagnostic-precedence pin: an entry like `((:soft-purge
6797 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
6798 // *both* purge-without-load (the cleanup runs before the
6799 // load) and state-change-after-cleanup (the state-change
6800 // runs after the cleanup). The more-fundamental ordering
6801 // gate must win — the missing-load defect (a cleanup that
6802 // drains the only resident version to nothing) is load-
6803 // bearing, and surfacing the state-change-after-cleanup
6804 // diagnostic first would mask the drain-to-nothing defect
6805 // the peer purge-ordering gate exists to close. Guards the
6806 // call order in `validate` against silent reordering. Same
6807 // posture as `validate_purge_ordering_fires_after_state_
6808 // change_ordering` on the sibling ordering gate.
6809 //
6810 // Pin specifically uses the load-after-cleanup shape (rather
6811 // than load-less) so the state-change-ordering gate (which
6812 // would otherwise fire first on a `((:soft-purge …)
6813 // (:state-change …))` shape with no leading load) is
6814 // sidestepped: with the load present after the cleanup,
6815 // state-change-ordering passes (its `loaded` latch is set
6816 // before the state-change is encountered) but purge-ordering
6817 // still fails (the cleanup precedes the load). That isolates
6818 // the precedence between purge-ordering and this gate
6819 // cleanly.
6820 let e = entry(
6821 "0.1.0",
6822 vec![
6823 UpgradeInstruction::SoftPurge {
6824 module: "x-old".into(),
6825 },
6826 UpgradeInstruction::LoadModule { module: "x".into() },
6827 UpgradeInstruction::StateChange {
6828 script: PathBuf::from("lib/m.lisp"),
6829 },
6830 ],
6831 );
6832 let err = e.validate().unwrap_err();
6833 assert!(
6834 matches!(
6835 err,
6836 UpgradeError::PurgeWithoutPriorLoad {
6837 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6838 ..
6839 }
6840 ),
6841 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
6842 );
6843 }
6844
6845 #[test]
6846 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
6847 // Diagnostic-precedence pin: an entry like `((:state-change
6848 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
6849 // load (because no `:load-module` precedes the state-change)
6850 // but *not* state-change-after-cleanup (the state-change
6851 // precedes the cleanup textually). The state-change-ordering
6852 // gate must surface first regardless — the missing-load
6853 // defect on the migration axis is the load-bearing semantic
6854 // and surfacing a different ordering diagnostic would mask
6855 // the migration-against-stale-code defect. Guards the call
6856 // order in `validate` against silent reordering on a shape
6857 // that fires only the state-change-ordering gate (not this
6858 // one), pinning that the state-change-ordering gate wins
6859 // ahead of this gate's chance to look at the list.
6860 let e = entry(
6861 "0.1.0",
6862 vec![
6863 UpgradeInstruction::StateChange {
6864 script: PathBuf::from("lib/m.lisp"),
6865 },
6866 UpgradeInstruction::SoftPurge {
6867 module: "x-old".into(),
6868 },
6869 ],
6870 );
6871 let err = e.validate().unwrap_err();
6872 assert!(
6873 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6874 "state-change-without-load must surface before purge-without-load (the canonical \
6875 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
6876 );
6877 }
6878
6879 #[test]
6880 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
6881 // Order pin: a malformed `:script` value on a `:state-change`
6882 // (an empty path) surfaces its narrower `EmptyScript`
6883 // diagnostic *before* the within-entry state-change-before-
6884 // cleanup gate fires. The per-instruction shape pass walks
6885 // the list inline before the ordering check, so the narrower
6886 // self-locating diagnostic surfaces first — mirrors the
6887 // empty-first cascade on every peer path-shape gate and the
6888 // `validate_purge_ordering_fires_after_per_instr_shape` pin
6889 // on the sibling ordering gate.
6890 let e = entry(
6891 "0.1.0",
6892 vec![
6893 UpgradeInstruction::LoadModule { module: "x".into() },
6894 UpgradeInstruction::SoftPurge {
6895 module: "x-old".into(),
6896 },
6897 UpgradeInstruction::StateChange {
6898 script: PathBuf::new(),
6899 },
6900 ],
6901 );
6902 let err = e.validate().unwrap_err();
6903 assert_eq!(
6904 err,
6905 UpgradeError::EmptyScript,
6906 "malformed instruction must surface its narrower diagnostic before the \
6907 state-change-before-cleanup gate fires, got {err:?}"
6908 );
6909 }
6910
6911 #[test]
6912 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
6913 // Diagnostic-precedence pin: an entry like `((:load-module
6914 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
6915 // (:state-change "m.lisp"))` violates *both* this ordering
6916 // gate (the first state-change follows the cleanup) and the
6917 // state-change-singularity gate (the same script appears
6918 // twice). The ordering gate must win — the canonical
6919 // "ordering before singularity" precedence the peer
6920 // `validate_state_change_ordering` / `validate_purge_
6921 // ordering` gates already establish over their own singularity
6922 // gates, applied uniformly across the OTP canonical-sequence
6923 // ordering axis here. Guards the call order in `validate`:
6924 // `validate_state_change_before_cleanup` runs before the
6925 // per-instruction-class singularity gates.
6926 let e = entry(
6927 "0.1.0",
6928 vec![
6929 UpgradeInstruction::LoadModule { module: "x".into() },
6930 UpgradeInstruction::SoftPurge {
6931 module: "x-old".into(),
6932 },
6933 UpgradeInstruction::StateChange {
6934 script: PathBuf::from("lib/m.lisp"),
6935 },
6936 UpgradeInstruction::StateChange {
6937 script: PathBuf::from("lib/m.lisp"),
6938 },
6939 ],
6940 );
6941 let err = e.validate().unwrap_err();
6942 assert!(
6943 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6944 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
6945 );
6946 }
6947
6948 #[test]
6949 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
6950 // The whole-list entry-point surfaces the per-entry ordering
6951 // error (mirrors `validate_purge_ordering_threads_through_
6952 // validate_upgrade_from` and every peer wiring pin): the gate
6953 // is reachable from the LayoutInvariants call site, not only
6954 // from a direct `entry.validate()`.
6955 let entries = vec![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::from("lib/m.lisp"),
6964 },
6965 ],
6966 )];
6967 let err = validate_upgrade_from(&entries).unwrap_err();
6968 assert!(
6969 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6970 "validate_upgrade_from must thread the state-change-before-cleanup error, \
6971 got {err:?}"
6972 );
6973 }
6974
6975 #[test]
6976 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
6977 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
6978 // per-instruction `StateChange`-arm script-path projection must
6979 // route through the sibling lifted
6980 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6981 // accessor, not the raw
6982 // `if let UpgradeInstruction::StateChange { script } = instr`
6983 // open-coded pattern-match the gate previously carried inside
6984 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
6985 //
6986 // Structurally: the gate's projection accept-set is the union
6987 // of every [`UpgradeInstruction`] variant for which
6988 // `declared_path().is_some()` — today exactly
6989 // [`UpgradeInstruction::StateChange`] per the sibling
6990 // `declared_path_only_for_state_change` pin, so a
6991 // state-change-after-cleanup input trips
6992 // `StateChangeAfterCleanup` and a non-`StateChange` input
6993 // (module-bearing / terminal) leaves the sticky-once latch
6994 // sweep quiet byte-identical to the pattern-match shape.
6995 //
6996 // Byte-equal today (`declared_path` returns `Some(script)` iff
6997 // `StateChange`, byte-for-byte from the variant's own storage);
6998 // the pin catches any future accessor extension that promotes
6999 // an additional variant onto the `PathBuf`-carrying axis — the
7000 // gate then fires on migrate-after-cleanup for that variant too,
7001 // and the migrate→cleanup ordering discipline the peer
7002 // [`validate_state_change_singularity`] /
7003 // [`validate_upgrade_from_against_behavior`] gates share on the
7004 // same axis extends to the promoted variant by construction.
7005 //
7006 // Peer of the sibling four per-`UpgradeInstruction` consumers
7007 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7008 // sandbox-path fan-out, the layout-side per-`StateChange`
7009 // script-existence fan-out at
7010 // `caixa-core/src/layout.rs:1058`, the within-entry
7011 // [`UpgradeFromEntry::validate_state_change_singularity`]
7012 // per-`StateChange` script-projection fan-out, the cross-slot
7013 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7014 // detection loop) — the fifth (and last unlifted inside
7015 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7016 // the `PathBuf`-carrying axis to now route through the accessor.
7017 // Same shape as the sibling
7018 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7019 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7020 // pins extended onto the within-entry migrate→cleanup ordering
7021 // gate.
7022 //
7023 // Three-arm projective coverage:
7024 // (a) `StateChange` scripts project through `declared_path()`
7025 // byte-equal to the raw `script.clone()` field access
7026 // the diagnostic previously carried;
7027 // (b) a `:state-change`-after-cleanup input trips the gate
7028 // with `StateChangeAfterCleanup` carrying the offending
7029 // script + the prior cleanup's kind/module verbatim;
7030 // (c) a non-`StateChange`-only input (`LoadModule` /
7031 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7032 // vacuous with `Ok(())` — the `declared_path().is_none()`
7033 // arm's fall-through pins.
7034 //
7035 // Fail-before-pass-after verified structurally: swapping the
7036 // production
7037 // `else if let Some(script) = instr.declared_path() && … { … }`
7038 // back to
7039 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7040 // keeps arms (a)-(c) passing but silently detaches this within-
7041 // entry ordering gate from the accessor's typed dispatch — any
7042 // future `declared_path` extension (promotion of an additional
7043 // variant onto the axis, an operator-side pre-resolved-path
7044 // cache the accessor materializes) would then silently disagree
7045 // between this gate's raw pattern-match and the peer four
7046 // sibling consumers that route through the accessor.
7047
7048 // (a) StateChange projection byte-equal via declared_path.
7049 let sc = UpgradeInstruction::StateChange {
7050 script: PathBuf::from("lib/m.lisp"),
7051 };
7052 assert_eq!(
7053 sc.declared_path().cloned(),
7054 Some(PathBuf::from("lib/m.lisp")),
7055 "declared_path() must project the StateChange :script byte-equal to the raw \
7056 field access — accessor divergence would silently detach this within-entry \
7057 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7058 consumer routes through"
7059 );
7060
7061 // (b) StateChange-after-cleanup trips the gate through the accessor.
7062 let after = entry(
7063 "0.1.0",
7064 vec![
7065 UpgradeInstruction::LoadModule { module: "x".into() },
7066 UpgradeInstruction::SoftPurge {
7067 module: "x-old".into(),
7068 },
7069 UpgradeInstruction::StateChange {
7070 script: PathBuf::from("lib/m.lisp"),
7071 },
7072 ],
7073 );
7074 assert_eq!(
7075 after.validate(),
7076 Err(UpgradeError::StateChangeAfterCleanup {
7077 from: "0.1.0".into(),
7078 script: PathBuf::from("lib/m.lisp"),
7079 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7080 prior_cleanup_module: "x-old".into(),
7081 }),
7082 "a :state-change following a cleanup must trip the gate through the declared_path \
7083 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7084 kind/module verbatim byte-identical to the pattern-match shape"
7085 );
7086
7087 // (c) Non-StateChange-only inputs leave the gate vacuous.
7088 for instrs in [
7089 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7090 vec![
7091 UpgradeInstruction::LoadModule { module: "x".into() },
7092 UpgradeInstruction::SoftPurge {
7093 module: "x-old".into(),
7094 },
7095 ],
7096 vec![
7097 UpgradeInstruction::LoadModule { module: "x".into() },
7098 UpgradeInstruction::Purge {
7099 module: "x-old".into(),
7100 },
7101 ],
7102 vec![UpgradeInstruction::Restart],
7103 ] {
7104 for instr in &instrs {
7105 assert!(
7106 instr.declared_path().is_none(),
7107 "non-StateChange variants must project None through declared_path — \
7108 accessor divergence would let this within-entry ordering gate silently \
7109 fire on a cleanup-only sequence far from any :state-change site"
7110 );
7111 }
7112 let e = entry("0.1.0", instrs);
7113 assert_eq!(
7114 e.validate(),
7115 Ok(()),
7116 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7117 instructions all project None through declared_path — the accessor's \
7118 None arm the pattern-match's implicit fall-through previously carried"
7119 );
7120 }
7121 }
7122
7123 #[test]
7124 fn validate_restart_order_independent() {
7125 // Position-agnostic: `(:restart)` leading or trailing the
7126 // mixed sequence surfaces the same RestartNotExclusive shape.
7127 // Mirrors OTP appup's order-insensitive
7128 // `restart_emulator | restart_new_emulator` terminal rule —
7129 // the position of the restart instruction in the script is
7130 // irrelevant; what matters is the script *contains* it
7131 // alongside other instructions at all. The gate must not
7132 // gain a false positive by depending on instruction ordering.
7133 let leading = entry(
7134 "0.1.0",
7135 vec![
7136 UpgradeInstruction::Restart,
7137 UpgradeInstruction::LoadModule { module: "x".into() },
7138 ],
7139 );
7140 let trailing = entry(
7141 "0.1.0",
7142 vec![
7143 UpgradeInstruction::LoadModule { module: "x".into() },
7144 UpgradeInstruction::Restart,
7145 ],
7146 );
7147 let middle = entry(
7148 "0.1.0",
7149 vec![
7150 UpgradeInstruction::LoadModule { module: "a".into() },
7151 UpgradeInstruction::Restart,
7152 UpgradeInstruction::SoftPurge {
7153 module: "a-old".into(),
7154 },
7155 ],
7156 );
7157 for e in [&leading, &trailing, &middle] {
7158 assert!(
7159 matches!(
7160 e.validate().unwrap_err(),
7161 UpgradeError::RestartNotExclusive {
7162 restart_count: 1,
7163 ..
7164 }
7165 ),
7166 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7167 instruction order, got {:?}",
7168 e.validate()
7169 );
7170 }
7171 }
7172
7173 #[test]
7174 fn validate_restart_exclusive_fires_after_per_instr_shape() {
7175 // Order pin: a malformed `:module` value on a Module-bearing
7176 // instruction (an empty string) surfaces its narrower
7177 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7178 // entry restart-exclusivity gate fires. The per-instruction
7179 // shape pass walks the list inline before the restart-
7180 // exclusive check, so the narrower self-locating diagnostic
7181 // surfaces first — mirrors the empty-first cascade on every
7182 // peer DNS-1123 gate (`validate_module`,
7183 // `validate_membro_caixa`, `validate_placement_cluster`) and
7184 // the `*_invalid_fires_before_duplicate_check` arm-ordering
7185 // pins on every typed-graph axis. Without this pin a future
7186 // shortcut that runs the restart-exclusive check ahead of
7187 // per-instruction shape would surface a less-actionable
7188 // RestartNotExclusive over an instruction list that's also
7189 // malformed at the per-instruction layer.
7190 let e = entry(
7191 "0.1.0",
7192 vec![
7193 UpgradeInstruction::LoadModule {
7194 module: String::new(),
7195 },
7196 UpgradeInstruction::Restart,
7197 ],
7198 );
7199 let err = e.validate().unwrap_err();
7200 assert_eq!(
7201 err,
7202 UpgradeError::ModuleEmpty {
7203 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7204 },
7205 "malformed instruction must surface its kind-tagged diagnostic before the \
7206 restart-exclusivity gate fires, got {err:?}"
7207 );
7208 }
7209
7210 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7211 // Helper for the cross-slot composition gate's pass arm: a
7212 // BehaviorSpec carrying just the `:on-state-change` callback,
7213 // the runtime hook the per-version `(:state-change "…")`
7214 // instruction is delivered through during hot upgrade. Mirrors
7215 // the canonical authoring shape pinned in the module doc.
7216 crate::BehaviorSpec {
7217 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7218 ..Default::default()
7219 }
7220 }
7221
7222 #[test]
7223 fn behavior_gate_rejects_state_change_without_any_behavior() {
7224 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7225 // caixa carries no `:behavior` at all surfaces the missing-
7226 // callback diagnostic naming the offending entry's `:from` +
7227 // script. The "I added the upgrade path but never declared
7228 // `:behavior`" footgun: `:behavior` is optional at the typed
7229 // root, the typed `:upgrade-from` slot validates on its own
7230 // merits, and the operator's hot-upgrade dispatch reaches for
7231 // a callback that doesn't exist.
7232 let entries = vec![entry(
7233 "0.1.0",
7234 vec![
7235 UpgradeInstruction::LoadModule { module: "x".into() },
7236 UpgradeInstruction::StateChange {
7237 script: PathBuf::from("lib/m.lisp"),
7238 },
7239 ],
7240 )];
7241 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7242 assert_eq!(
7243 err,
7244 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7245 from: "0.1.0".into(),
7246 script: PathBuf::from("lib/m.lisp"),
7247 },
7248 );
7249 }
7250
7251 #[test]
7252 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7253 // `:behavior` declared with *other* callbacks set
7254 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7255 // None still surfaces the missing-callback diagnostic — only
7256 // the `:on-state-change` axis matters for this gate. The
7257 // "I declared `:behavior` but missed the migration callback"
7258 // footgun: a caixa that registers its lifecycle hooks but
7259 // forgets the migration delivery path leaves the
7260 // `:state-change` instruction with no runtime hook to
7261 // dispatch through.
7262 let entries = vec![entry(
7263 "0.1.0",
7264 vec![
7265 UpgradeInstruction::LoadModule { module: "x".into() },
7266 UpgradeInstruction::StateChange {
7267 script: PathBuf::from("lib/m.lisp"),
7268 },
7269 ],
7270 )];
7271 let b = crate::BehaviorSpec {
7272 on_init: Some(PathBuf::from("lib/init.lisp")),
7273 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7274 ..Default::default()
7275 };
7276 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7277 assert_eq!(
7278 err,
7279 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7280 from: "0.1.0".into(),
7281 script: PathBuf::from("lib/m.lisp"),
7282 },
7283 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7284 the missing migration hook"
7285 );
7286 }
7287
7288 #[test]
7289 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7290 // The canonical composition shape: a per-version
7291 // `(:state-change "lib/m.lisp")` instruction paired with the
7292 // `:behavior :on-state-change "lib/migrations.lisp"` callback
7293 // it is delivered through at hot-upgrade time. Pins the gate's
7294 // pass arm — drift here = a future tighten that rejects the
7295 // canonical OTP-shape composition surfaces as a regression at
7296 // this positive-control pin.
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 b = behavior_with_state_change_callback();
7307 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7308 }
7309
7310 #[test]
7311 fn behavior_gate_accepts_entries_without_any_state_change() {
7312 // Empty-set identity: entries carrying no `:state-change`
7313 // instruction at all (load + cleanup only — the metadata-only
7314 // upgrade shape the module doc names, "On any failure, the
7315 // current version stays load-bearing — a typed atomic
7316 // upgrade") leave the gate vacuous. The composition only
7317 // requires a callback when the per-version script exists; a
7318 // load + cleanup pair has no migration to deliver, so the
7319 // absence of `:on-state-change` is coherent.
7320 let entries = vec![entry(
7321 "0.1.0",
7322 vec![
7323 UpgradeInstruction::LoadModule { module: "x".into() },
7324 UpgradeInstruction::SoftPurge {
7325 module: "x-old".into(),
7326 },
7327 ],
7328 )];
7329 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7330 }
7331
7332 #[test]
7333 fn behavior_gate_accepts_restart_only_entry() {
7334 // The terminal-fallback `((:restart))` shape carries no
7335 // `:state-change` — the operator restarts the pod and the
7336 // new version comes up fresh against its initial state, no
7337 // migration. Pinned alongside the metadata-only positive
7338 // control above as the second empty-state-change shape.
7339 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7340 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7341 }
7342
7343 #[test]
7344 fn behavior_gate_accepts_empty_entries_list() {
7345 // Empty `:upgrade-from` (a caixa with no declared upgrade
7346 // paths — the v0.1.0 caixa before any upgrade entries are
7347 // added) trivially passes the gate. Pinned so the gate
7348 // doesn't accidentally fire on a caixa that hasn't yet
7349 // declared any upgrades.
7350 let entries: Vec<UpgradeFromEntry> = vec![];
7351 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7352 }
7353
7354 #[test]
7355 fn behavior_gate_reports_first_state_change_in_first_entry() {
7356 // First-collision determinism: with multiple `:state-change`
7357 // instructions across multiple entries, the gate reports the
7358 // *first* one encountered in declaration order — the entry's
7359 // declaration order first, then the within-entry instruction
7360 // order. Mirrors every peer first-collision diagnostic posture
7361 // on this module (`validate_state_change_ordering`,
7362 // `validate_purge_ordering`, the singularity gates), so a
7363 // future shortcut that walks the list in reverse or returns
7364 // the last collision surfaces as a regression here.
7365 let entries = vec![
7366 entry(
7367 "0.1.0",
7368 vec![
7369 UpgradeInstruction::LoadModule { module: "x".into() },
7370 UpgradeInstruction::StateChange {
7371 script: PathBuf::from("lib/m1.lisp"),
7372 },
7373 UpgradeInstruction::StateChange {
7374 script: PathBuf::from("lib/m2.lisp"),
7375 },
7376 ],
7377 ),
7378 entry(
7379 "0.1.5",
7380 vec![
7381 UpgradeInstruction::LoadModule { module: "x".into() },
7382 UpgradeInstruction::StateChange {
7383 script: PathBuf::from("lib/m3.lisp"),
7384 },
7385 ],
7386 ),
7387 ];
7388 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7389 assert_eq!(
7390 err,
7391 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7392 from: "0.1.0".into(),
7393 script: PathBuf::from("lib/m1.lisp"),
7394 },
7395 "the first :state-change in the first entry must surface, not later collisions"
7396 );
7397 }
7398
7399 #[test]
7400 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7401 // Cross-entry pin: a first entry with no `:state-change` (just
7402 // a load + cleanup) leaves the gate's per-entry walk continuing
7403 // to the second entry, where the offending instruction lives.
7404 // The diagnostic names the *second* entry's `:from` because
7405 // that's where the missing-callback shape is exposed — pinned
7406 // so a shortcut that bails on the first entry without a
7407 // `:state-change` (rather than continuing) doesn't mask the
7408 // defect in a later entry.
7409 let entries = vec![
7410 entry(
7411 "0.1.0",
7412 vec![
7413 UpgradeInstruction::LoadModule { module: "x".into() },
7414 UpgradeInstruction::SoftPurge {
7415 module: "x-old".into(),
7416 },
7417 ],
7418 ),
7419 entry(
7420 "0.1.5",
7421 vec![
7422 UpgradeInstruction::LoadModule { module: "x".into() },
7423 UpgradeInstruction::StateChange {
7424 script: PathBuf::from("lib/m.lisp"),
7425 },
7426 ],
7427 ),
7428 ];
7429 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7430 assert_eq!(
7431 err,
7432 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7433 from: "0.1.5".into(),
7434 script: PathBuf::from("lib/m.lisp"),
7435 },
7436 "the offending entry's `:from` must surface even when an earlier entry carries no \
7437 :state-change"
7438 );
7439 }
7440
7441 #[test]
7442 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7443 // Positive control: a multi-entry `:upgrade-from` (chained
7444 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7445 // a `:state-change` passes when the callback is declared once
7446 // at the caixa root. The callback is a single per-caixa
7447 // runtime hook; one declaration covers every entry's
7448 // `:state-change`, mirroring OTP's
7449 // `release_handler:install_release/1` which dispatches every
7450 // appup's `code_change` instruction through the single
7451 // `gen_server:code_change/3` callback registered on the
7452 // module.
7453 let entries = vec![
7454 entry(
7455 "0.1.0",
7456 vec![
7457 UpgradeInstruction::LoadModule { module: "x".into() },
7458 UpgradeInstruction::StateChange {
7459 script: PathBuf::from("lib/m1.lisp"),
7460 },
7461 ],
7462 ),
7463 entry(
7464 "0.1.5",
7465 vec![
7466 UpgradeInstruction::LoadModule { module: "x".into() },
7467 UpgradeInstruction::StateChange {
7468 script: PathBuf::from("lib/m2.lisp"),
7469 },
7470 ],
7471 ),
7472 ];
7473 let b = behavior_with_state_change_callback();
7474 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7475 }
7476
7477 #[test]
7478 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7479 // Symmetry pin: the gate's pass arm doesn't depend on the
7480 // entry actually carrying a `:state-change` — if no
7481 // `:state-change` is declared, the gate is vacuous regardless
7482 // of the callback (an `:on-state-change` declared without a
7483 // matching per-version script is fine, the callback is the
7484 // runtime default for any *future* migration the author hasn't
7485 // yet added). Pins that a caixa author can declare the
7486 // callback ahead of any migration without the gate
7487 // complaining.
7488 let entries = vec![entry(
7489 "0.1.0",
7490 vec![
7491 UpgradeInstruction::LoadModule { module: "x".into() },
7492 UpgradeInstruction::SoftPurge {
7493 module: "x-old".into(),
7494 },
7495 ],
7496 )];
7497 let b = behavior_with_state_change_callback();
7498 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7499 }
7500
7501 #[test]
7502 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7503 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7504 // per-instruction `StateChange`-arm script-path projection must
7505 // route through the sibling lifted
7506 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7507 // accessor, not the raw
7508 // `if let UpgradeInstruction::StateChange { script } = instr`
7509 // open-coded pattern-match the cross-slot gate previously
7510 // carried at caixa-core/src/upgrade.rs:1365.
7511 //
7512 // Structurally: the gate's projection accept-set is the union
7513 // of every [`UpgradeInstruction`] variant for which
7514 // `declared_path().is_some()` — today exactly
7515 // [`UpgradeInstruction::StateChange`] per the sibling
7516 // `declared_path_only_for_state_change` pin, so a
7517 // `:state-change`-carrying entry without an `:on-state-change`
7518 // callback trips `StateChangeWithoutOnStateChangeCallback` and
7519 // a non-`StateChange` entry (load-only / cleanup-only /
7520 // restart-only / empty-`:instructions`) leaves the per-entry
7521 // walk continuing past every non-projecting instruction
7522 // byte-identical to the pattern-match shape.
7523 //
7524 // Byte-equal today (`declared_path` returns `Some(script)` iff
7525 // `StateChange`, byte-for-byte from the variant's own storage);
7526 // the pin catches any future accessor extension that promotes
7527 // an additional variant onto the `PathBuf`-carrying axis — the
7528 // gate then fires on scripts from that variant too, and the
7529 // cross-slot composition discipline the sibling per-
7530 // `UpgradeInstruction` consumers share on the `PathBuf`-
7531 // carrying axis extends to the promoted variant by
7532 // construction.
7533 //
7534 // Peer of the sibling four per-`UpgradeInstruction` consumers
7535 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7536 // sandbox-path fan-out, the layout-side per-`StateChange`
7537 // script-existence fan-out at
7538 // `caixa-core/src/layout.rs:1058`, the within-entry
7539 // [`UpgradeFromEntry::validate_state_change_singularity`]
7540 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7541 // peer [`UpgradeInstruction::declared_module`] `String`-axis
7542 // per-variant unifier) — the fourth (and last) per-
7543 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7544 // to now route through the accessor. Same shape as the
7545 // sibling
7546 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7547 // pin extended onto the cross-slot composition gate.
7548 //
7549 // Three-arm projective coverage:
7550 // (a) `StateChange` scripts project through `declared_path()`
7551 // byte-equal to the raw `script.clone()` field access
7552 // the diagnostic previously carried;
7553 // (b) a `:state-change`-carrying entry with `behavior: None`
7554 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
7555 // carrying the offending script verbatim;
7556 // (c) a non-`StateChange`-only entry (`LoadModule` /
7557 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7558 // vacuous with `Ok(())` — the `declared_path().is_none()`
7559 // arm's fall-through pins.
7560 //
7561 // Fail-before-pass-after verified structurally: swapping the
7562 // production
7563 // `if let Some(script) = instr.declared_path() { … }`
7564 // back to
7565 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
7566 // keeps arms (a)-(c) passing but silently detaches the gate
7567 // from the accessor's typed dispatch — any future
7568 // `declared_path` extension (promotion of an additional
7569 // variant onto the axis, an operator-side pre-resolved-path
7570 // cache the accessor materializes) would then silently
7571 // disagree between this cross-slot gate's raw pattern-match
7572 // and the peer four sibling consumers that route through the
7573 // accessor.
7574
7575 // (a) StateChange projection byte-equal via declared_path.
7576 let sc = UpgradeInstruction::StateChange {
7577 script: PathBuf::from("lib/m.lisp"),
7578 };
7579 assert_eq!(
7580 sc.declared_path().cloned(),
7581 Some(PathBuf::from("lib/m.lisp")),
7582 "declared_path() must project the StateChange :script byte-equal to the raw \
7583 field access — accessor divergence would silently detach this cross-slot \
7584 composition gate from the projection every peer per-`UpgradeInstruction` \
7585 consumer routes through"
7586 );
7587
7588 // (b) StateChange-carrying entry with behavior: None trips gate.
7589 let entries = vec![entry(
7590 "0.1.0",
7591 vec![
7592 UpgradeInstruction::LoadModule { module: "x".into() },
7593 UpgradeInstruction::StateChange {
7594 script: PathBuf::from("lib/m.lisp"),
7595 },
7596 ],
7597 )];
7598 assert_eq!(
7599 validate_upgrade_from_against_behavior(&entries, None),
7600 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7601 from: "0.1.0".into(),
7602 script: PathBuf::from("lib/m.lisp"),
7603 }),
7604 "a :state-change-carrying entry with behavior: None must trip the gate through \
7605 the declared_path accessor's Some(script) arm — carrying the offending script \
7606 verbatim byte-identical to the pattern-match shape"
7607 );
7608
7609 // (c) Non-StateChange-only inputs leave the gate vacuous.
7610 for instrs in [
7611 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7612 vec![
7613 UpgradeInstruction::LoadModule { module: "x".into() },
7614 UpgradeInstruction::SoftPurge {
7615 module: "x-old".into(),
7616 },
7617 ],
7618 vec![
7619 UpgradeInstruction::LoadModule { module: "x".into() },
7620 UpgradeInstruction::Purge {
7621 module: "x-old".into(),
7622 },
7623 ],
7624 vec![UpgradeInstruction::Restart],
7625 ] {
7626 for instr in &instrs {
7627 assert!(
7628 instr.declared_path().is_none(),
7629 "non-StateChange variants must project None through declared_path — \
7630 accessor divergence would let this cross-slot composition gate silently \
7631 fire on a module reference far from any :state-change site"
7632 );
7633 }
7634 let entries = vec![entry("0.1.0", instrs)];
7635 assert_eq!(
7636 validate_upgrade_from_against_behavior(&entries, None),
7637 Ok(()),
7638 "the cross-slot composition gate must return Ok(()) on an entry whose \
7639 instructions all project None through declared_path — the accessor's \
7640 None arm the pattern-match's implicit fall-through previously carried"
7641 );
7642 }
7643 }
7644
7645 #[test]
7646 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7647 // Wiring pin: the within-entry restart-exclusivity gate fires
7648 // through [`validate_upgrade_from`] (which delegates to
7649 // [`UpgradeFromEntry::validate`] per entry) before the cross-
7650 // entry duplicate-`:from` gate would have a chance to run on
7651 // the malformed entry. Pinned here so a future refactor that
7652 // walks the cross-entry gate first doesn't accidentally
7653 // surface a DuplicateFrom over an entry that's also malformed
7654 // at the within-entry restart-exclusivity layer.
7655 let entries = vec![
7656 entry(
7657 "0.1.0",
7658 vec![
7659 UpgradeInstruction::LoadModule { module: "x".into() },
7660 UpgradeInstruction::Restart,
7661 ],
7662 ),
7663 entry("0.1.0", vec![UpgradeInstruction::Restart]),
7664 ];
7665 let err = validate_upgrade_from(&entries).unwrap_err();
7666 assert!(
7667 matches!(
7668 err,
7669 UpgradeError::RestartNotExclusive {
7670 restart_count: 1,
7671 ..
7672 }
7673 ),
7674 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7675 duplicate-`:from` gate fires, got {err:?}"
7676 );
7677 }
7678
7679 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7680
7681 #[test]
7682 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7683 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7684 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7685 // name the exact camelCase JSON keys the `#[serde(rename_all =
7686 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7687 // test-side probe across the caixa-core / caixa-flux renderer
7688 // test fixtures navigates into each element of the rendered
7689 // `:upgrade-from` overlay sequence by consulting one of these two
7690 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7691 // and pin that each canonical byte-sequence appears verbatim in
7692 // the JSON — a future accidental `rename_all = "snake_case"` /
7693 // `"kebab-case"` / verbatim-field-name flip at the derive
7694 // attribute (any of which would silently break every test-side
7695 // probe that reaches for one of the two consts) surfaces here as
7696 // a build-time test failure at `upgrade.rs`, not as an apply-time
7697 // `.get(<stale-canonical-const>)` returning `None` far from the
7698 // derive-attr drift's commit. Same discipline the sibling
7699 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7700 // (d8b8b4f) and
7701 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7702 // (21fe462) pins established on the peer `:limits` / `:behavior`
7703 // sub-slot axes: one canonical byte-string per typed sub-key
7704 // axis, pinned to the load-bearing serde derivation at the type
7705 // itself.
7706 let e = UpgradeFromEntry {
7707 from: "0.1.0".into(),
7708 instructions: vec![UpgradeInstruction::LoadModule {
7709 module: "hello-rio".into(),
7710 }],
7711 };
7712 let json = serde_json::to_string(&e).unwrap();
7713 for key in [
7714 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7715 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7716 ] {
7717 let quoted = format!("\"{key}\"");
7718 assert!(
7719 json.contains("ed),
7720 "serialized UpgradeFromEntry must carry the lifted \
7721 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7722 the JSON emission (got: {json})",
7723 );
7724 }
7725 }
7726
7727 #[test]
7728 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7729 // Cross-axis drift-detection pin: a future collapse of the two
7730 // canonical sub-key byte-strings onto the same value (e.g. an
7731 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7732 // to also read `"from"`) would silently reroute every test-side
7733 // probe on one axis onto the sibling axis's per-entry field and
7734 // pass every propagation-probe test that expected only the stale
7735 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7736 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7737 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7738 let all = [
7739 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7740 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7741 ];
7742 for (i, a) in all.iter().enumerate() {
7743 for b in all.iter().skip(i + 1) {
7744 assert_ne!(
7745 a, b,
7746 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
7747 canonical byte-sequences — got `{a}` == `{b}`",
7748 );
7749 }
7750 }
7751 }
7752
7753 #[test]
7754 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
7755 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7756 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
7757 // tagged variant-discriminator key axis: the
7758 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
7759 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
7760 // attribute on [`UpgradeInstruction`] emits, and every downstream
7761 // consumer that navigates the serialized instruction blob to
7762 // route by variant (the caixa-core reflection-vs-serde round-trip
7763 // check in `dispatcher_registration.rs` that probes
7764 // `v.get("kind")` against every variant's expected kebab-case
7765 // tag, the future M4 admission-webhook path, any wasm-operator
7766 // dispatch step consuming the serialized instruction blob) reads
7767 // through the same `&'static str`. Serialize every variant and
7768 // pin that the const's byte-sequence appears verbatim as the
7769 // tag-slot JSON key with the expected kebab-case value — a
7770 // future accidental `tag = "type"` / `tag = "op"` /
7771 // `tag = "instruction"` rebrand at the derive attribute (any of
7772 // which would silently break every consumer probe reaching for
7773 // the stale-tag-key const) surfaces here as a build-time test
7774 // failure at `upgrade.rs`, not as an apply-time
7775 // `.get(<stale-tag-key>)` returning `None` far from the derive-
7776 // attr drift's commit.
7777 //
7778 // Same "one canonical byte-string per typed axis" discipline the
7779 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7780 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
7781 // outer-container axis — this pin extends the discipline one
7782 // altitude deeper onto the per-instruction *tag* axis inside
7783 // each element of the `:instructions` list, completing the
7784 // typed coverage of the `:upgrade-from :instructions` dual
7785 // (key = "kind" + five variant-value tags): the five
7786 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
7787 // per-variant kebab-case *values*; this pin pins the tag *key*
7788 // above them.
7789 let samples: [(UpgradeInstruction, &'static str); 5] = [
7790 (
7791 UpgradeInstruction::LoadModule {
7792 module: "hello-rio".into(),
7793 },
7794 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
7795 ),
7796 (
7797 UpgradeInstruction::StateChange {
7798 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7799 },
7800 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
7801 ),
7802 (
7803 UpgradeInstruction::SoftPurge {
7804 module: "hello-rio-old".into(),
7805 },
7806 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
7807 ),
7808 (
7809 UpgradeInstruction::Purge {
7810 module: "hello-rio-old".into(),
7811 },
7812 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
7813 ),
7814 (
7815 UpgradeInstruction::Restart,
7816 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
7817 ),
7818 ];
7819 for (sample, expected_value) in &samples {
7820 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
7821 let got = v
7822 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
7823 .and_then(|k| k.as_str());
7824 assert_eq!(
7825 got,
7826 Some(*expected_value),
7827 "serialized {sample:?} must carry the lifted \
7828 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
7829 ({:?}) verbatim as the tag-slot JSON key, holding the \
7830 expected kebab-case value {expected_value:?} (got: {v})",
7831 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
7832 );
7833 }
7834 }
7835
7836 #[test]
7837 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
7838 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
7839 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7840 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7841 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7842 // whitespace / colons / dots) — the canonical shape a serde
7843 // internally-tagged discriminator key takes across every peer
7844 // enum in this crate. A future flip to a non-camelCase byte at
7845 // the const surfaces here at build time. Peer of
7846 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
7847 // sibling per-entry outer-container axis.
7848 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7849 assert!(
7850 !key.is_empty(),
7851 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
7852 );
7853 let first = key.chars().next().unwrap();
7854 assert!(
7855 first.is_ascii_lowercase(),
7856 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
7857 byte (got {key:?}, leads with {first:?})",
7858 );
7859 assert!(
7860 key.chars().all(|c| c.is_ascii_alphanumeric()),
7861 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
7862 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7863 );
7864 }
7865
7866 #[test]
7867 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
7868 // Cross-axis drift-detection pin: the tag-slot key
7869 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
7870 // disjoint from every per-variant data-field key the
7871 // internally-tagged serialization also emits (`"module"` for
7872 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
7873 // future accidental rebrand that collapses `tag = "kind"` onto
7874 // one of the data-field names (e.g. `tag = "module"`) would
7875 // silently corrupt every serialized LoadModule blob (the
7876 // module string and the variant tag would collide on the same
7877 // JSON key) and every consumer probe would either misread the
7878 // tag or fail to distinguish variants. Pin the disjointness at
7879 // build time. Same cross-axis discipline the sibling
7880 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
7881 // (36ffe65) established on the outer container's own
7882 // `from`/`instructions` pair.
7883 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7884 // Enumerate every per-variant data-field key across all five
7885 // variants of [`UpgradeInstruction`], routing through the two
7886 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
7887 // that name the same per-variant data-field JSON keys the
7888 // `variant_fields` reflection in
7889 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
7890 // per-variant struct-field rebrand (`module` → `component`,
7891 // `script` → `path`) lands as an edit to exactly one const and
7892 // reaches this disjointness pin by construction — the two axes
7893 // (tag-slot key on one side, per-variant data-field keys on the
7894 // other) share one source of truth per axis.
7895 for data_field in [
7896 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7897 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7898 ] {
7899 assert_ne!(
7900 key, data_field,
7901 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
7902 must be disjoint from every UpgradeInstruction per-variant \
7903 data-field key — got tag-key {key:?} colliding with \
7904 data-field {data_field:?}, which would silently corrupt \
7905 the internally-tagged serialization",
7906 );
7907 }
7908 }
7909
7910 #[test]
7911 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
7912 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7913 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
7914 // data-field JSON key axis: the two
7915 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
7916 // `_SCRIPT`) name the exact per-variant field JSON keys the
7917 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
7918 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
7919 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
7920 // const — the `module: String` struct-field on
7921 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
7922 // struct-field on `StateChange` are promoted to sibling JSON keys
7923 // at the same nesting level as the tag by the internally-tagged
7924 // serialization, and every downstream consumer that navigates the
7925 // serialized instruction blob to reach the payload (the caixa-core
7926 // reflection round-trip in `dispatcher_registration.rs` that
7927 // consults `variant_fields`, the sibling disjointness pin below,
7928 // any future wasm-operator upgrade-dispatch step consuming the
7929 // serialized instruction blob to route the per-module load /
7930 // soft-purge / purge action or the per-script state-change action)
7931 // reads through the same `&'static str`. Serialize one Module-
7932 // bearing variant and one Script-bearing variant, then pin that
7933 // each const's byte-sequence appears verbatim in the JSON emission
7934 // — a future accidental struct-field rebrand (`module: String` →
7935 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
7936 // either variant surfaces here as a build-time test failure at
7937 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
7938 // returning `None` far from the field-name drift's commit.
7939 //
7940 // Same "one canonical byte-string per typed axis" discipline the
7941 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
7942 // pin established on the peer tag-slot key axis on the same
7943 // enum — this pin extends the discipline onto the per-variant
7944 // data-field key axis, completing the `:upgrade-from :instructions`
7945 // variant-JSON dual (tag key + tag values + per-variant field keys)
7946 // fully into caixa-core.
7947 let module_sample = UpgradeInstruction::LoadModule {
7948 module: "hello-rio".into(),
7949 };
7950 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
7951 assert_eq!(
7952 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
7953 .and_then(|k| k.as_str()),
7954 Some("hello-rio"),
7955 "serialized {module_sample:?} must carry the lifted \
7956 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
7957 ({:?}) verbatim as the data-field JSON key holding the \
7958 module string (got: {v})",
7959 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7960 );
7961
7962 let script_sample = UpgradeInstruction::StateChange {
7963 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7964 };
7965 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
7966 assert_eq!(
7967 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
7968 .and_then(|k| k.as_str()),
7969 Some("lib/migrations/v01-to-v02.lisp"),
7970 "serialized {script_sample:?} must carry the lifted \
7971 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
7972 ({:?}) verbatim as the data-field JSON key holding the \
7973 script path (got: {v})",
7974 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7975 );
7976 }
7977
7978 #[test]
7979 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
7980 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
7981 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7982 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7983 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7984 // whitespace / colons / dots) — the canonical shape a Rust
7985 // struct-field name promoted to a JSON key by serde takes on this
7986 // internally-tagged variant surface, matching the sibling
7987 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
7988 // shape. A future flip to a non-camelCase byte at either const
7989 // (an accidental `rename_all` regime interleave, or a struct-
7990 // field flip like `module` → `module_name`) surfaces here at
7991 // build time. Peer of
7992 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
7993 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
7994 // the sibling wire-key axes.
7995 for key in [
7996 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7997 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7998 ] {
7999 assert!(
8000 !key.is_empty(),
8001 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8002 );
8003 let first = key.chars().next().unwrap();
8004 assert!(
8005 first.is_ascii_lowercase(),
8006 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8007 byte (got {key:?}, leads with {first:?})",
8008 );
8009 assert!(
8010 key.chars().all(|c| c.is_ascii_alphanumeric()),
8011 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8012 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8013 );
8014 }
8015 }
8016
8017 #[test]
8018 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8019 // Cross-axis drift-detection pin: a future collapse of the two
8020 // canonical per-variant data-field byte-strings onto the same
8021 // value (e.g. an accidental copy-paste flip of
8022 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8023 // `"module"`) would silently reroute every test-side probe on one
8024 // variant's payload onto the sibling variant's payload and pass
8025 // every propagation-probe test that expected only the stale
8026 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8027 // on the sibling per-entry outer-container axis, and of
8028 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8029 // on the sibling tag-slot key ↔ per-variant data-field key axis.
8030 let all = [
8031 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8032 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8033 ];
8034 for (i, a) in all.iter().enumerate() {
8035 for b in all.iter().skip(i + 1) {
8036 assert_ne!(
8037 a, b,
8038 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8039 canonical byte-sequences — got `{a}` == `{b}`",
8040 );
8041 }
8042 }
8043 }
8044
8045 #[test]
8046 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8047 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8048 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8049 // `kebab-case` hyphens, no `PascalCase` leading capital, no
8050 // whitespace / colons / dots) — the canonical shape the
8051 // `#[serde(rename_all = "camelCase")]` derive produces on
8052 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8053 // at the derive surfaces both here (this test fails on the
8054 // stale-constant shape) and at
8055 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8056 // (that test fails on the mismatch between const and derive).
8057 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8058 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8059 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8060 for key in [
8061 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8062 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8063 ] {
8064 assert!(
8065 !key.is_empty(),
8066 "M2_UPGRADE_FROM_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_FROM_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_FROM_KEY_* must be ASCII-alphanumeric only \
8077 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8078 );
8079 }
8080 }
8081
8082 #[test]
8083 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8084 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8085 // OTP-appup variant-tag axis: the five canonical author-facing
8086 // kebab-case labels (`:load-module` / `:state-change` /
8087 // `:soft-purge` / `:purge` / `:restart`) the substrate's
8088 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8089 // from and every downstream consumer probes for verbatim. Same
8090 // scalar-value discipline the peer
8091 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8092 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8093 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8094 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8095 // (be40492) established for the sibling M2 / M3 / Supervisor
8096 // top-level and sub-slot author-facing-label axes. Fail-before-
8097 // pass-after locally verified by mutating
8098 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8099 // pin fires as expected; restoring passes.
8100 //
8101 // A future OTP-lineage per-variant rebrand (e.g.
8102 // `:load-module` → `:load` matching Erlang's abbreviated
8103 // `code:load_module` name, `:state-change` → `:code-change`
8104 // matching Erlang's verbatim `code_change/3` callback,
8105 // `:soft-purge` → `:drain` matching a hypothetical operator-side
8106 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8107 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8108 // matching a supervisor-tree vocabulary alignment) lands as an
8109 // edit to exactly one const, and every consumer that reaches for
8110 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8111 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8112 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8113 // `prior_cleanup_kind:` diagnostic field, the
8114 // [`LayoutError::UpgradeViolation`] `issue:` probe in
8115 // `layout.rs`) picks it up at build time rather than at runtime
8116 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8117 // far from the rename's commit.
8118 assert_eq!(
8119 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8120 ":load-module"
8121 );
8122 assert_eq!(
8123 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8124 ":state-change"
8125 );
8126 assert_eq!(
8127 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8128 ":soft-purge"
8129 );
8130 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8131 assert_eq!(
8132 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8133 ":restart"
8134 );
8135 }
8136
8137 #[test]
8138 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8139 // Cross-arm drift-detection pin on the M2
8140 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8141 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8142 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8143 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8144 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8145 // closed-set OTP-appup variant-tag pentad: a future collapse
8146 // of two canonical variant byte-strings onto the same value
8147 // (an accidental copy-paste flip of
8148 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8149 // to also read `":purge"`, a per-arm rebrand that lands one
8150 // const without touching its paired peer) would silently
8151 // reroute every downstream OTP-appup dispatcher's per-
8152 // instruction branch onto the sibling arm's runtime
8153 // behavior and pass every propagation-probe test that
8154 // expected only the stale arm's tag — a `:soft-purge`
8155 // instruction (drain-then-swap: existing callers finish
8156 // under the old module, new callers land on the new one)
8157 // would come up under the `:purge` reconcile branch
8158 // (drop-existing: every in-flight caller terminates
8159 // immediately) on every hot-upgrade cycle, so a rolling
8160 // module swap would silently downgrade to a hard cutover
8161 // against its declared appup discipline, with no field
8162 // naming the instruction-tag drift root cause. Every
8163 // [`crate::UpgradeError`] diagnostic that surfaces the tag
8164 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8165 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8166 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8167 // `prior_cleanup_kind:` field, the
8168 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8169 // `layout.rs`) would emit the sibling arm's stale bytes at
8170 // the operator's console, far from the source rebrand
8171 // commit. Peer of the sibling
8172 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8173 // (09ffb2d) /
8174 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8175 // (ccdf955) /
8176 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8177 // (d739850) distinctness pins on the sibling OTP-shape /
8178 // caixa-kind closed-set typed-enum discriminator axes —
8179 // the fifth closed-set OTP-appup / typed-enum axis to
8180 // converge on the same
8181 // "pairwise-distinct-by-construction" discipline, and the
8182 // canonical companion to the peer
8183 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8184 // (ff980bb) distinctness pin on the sibling internally-
8185 // tagged-JSON per-variant data-field-key axis (the tag axis
8186 // this pin covers vs. the data-field-key axis its peer
8187 // covers — two paired axes on the same
8188 // [`crate::UpgradeInstruction`] typed enum surface).
8189 //
8190 // Fail-before-pass-after locally verified by mutating
8191 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8192 // to also read `":purge"` — this pin fires as expected;
8193 // restoring passes.
8194 let all = [
8195 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8196 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8197 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8198 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8199 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8200 ];
8201 for (i, a) in all.iter().enumerate() {
8202 for (j, b) in all.iter().enumerate() {
8203 if i != j {
8204 assert_ne!(
8205 a, b,
8206 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8207 distinct — got duplicate {a:?} at indices {i} and {j}",
8208 );
8209 }
8210 }
8211 }
8212 }
8213
8214 #[test]
8215 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8216 // Production-through-const pin: the five per-variant labels
8217 // [`UpgradeInstruction::lisp_form`] returns route through the
8218 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8219 // so a future rebrand that reaches the const but not the
8220 // dispatch (or vice versa) surfaces here at build time rather
8221 // than at runtime as a downstream
8222 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8223 // diagnostic drift far from the rename's commit. Mirror of the
8224 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8225 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8226 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8227 // (f49c8b0) production-through-const pins on the sibling M3 /
8228 // M2 top-level slot axes.
8229 //
8230 // Fail-before-pass-after locally verified by mutating
8231 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8232 // `":purge-drift"` — this pin fires as expected; restoring
8233 // passes.
8234 let cases: &[(UpgradeInstruction, &'static str)] = &[
8235 (
8236 UpgradeInstruction::LoadModule { module: "x".into() },
8237 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8238 ),
8239 (
8240 UpgradeInstruction::StateChange {
8241 script: PathBuf::from("lib/m.lisp"),
8242 },
8243 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8244 ),
8245 (
8246 UpgradeInstruction::SoftPurge {
8247 module: "x-old".into(),
8248 },
8249 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8250 ),
8251 (
8252 UpgradeInstruction::Purge {
8253 module: "x-old".into(),
8254 },
8255 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8256 ),
8257 (
8258 UpgradeInstruction::Restart,
8259 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8260 ),
8261 ];
8262 for (instr, expected) in cases {
8263 assert_eq!(
8264 instr.lisp_form(),
8265 *expected,
8266 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8267 const (expected {expected:?})",
8268 );
8269 }
8270 }
8271
8272 #[test]
8273 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8274 // The canonical per-`:upgrade-from :instructions` OTP-appup
8275 // migration-instruction-list slice-shape pin:
8276 // [`UpgradeFromEntry::instructions`] must return the
8277 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8278 // a `&[UpgradeInstruction]` slice-view over the same backing
8279 // buffer the raw `self.instructions.as_slice()` field access
8280 // borrows from, byte-equal across every representative fixture
8281 // in the accept-set — the empty slice (the "no-op upgrade" /
8282 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8283 // field's own docstring names), the singleton slice on every
8284 // variant of the [`UpgradeInstruction`] arm-space
8285 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8286 // `Restart` — the five OTP-appup runtime-primitive variants),
8287 // and multi-instruction cohorts (the canonical
8288 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8289 // load + state-migration triad the module doc names as the
8290 // "runs the instructions in order" example).
8291 //
8292 // Pins against a future silent detour that returned
8293 // `&Vec<UpgradeInstruction>` (which would type-check but leak
8294 // the storage-side `Vec`'s grow/push/reserve surface no
8295 // consumer of the typed view reaches for), a fresh-allocated
8296 // `Vec<UpgradeInstruction>` copy (which would type-check via
8297 // a coercion but silently break every downstream caller that
8298 // relied on the slice sharing the backing buffer's identity),
8299 // or an out-of-order or length-drifted projection (which
8300 // would silently split the paired within-entry cross-
8301 // instruction ordering gates' inputs from the peer per-
8302 // instruction shape-check loop's input, one seven-gate cohort
8303 // silently drifting from the peer gate's actual traversal
8304 // input).
8305 //
8306 // Peer of the sibling
8307 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8308 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8309 // `:contratos` edge-list axis, extended onto the M2 per-
8310 // `:upgrade-from :instructions` migration-instruction-list
8311 // axis — the fifth `&[T]`-return byte-equal pin, closing the
8312 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8313 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8314 Vec::new(),
8315 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8316 vec![UpgradeInstruction::StateChange {
8317 script: PathBuf::from("lib/m.lisp"),
8318 }],
8319 vec![UpgradeInstruction::SoftPurge {
8320 module: "x-old".into(),
8321 }],
8322 vec![UpgradeInstruction::Purge {
8323 module: "x-old".into(),
8324 }],
8325 vec![UpgradeInstruction::Restart],
8326 vec![
8327 UpgradeInstruction::LoadModule { module: "x".into() },
8328 UpgradeInstruction::StateChange {
8329 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8330 },
8331 UpgradeInstruction::SoftPurge {
8332 module: "x-old".into(),
8333 },
8334 ],
8335 ];
8336 for instructions in fixtures {
8337 let e = UpgradeFromEntry {
8338 from: "0.1.0".into(),
8339 instructions: instructions.clone(),
8340 };
8341 assert_eq!(
8342 e.instructions(),
8343 e.instructions.as_slice(),
8344 "UpgradeFromEntry::instructions must project the raw \
8345 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8346 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8347 (fixture: {instructions:?})",
8348 );
8349 assert_eq!(
8350 e.instructions().len(),
8351 instructions.len(),
8352 "UpgradeFromEntry::instructions length must match the raw \
8353 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8354 );
8355 }
8356 }
8357
8358 #[test]
8359 fn validate_reads_through_lifted_instructions_accessor() {
8360 // Three-consumer coherence pin on the lifted
8361 // [`UpgradeFromEntry::instructions`] slice-return accessor:
8362 // exercises three of the nine paired production consumers of
8363 // the per-`:upgrade-from :instructions` OTP-appup migration-
8364 // instruction-list surface through end-to-end validate() paths
8365 // that require the accessor to reach each of the fixture's
8366 // instructions.
8367 //
8368 // (1) The per-instruction shape-check fan-out
8369 // ([`UpgradeFromEntry::validate`]'s `for instr in
8370 // self.instructions()` loop): pass the well-formed load →
8371 // state-change → soft-purge triad — `validate()` must accept
8372 // it, which requires the accessor to project every entry so
8373 // each `instr.validate()` fires.
8374 //
8375 // (2) The within-entry state-change-ordering gate
8376 // ([`Self::validate_state_change_ordering`]): pass a
8377 // `((:state-change …))` singleton — `validate()` must return
8378 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8379 // requires the accessor to reach the state-change so the
8380 // no-prior-load probe fires.
8381 //
8382 // (3) The within-entry per-module cleanup-singularity gate
8383 // ([`Self::validate_cleanup_singularity`]): pass a
8384 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8385 // "x-old"))` cohort — `validate()` must return
8386 // [`UpgradeError::DuplicateCleanup`], which requires the
8387 // accessor to iterate the whole list so the second `SoftPurge`
8388 // matches the first via the `seen` set.
8389 //
8390 // Peer of the sibling
8391 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8392 // three-consumer coherence pin on the M3 per-`:contratos`
8393 // edge-list axis, extended onto the M2 per-`:upgrade-from
8394 // :instructions` migration-instruction-list axis.
8395
8396 // (1) accept the well-formed OTP two-phase code-load triad
8397 let well_formed = entry(
8398 "0.1.0",
8399 vec![
8400 UpgradeInstruction::LoadModule { module: "x".into() },
8401 UpgradeInstruction::StateChange {
8402 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8403 },
8404 UpgradeInstruction::SoftPurge {
8405 module: "x-old".into(),
8406 },
8407 ],
8408 );
8409 assert!(
8410 well_formed.validate().is_ok(),
8411 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8412 the per-instruction shape-check fan-out requires the accessor to reach every entry"
8413 );
8414
8415 // (2) refuse a `((:state-change …))` singleton — the
8416 // state-change-without-prior-load gate must fire, which
8417 // requires the accessor to reach the single instruction.
8418 let no_prior_load = entry(
8419 "0.1.0",
8420 vec![UpgradeInstruction::StateChange {
8421 script: PathBuf::from("lib/m.lisp"),
8422 }],
8423 );
8424 match no_prior_load.validate() {
8425 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8426 other => panic!(
8427 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8428 — the within-entry state-change-ordering gate must reach the single \
8429 instruction through the lifted accessor; got: {other:?}"
8430 ),
8431 }
8432
8433 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8434 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8435 // singularity gate must fire on the second `SoftPurge`, which
8436 // requires the accessor to iterate the whole list.
8437 let duplicate_cleanup = entry(
8438 "0.1.0",
8439 vec![
8440 UpgradeInstruction::LoadModule { module: "x".into() },
8441 UpgradeInstruction::SoftPurge {
8442 module: "x-old".into(),
8443 },
8444 UpgradeInstruction::SoftPurge {
8445 module: "x-old".into(),
8446 },
8447 ],
8448 );
8449 match duplicate_cleanup.validate() {
8450 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8451 assert_eq!(
8452 module, "x-old",
8453 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8454 cleanup-singularity gate must iterate through the lifted accessor to \
8455 match the second SoftPurge against the first via the `seen` set"
8456 );
8457 }
8458 other => panic!(
8459 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8460 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8461 iterate the whole list through the lifted accessor; got: {other:?}"
8462 ),
8463 }
8464
8465 // Path::new suppresses the unused-import warning if the
8466 // outer module trims `use std::path::Path;` in a future edit.
8467 let _ = Path::new("lib/m.lisp");
8468 }
8469
8470 // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8471 // macro definition (see the paired doc-block above the macro
8472 // definition) — every generated `<ctor>(from: &str, script: &Path)
8473 // -> Self` constructor folds the uniform `Self::<Variant> { from:
8474 // from.to_string(), script: script.to_path_buf() }` two-field
8475 // struct-literal onto one substrate primitive. The three per-variant
8476 // equivalence pins below (fail-before-pass-after by construction — a
8477 // byte-mismatched macro arm would trip its equivalence pin first)
8478 // lock each generated constructor to its struct-literal peer under
8479 // `PartialEq`, so every wire-up in
8480 // [`UpgradeFromEntry::validate_state_change_ordering`],
8481 // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8482 // [`validate_state_change_on_state_change_callback`] on that
8483 // variant produces a byte-equal `UpgradeError` to the pre-lift
8484 // open-coded struct-literal. The cross-axis pin that follows
8485 // (non-default `(from, script)` pair) routes both constructor input
8486 // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8487 // not silently collapse onto a fixed `from` / `script` value.
8488 //
8489 // Peer of the sibling `empty_child_version_ctor_matches_struct_
8490 // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8491 // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8492 // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8493 // to_string` equivalence + cross-axis pins the sibling
8494 // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8495 // established on the peer `SupervisorError` envelope; extended
8496 // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8497 // two-slot envelope so every substrate-primitive ctor family in
8498 // caixa-core guarantees the same-shape fold every wire-up on the
8499 // family reads through one dispatch.
8500
8501 #[test]
8502 fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8503 let from = "0.1.0";
8504 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8505 assert_eq!(
8506 UpgradeError::state_change_without_prior_load(from, script),
8507 UpgradeError::StateChangeWithoutPriorLoad {
8508 from: from.to_string(),
8509 script: script.to_path_buf(),
8510 },
8511 "generated state_change_without_prior_load ctor must produce \
8512 byte-equal UpgradeError to the open-coded struct-literal \
8513 wrap on the same (&str, &Path) fixture",
8514 );
8515 }
8516
8517 #[test]
8518 fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
8519 let from = "0.1.0";
8520 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8521 assert_eq!(
8522 UpgradeError::duplicate_state_change(from, script),
8523 UpgradeError::DuplicateStateChange {
8524 from: from.to_string(),
8525 script: script.to_path_buf(),
8526 },
8527 "generated duplicate_state_change ctor must produce byte-equal \
8528 UpgradeError to the open-coded struct-literal wrap on the \
8529 same (&str, &Path) fixture",
8530 );
8531 }
8532
8533 #[test]
8534 fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
8535 let from = "0.1.0";
8536 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8537 assert_eq!(
8538 UpgradeError::state_change_without_on_state_change_callback(from, script),
8539 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8540 from: from.to_string(),
8541 script: script.to_path_buf(),
8542 },
8543 "generated state_change_without_on_state_change_callback ctor \
8544 must produce byte-equal UpgradeError to the open-coded \
8545 struct-literal wrap on the same (&str, &Path) fixture",
8546 );
8547 }
8548
8549 #[test]
8550 fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
8551 // Cross-axis pin: sweep both constructor input axes (`from:
8552 // &str`, `script: &Path`) through non-default fixtures against
8553 // every generated arm in the [`upgrade_from_script_ctors!`]
8554 // macro, so any wrapper-side lowercase / trim / truncate /
8555 // re-order / fixed-path substitution on the two-field
8556 // construction surfaces here rather than at a downstream
8557 // diagnostic-shape mismatch. Also exercises the `&Path`
8558 // parameter under both `&Path` (direct `Path::new`) and
8559 // `&PathBuf` (via Deref coercion), matching the two shapes the
8560 // three wire-up sites thread through — the ordering /
8561 // callback-declaration gates hand a `&PathBuf` from
8562 // `instr.declared_path()`; the uniqueness gate hands a `&Path`
8563 // from `script.as_path()`. Peer of the sibling
8564 // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
8565 // cross-axis pin on the peer `SupervisorError` `{ caixa:
8566 // String }` envelope.
8567 let from = "1.2.3-rc.1";
8568 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8569 let script_ref: &Path = script_owned.as_path();
8570 for script in [script_ref, &script_owned as &Path] {
8571 assert_eq!(
8572 UpgradeError::state_change_without_prior_load(from, script),
8573 UpgradeError::StateChangeWithoutPriorLoad {
8574 from: from.to_string(),
8575 script: script.to_path_buf(),
8576 },
8577 );
8578 assert_eq!(
8579 UpgradeError::duplicate_state_change(from, script),
8580 UpgradeError::DuplicateStateChange {
8581 from: from.to_string(),
8582 script: script.to_path_buf(),
8583 },
8584 );
8585 assert_eq!(
8586 UpgradeError::state_change_without_on_state_change_callback(from, script),
8587 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8588 from: from.to_string(),
8589 script: script.to_path_buf(),
8590 },
8591 );
8592 }
8593 }
8594
8595 // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
8596 // macro definition (see the paired doc-block above the macro
8597 // definition) — every generated `<ctor>(script: &Path) -> Self`
8598 // constructor folds the uniform `Self::<Variant> { script:
8599 // script.to_path_buf() }` one-field struct-literal onto one substrate
8600 // primitive. The three per-variant equivalence pins below
8601 // (fail-before-pass-after by construction — a byte-mismatched macro
8602 // arm would trip its equivalence pin first) lock each generated
8603 // constructor to its struct-literal peer under `PartialEq`, so every
8604 // closure passed to [`crate::render::require_sandboxed_lisp_path`]
8605 // at [`UpgradeInstruction::validate`] on that variant produces a
8606 // byte-equal `UpgradeError` to the pre-lift open-coded
8607 // struct-literal. The cross-axis pin that follows (non-default
8608 // `script` path, both `&Path` and `&PathBuf` shapes) routes the
8609 // constructor input axis through `.to_path_buf()`, so the fold does
8610 // not silently collapse onto a fixed `script` value or drop the
8611 // Deref-coercion arm the wire-up sites depend on.
8612 //
8613 // Peer of the sibling
8614 // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
8615 // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
8616 // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
8617 // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
8618 // equivalence + cross-axis pins the sibling
8619 // [`upgrade_from_script_ctors!`] family (8e67041) established on the
8620 // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
8621 // extended here onto the `{ script: PathBuf }` one-slot envelope
8622 // shape so every substrate-primitive ctor family on `UpgradeError`
8623 // guarantees the same-shape fold every wire-up on the family reads
8624 // through one dispatch.
8625
8626 #[test]
8627 fn absolute_script_ctor_matches_struct_literal_wrap() {
8628 let script = Path::new("/etc/nope.lisp");
8629 assert_eq!(
8630 UpgradeError::absolute_script(script),
8631 UpgradeError::AbsoluteScript {
8632 script: script.to_path_buf(),
8633 },
8634 "generated absolute_script ctor must produce byte-equal \
8635 UpgradeError to the open-coded struct-literal wrap on the \
8636 same &Path fixture",
8637 );
8638 }
8639
8640 #[test]
8641 fn parent_escape_script_ctor_matches_struct_literal_wrap() {
8642 let script = Path::new("../oops.lisp");
8643 assert_eq!(
8644 UpgradeError::parent_escape_script(script),
8645 UpgradeError::ParentEscapeScript {
8646 script: script.to_path_buf(),
8647 },
8648 "generated parent_escape_script ctor must produce byte-equal \
8649 UpgradeError to the open-coded struct-literal wrap on the \
8650 same &Path fixture",
8651 );
8652 }
8653
8654 #[test]
8655 fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8656 let script = Path::new("lib/migrations.rs");
8657 assert_eq!(
8658 UpgradeError::non_lisp_extension_script(script),
8659 UpgradeError::NonLispExtensionScript {
8660 script: script.to_path_buf(),
8661 },
8662 "generated non_lisp_extension_script ctor must produce \
8663 byte-equal UpgradeError to the open-coded struct-literal \
8664 wrap on the same &Path fixture",
8665 );
8666 }
8667
8668 #[test]
8669 fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8670 // Cross-axis pin: sweep the constructor input axis (`script:
8671 // &Path`) through a non-default fixture against every generated
8672 // arm in the [`upgrade_script_only_ctors!`] macro, so any
8673 // wrapper-side lowercase / trim / truncate / re-order /
8674 // fixed-path substitution on the one-field construction
8675 // surfaces here rather than at a downstream diagnostic-shape
8676 // mismatch. Also exercises the `&Path` parameter under both
8677 // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
8678 // coercion), matching the shape the three closures at
8679 // [`UpgradeInstruction::validate`] thread through — the
8680 // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
8681 // each closure, so the Deref-coercion arm the ctor advertises
8682 // must actually route through `.to_path_buf()` and not
8683 // silently swap in a fixed path.
8684 //
8685 // Peer of the sibling
8686 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8687 // cross-axis pin on the sibling `{ from, script }` two-slot
8688 // envelope shape.
8689 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8690 let script_ref: &Path = script_owned.as_path();
8691 for script in [script_ref, &script_owned as &Path] {
8692 assert_eq!(
8693 UpgradeError::absolute_script(script),
8694 UpgradeError::AbsoluteScript {
8695 script: script.to_path_buf(),
8696 },
8697 );
8698 assert_eq!(
8699 UpgradeError::parent_escape_script(script),
8700 UpgradeError::ParentEscapeScript {
8701 script: script.to_path_buf(),
8702 },
8703 );
8704 assert_eq!(
8705 UpgradeError::non_lisp_extension_script(script),
8706 UpgradeError::NonLispExtensionScript {
8707 script: script.to_path_buf(),
8708 },
8709 );
8710 }
8711 }
8712
8713 // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
8714 // macro definition (see the paired doc-block above the macro
8715 // definition) — every generated `<ctor>(from: &str, <axis>: &str)
8716 // -> Self` constructor folds the uniform `Self::<Variant> { from:
8717 // from.to_string(), <axis>: <axis>.to_string() }` two-field
8718 // struct-literal onto one substrate primitive. The three per-variant
8719 // equivalence pins below (fail-before-pass-after by construction — a
8720 // byte-mismatched macro arm would trip its equivalence pin first)
8721 // lock each generated constructor to its struct-literal peer under
8722 // `PartialEq`, so every wire-up in
8723 // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
8724 // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
8725 // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
8726 // `:from < :versao` gate on that variant produces a byte-equal
8727 // `UpgradeError` to the pre-lift open-coded struct-literal. The
8728 // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
8729 // pair) routes both constructor input axes through `.to_string()`
8730 // in declared field order, so the fold does not silently swap `from`
8731 // and the middle `<axis>` field, or silently collapse onto a fixed
8732 // `from` / `<axis>` value on any one variant.
8733 //
8734 // Peer of the sibling `state_change_without_prior_load_ctor_matches_
8735 // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
8736 // struct_literal_wrap` / `state_change_without_on_state_change_
8737 // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
8738 // ctors_route_from_and_script_verbatim` equivalence + cross-axis
8739 // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
8740 // established on the sibling `{ from: String, script: PathBuf }`
8741 // two-slot envelope shape; extended here onto the `{ from: String,
8742 // <axis>: String }` two-slot envelope shape so every substrate-
8743 // primitive ctor family on `UpgradeError` guarantees the same-shape
8744 // fold every wire-up on the family reads through one dispatch. Also
8745 // mirror-symmetric peer of the sibling
8746 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8747 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
8748 // <axis>: String }` two-slot envelope shape.
8749
8750 #[test]
8751 fn from_invalid_ctor_matches_struct_literal_wrap() {
8752 let from = "not-a-semver";
8753 let reason = "unexpected character '-' at position 3";
8754 assert_eq!(
8755 UpgradeError::from_invalid(from, reason),
8756 UpgradeError::FromInvalid {
8757 from: from.to_string(),
8758 reason: reason.to_string(),
8759 },
8760 "generated from_invalid ctor must produce byte-equal \
8761 UpgradeError to the open-coded struct-literal wrap on the \
8762 same (&str, &str) fixture",
8763 );
8764 }
8765
8766 #[test]
8767 fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
8768 let from = "0.2.0";
8769 let versao = "0.1.0";
8770 assert_eq!(
8771 UpgradeError::from_not_before_versao(from, versao),
8772 UpgradeError::FromNotBeforeVersao {
8773 from: from.to_string(),
8774 versao: versao.to_string(),
8775 },
8776 "generated from_not_before_versao ctor must produce byte-equal \
8777 UpgradeError to the open-coded struct-literal wrap on the \
8778 same (&str, &str) fixture",
8779 );
8780 }
8781
8782 #[test]
8783 fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
8784 let from = "0.1.0";
8785 let module = "hello-rio";
8786 assert_eq!(
8787 UpgradeError::duplicate_load_module(from, module),
8788 UpgradeError::DuplicateLoadModule {
8789 from: from.to_string(),
8790 module: module.to_string(),
8791 },
8792 "generated duplicate_load_module ctor must produce byte-equal \
8793 UpgradeError to the open-coded struct-literal wrap on the \
8794 same (&str, &str) fixture",
8795 );
8796 }
8797
8798 #[test]
8799 fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
8800 // Cross-axis routing pin: sweep the two constructor input axes
8801 // (`from: &str`, `<axis>: &str`) through distinct-per-axis
8802 // fixtures against every generated arm in the
8803 // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
8804 // lowercase / trim / truncate at codegen time — a silent field
8805 // swap between `from` and the middle `<axis>` field, or a
8806 // `<axis>` axis silently rerouted through the wrong field on any
8807 // one variant — surfaces here rather than at a downstream
8808 // diagnostic-shape mismatch. Peer of the sibling
8809 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8810 // (8e67041) cross-axis pin on the same envelope's sibling
8811 // `{ from: String, script: PathBuf }` two-slot family, and of the
8812 // sibling
8813 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
8814 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
8815 // String, <axis>: String }` two-slot envelope. Distinct-per-
8816 // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
8817 // that would still pass a same-fixture-per-axis pin. Both
8818 // `&str`-literal and `&String` (via Deref coercion) carriers
8819 // are exercised because the three wire-up sites hand a mix of
8820 // both (the `from_invalid` site hands `&e.to_string()` — an
8821 // owned `String` — for `reason`; the `duplicate_load_module`
8822 // site hands a `&str` slice for `module`; the
8823 // `from_not_before_versao` site hands the caller-supplied
8824 // `versao: &str` for `versao`).
8825 let from = "0.1.0";
8826 let axis = "distinct-axis-value";
8827 let from_owned: String = from.to_string();
8828 let axis_owned: String = axis.to_string();
8829 for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
8830 assert_eq!(
8831 UpgradeError::from_invalid(from_in, axis_in),
8832 UpgradeError::FromInvalid {
8833 from: from.to_string(),
8834 reason: axis.to_string(),
8835 },
8836 "from_invalid must route `from` → `from`, `axis` → `reason` \
8837 in declared field order",
8838 );
8839 assert_eq!(
8840 UpgradeError::from_not_before_versao(from_in, axis_in),
8841 UpgradeError::FromNotBeforeVersao {
8842 from: from.to_string(),
8843 versao: axis.to_string(),
8844 },
8845 "from_not_before_versao must route `from` → `from`, \
8846 `axis` → `versao` in declared field order",
8847 );
8848 assert_eq!(
8849 UpgradeError::duplicate_load_module(from_in, axis_in),
8850 UpgradeError::DuplicateLoadModule {
8851 from: from.to_string(),
8852 module: axis.to_string(),
8853 },
8854 "duplicate_load_module must route `from` → `from`, \
8855 `axis` → `module` in declared field order",
8856 );
8857 }
8858 }
8859
8860 // Per-variant equivalence + accessor-fidelity + cross-axis pins for
8861 // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
8862 // the paired doc-block above the ctor definition) — the fold of the
8863 // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
8864 // struct-literal inside [`validate_upgrade_from`]'s cross-entry
8865 // duplicate gate onto one substrate primitive on the
8866 // [`UpgradeError`] envelope, projecting through the paired
8867 // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
8868 // primitive. A byte-mismatched ctor body would trip the equivalence
8869 // pin first, ahead of any downstream diagnostic-shape drift.
8870 //
8871 // Peer of the sibling standalone-ctor equivalence pins on the peer
8872 // one-off variants across caixa-core:
8873 // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
8874 // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
8875 // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
8876 // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
8877 // envelope, the sibling
8878 // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
8879 // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
8880 // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
8881 // pin on the sibling standalone `{ host, reason }` two-slot ctor.
8882
8883 #[test]
8884 fn duplicate_from_ctor_matches_struct_literal_wrap() {
8885 // Equivalence pin: the ctor produces byte-equal
8886 // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
8887 // struct-literal that read the same `from` field through
8888 // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
8889 // addition / reordering / string-conversion tweak on the
8890 // variant. Same equivalence-pin shape as the sibling
8891 // `contrato_self_loop_ctor_matches_struct_literal_wrap`
8892 // (b30edfe) on the paired two-slot `{ caixa, wit }`
8893 // envelope inside `impl AplicacaoSpec`.
8894 let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
8895 let lifted = UpgradeError::duplicate_from(&entry);
8896 let struct_literal = UpgradeError::DuplicateFrom {
8897 from: entry.prior_versao().to_string(),
8898 };
8899 assert_eq!(lifted, struct_literal);
8900 }
8901
8902 #[test]
8903 fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
8904 // Routing pin sweeping a non-default `:from` value
8905 // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
8906 // release and build metadata) through the paired
8907 // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
8908 // wrapper-side lowercase / trim / truncate on the one-field
8909 // construction surfaces here rather than at a downstream
8910 // diagnostic-shape drift. Peer of the sibling
8911 // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
8912 // (b30edfe) routing pin on the sibling two-slot envelope.
8913 //
8914 // The pre-release + build-metadata carrier value is deliberately
8915 // chosen to exercise the `.to_string()` path against a `:from`
8916 // shape [`semver::Version::PartialEq`] treats as distinct from
8917 // its release-only sibling (per the
8918 // `validate_upgrade_from_treats_pre_release_as_distinct` and
8919 // build-metadata-tightening-note doc-block on
8920 // [`validate_upgrade_from`]) — so any silent normalization at
8921 // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
8922 // `.split_once('-')` collapse) would drop bytes from the
8923 // rendered diagnostic and surface here.
8924 let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
8925 let built = UpgradeError::duplicate_from(&entry);
8926 match built {
8927 UpgradeError::DuplicateFrom { from } => {
8928 assert_eq!(
8929 from, "1.2.3-rc.4+build.5",
8930 "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
8931 preserving pre-release + build-metadata bytes"
8932 );
8933 }
8934 other => panic!("expected DuplicateFrom, got {other:?}"),
8935 }
8936 }
8937
8938 #[test]
8939 fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
8940 // Accessor-fidelity pin: the ctor's `from` slot keys off the
8941 // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
8942 // the pre-lift open-coded body's field selection), not any
8943 // stringified rendering of the full entry (e.g. the
8944 // `impl Display for UpgradeFromEntry` output, if one were later
8945 // added, or a `format!("{:?}", entry)` debug dump). Pins the
8946 // projection axis so a silent swap at the ctor body — say, a
8947 // future refactor that projects through `entry.instructions()`
8948 // in shape (dropping the `:from` axis entirely) or through a
8949 // whole-entry `format!` — surfaces here rather than at a
8950 // downstream diagnostic mis-attribution far from the duplicate
8951 // gate's owner.
8952 //
8953 // A future consumer that constructs the ctor against a not-yet-
8954 // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
8955 // CR admission webhook re-checking a per-`:upgrade-from`-patched
8956 // candidate before the cross-entry duplicate gate re-fires, a
8957 // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
8958 // `(:from …)` introduced by a cluster-local `:upgrade-from`
8959 // override) needs the pre-lift projection axis pinned.
8960 //
8961 // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
8962 // paired with a distinctive multi-instruction sequence so a
8963 // silent swap that projects through the whole-entry rendering
8964 // instead of the paired scalar accessor would land debug bytes
8965 // from the `:instructions` list into the `from` slot and trip
8966 // the assertion here.
8967 let entry = entry(
8968 "0.2.0-alpha.7",
8969 vec![
8970 UpgradeInstruction::LoadModule {
8971 module: "distinctive-load-target".into(),
8972 },
8973 UpgradeInstruction::StateChange {
8974 script: PathBuf::from("lib/distinctive-migrate.lisp"),
8975 },
8976 UpgradeInstruction::Restart,
8977 ],
8978 );
8979 let built = UpgradeError::duplicate_from(&entry);
8980 match built {
8981 UpgradeError::DuplicateFrom { from } => {
8982 assert_eq!(
8983 from, "0.2.0-alpha.7",
8984 "from slot must project UpgradeFromEntry::prior_versao() \
8985 (not any whole-entry rendering)"
8986 );
8987 }
8988 other => panic!("expected DuplicateFrom, got {other:?}"),
8989 }
8990 }
8991
8992 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
8993 // the standalone [`UpgradeError::purge_without_prior_load`] inherent
8994 // ctor (see the paired doc-block above the ctor definition) — the
8995 // fold of the last open-coded three-slot `{ from: String, kind:
8996 // &'static str, module: String }` struct-literal wire-up on
8997 // [`UpgradeError`] closes the sole in-crate wire-up site inside
8998 // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
8999 // load-family sticky-latch dispatch onto one substrate primitive.
9000 // A byte-mismatched ctor body would trip the equivalence pin first,
9001 // ahead of any downstream diagnostic-shape drift.
9002 //
9003 // Peer of the sibling standalone-ctor equivalence + routing pins on
9004 // the sibling one-off variants across `UpgradeError`
9005 // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9006 // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9007 // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9008 // the paired one-slot `{ from: String }` envelope) and across
9009 // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9010 // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9011 // String }` `AplicacaoError` envelope).
9012
9013 #[test]
9014 fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9015 // Equivalence pin: the ctor produces byte-equal
9016 // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9017 // open-coded three-field struct-literal on the same `(&str,
9018 // &'static str, &str)` fixture. Guards any future field-
9019 // addition / reordering / string-conversion tweak on the
9020 // variant. Same equivalence-pin shape as the sibling
9021 // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9022 // on the peer one-slot `{ from: String }` envelope.
9023 let from = "0.1.0";
9024 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9025 let module = "hello-rio-old";
9026 assert_eq!(
9027 UpgradeError::purge_without_prior_load(from, kind, module),
9028 UpgradeError::PurgeWithoutPriorLoad {
9029 from: from.to_string(),
9030 kind,
9031 module: module.to_string(),
9032 },
9033 "generated purge_without_prior_load ctor must produce \
9034 byte-equal UpgradeError to the open-coded struct-literal \
9035 wrap on the same (&str, &'static str, &str) fixture",
9036 );
9037 }
9038
9039 #[test]
9040 fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9041 // Cross-axis routing pin: sweep the three constructor input
9042 // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9043 // through distinct-per-axis fixtures across every cleanup-family
9044 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9045 // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9046 // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9047 // wrapper-side lowercase / trim / truncate / silent axis-swap
9048 // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9049 // three-field construction surfaces at assert time rather than
9050 // at a downstream diagnostic consumer that reads the fields
9051 // back and gets a different value than the one it stored. Both
9052 // `&str`-literal and `&String` (via Deref coercion) carriers
9053 // are exercised for `from` / `module` because the sole wire-up
9054 // hands `self.prior_versao()` (a `&str` accessor) and
9055 // `instr.declared_module().expect(…)` (also a `&str`) — the
9056 // ctor must accept both shapes without a pre-conversion.
9057 let kinds: [&'static str; 2] = [
9058 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9059 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9060 ];
9061 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9062 let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9063 for kind in kinds {
9064 for from in froms {
9065 for module in modules {
9066 let from_owned: String = from.to_string();
9067 let module_owned: String = module.to_string();
9068 for (from_in, module_in) in
9069 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9070 {
9071 assert_eq!(
9072 UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9073 UpgradeError::PurgeWithoutPriorLoad {
9074 from: from.to_string(),
9075 kind,
9076 module: module.to_string(),
9077 },
9078 "purge_without_prior_load must route from → from, \
9079 kind → kind, module → module in declared field \
9080 order verbatim on ({from:?}, {kind:?}, {module:?})",
9081 );
9082 }
9083 }
9084 }
9085 }
9086 }
9087
9088 #[test]
9089 fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9090 // End-to-end wire-up pin: build an entry whose declared
9091 // `:instructions` list places a `:soft-purge` (and separately a
9092 // `:purge`) before any `:load-module` so
9093 // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9094 // sticky-latch dispatch surfaces
9095 // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9096 // observed `Err` byte-equals the substrate-primitive
9097 // [`UpgradeError::purge_without_prior_load`] ctor's output on
9098 // the same fixture. A future silent de-lift of the wire-up back
9099 // to the open-coded struct-literal (or a silent axis-swap on
9100 // the three-field construction at the wire-up site) trips at
9101 // caixa-core test time rather than at a downstream diagnostic
9102 // consumer far from the wire-up commit. Same end-to-end-wire-up
9103 // discipline as the sibling
9104 // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9105 // on the peer cross-entry duplicate-`:from` gate; both key off
9106 // exactly one typed dispatch on the substrate primitive.
9107 let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9108 (
9109 "0.1.0",
9110 UpgradeInstruction::SoftPurge {
9111 module: "hello-rio-old".into(),
9112 },
9113 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9114 "hello-rio-old",
9115 ),
9116 (
9117 "1.2.3-rc.1",
9118 UpgradeInstruction::Purge {
9119 module: "cache-v2-ancient".into(),
9120 },
9121 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9122 "cache-v2-ancient",
9123 ),
9124 ];
9125 for (from, instr, kind, module) in cases {
9126 let e = entry(from, vec![instr]);
9127 let observed = e.validate().unwrap_err();
9128 assert_eq!(
9129 observed,
9130 UpgradeError::purge_without_prior_load(from, kind, module),
9131 "validate_purge_ordering must route its refusal through \
9132 UpgradeError::purge_without_prior_load(from, kind, \
9133 module) on a bare-cleanup {kind:?} entry, byte-equal \
9134 to the pre-lift open-coded struct-literal wrap on the \
9135 same fixture",
9136 );
9137 }
9138 }
9139
9140 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9141 // the standalone [`UpgradeError::state_change_after_cleanup`]
9142 // inherent ctor (see the paired doc-block above the ctor
9143 // definition) — the fold of the last open-coded four-slot `{ from:
9144 // String, script: PathBuf, prior_cleanup_kind: &'static str,
9145 // prior_cleanup_module: String }` struct-literal wire-up on
9146 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9147 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9148 // migrate-family sticky-latch dispatch onto one substrate primitive.
9149 // A byte-mismatched ctor body would trip the equivalence pin first,
9150 // ahead of any downstream diagnostic-shape drift. Peer of the
9151 // sibling standalone-ctor equivalence + routing pins on the sibling
9152 // one-off variants across `UpgradeError`
9153 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9154 // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9155 // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9156 // on the paired three-slot `{ from, kind, module }` envelope;
9157 // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9158 // one-slot `{ from }` envelope).
9159
9160 #[test]
9161 fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9162 // Equivalence pin: the ctor produces byte-equal
9163 // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9164 // open-coded four-field struct-literal on the same `(&str,
9165 // &Path, &'static str, &str)` fixture. Guards any future
9166 // field-addition / reordering / string-conversion tweak on the
9167 // variant. Same equivalence-pin shape as the sibling
9168 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9169 // (9752da1) on the peer three-slot envelope.
9170 let from = "0.1.0";
9171 let script = Path::new("lib/m.lisp");
9172 let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9173 let prior_cleanup_module = "x-old";
9174 assert_eq!(
9175 UpgradeError::state_change_after_cleanup(
9176 from,
9177 script,
9178 prior_cleanup_kind,
9179 prior_cleanup_module,
9180 ),
9181 UpgradeError::StateChangeAfterCleanup {
9182 from: from.to_string(),
9183 script: script.to_path_buf(),
9184 prior_cleanup_kind,
9185 prior_cleanup_module: prior_cleanup_module.to_string(),
9186 },
9187 "generated state_change_after_cleanup ctor must produce \
9188 byte-equal UpgradeError to the open-coded struct-literal \
9189 wrap on the same (&str, &Path, &'static str, &str) fixture",
9190 );
9191 }
9192
9193 #[test]
9194 fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9195 // Cross-axis routing pin: sweep the four constructor input
9196 // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9197 // &'static str`, `prior_cleanup_module: &str`) through
9198 // distinct-per-axis fixtures across every cleanup-family
9199 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9200 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9201 // build-metadata, zero), sibling-`.lisp` script-path shapes
9202 // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9203 // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9204 // lowercase / trim / truncate / silent axis-swap
9205 // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9206 // `from`, `prior_cleanup_kind` misrouted onto
9207 // `prior_cleanup_module`) on the four-field construction
9208 // surfaces at assert time rather than at a downstream diagnostic
9209 // consumer that reads the fields back and gets a different value
9210 // than the one it stored. Both `&str`-literal and `&String` (via
9211 // Deref coercion) carriers are exercised for `from` /
9212 // `prior_cleanup_module` because the sole wire-up hands
9213 // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9214 // (also `&str`, from `declared_module().expect(…)`) — the ctor
9215 // must accept both shapes without a pre-conversion. Both
9216 // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9217 // are exercised for `script` because the sole wire-up hands a
9218 // `&PathBuf` sticky-latch projection from `declared_path()`'s
9219 // `Option<&PathBuf>` return — the ctor must accept both shapes
9220 // without a pre-conversion.
9221 let kinds: [&'static str; 2] = [
9222 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9223 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9224 ];
9225 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9226 let scripts: [&str; 3] = [
9227 "m.lisp",
9228 "lib/migrations.lisp",
9229 "lib/migrations/v01/step-1.lisp",
9230 ];
9231 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9232 for kind in kinds {
9233 for from in froms {
9234 for script_str in scripts {
9235 for module in modules {
9236 let from_owned: String = from.to_string();
9237 let module_owned: String = module.to_string();
9238 let script_path = Path::new(script_str);
9239 let script_pathbuf = PathBuf::from(script_str);
9240 for (from_in, module_in, script_in) in [
9241 (from, module, script_path),
9242 (
9243 from_owned.as_str(),
9244 module_owned.as_str(),
9245 script_pathbuf.as_path(),
9246 ),
9247 ] {
9248 assert_eq!(
9249 UpgradeError::state_change_after_cleanup(
9250 from_in, script_in, kind, module_in,
9251 ),
9252 UpgradeError::StateChangeAfterCleanup {
9253 from: from.to_string(),
9254 script: PathBuf::from(script_str),
9255 prior_cleanup_kind: kind,
9256 prior_cleanup_module: module.to_string(),
9257 },
9258 "state_change_after_cleanup must route from → from, \
9259 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9260 prior_cleanup_module → prior_cleanup_module in declared \
9261 field order verbatim on ({from:?}, {script_str:?}, \
9262 {kind:?}, {module:?})",
9263 );
9264 }
9265 }
9266 }
9267 }
9268 }
9269 }
9270
9271 #[test]
9272 fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9273 // End-to-end wire-up pin: build an entry whose declared
9274 // `:instructions` list places a `:soft-purge` (and separately a
9275 // `:purge`) before a `:state-change` so
9276 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9277 // migrate-family sticky-latch dispatch surfaces
9278 // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9279 // observed `Err` byte-equals the substrate-primitive
9280 // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9281 // the same fixture. A future silent de-lift of the wire-up back
9282 // to the open-coded struct-literal (or a silent axis-swap on
9283 // the four-field construction at the wire-up site) trips at
9284 // caixa-core test time rather than at a downstream diagnostic
9285 // consumer far from the wire-up commit. Same end-to-end-wire-up
9286 // discipline as the sibling
9287 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9288 // on the peer load → cleanup ordering gate; both key off
9289 // exactly one typed dispatch on the substrate primitive. Every
9290 // entry here front-loads a `:load-module` so the sole surviving
9291 // ordering refusal is the migrate → cleanup one this gate
9292 // owns — the peer `validate_purge_ordering` load → cleanup gate
9293 // returns `Ok(())` on these fixtures, so the migrate-after-
9294 // cleanup arm is the only path to an `Err`.
9295 let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9296 (
9297 "0.1.0",
9298 UpgradeInstruction::SoftPurge {
9299 module: "hello-rio-old".into(),
9300 },
9301 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9302 "hello-rio-old",
9303 "lib/migrations/v01.lisp",
9304 ),
9305 (
9306 "1.2.3-rc.1",
9307 UpgradeInstruction::Purge {
9308 module: "cache-v2-ancient".into(),
9309 },
9310 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9311 "cache-v2-ancient",
9312 "lib/migrations/v02.lisp",
9313 ),
9314 ];
9315 for (from, cleanup, kind, module, script_str) in cases {
9316 let script = PathBuf::from(script_str);
9317 let e = entry(
9318 from,
9319 vec![
9320 UpgradeInstruction::LoadModule {
9321 module: "hello-rio".into(),
9322 },
9323 cleanup,
9324 UpgradeInstruction::StateChange {
9325 script: script.clone(),
9326 },
9327 ],
9328 );
9329 let observed = e.validate().unwrap_err();
9330 assert_eq!(
9331 observed,
9332 UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9333 "validate_state_change_before_cleanup must route its \
9334 refusal through \
9335 UpgradeError::state_change_after_cleanup(from, script, \
9336 prior_cleanup_kind, prior_cleanup_module) on a \
9337 `:state-change` after a bare-cleanup {kind:?} entry, \
9338 byte-equal to the pre-lift open-coded struct-literal \
9339 wrap on the same fixture",
9340 );
9341 }
9342 }
9343
9344 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9345 // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9346 // (see the paired doc-block above the ctor definition) — the fold of
9347 // the last open-coded three-slot `{ from: String, module: String,
9348 // kinds: Vec<&'static str> }` struct-literal wire-up on
9349 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9350 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9351 // cleanup-family dedup arm onto one substrate primitive. A byte-
9352 // mismatched ctor body would trip the equivalence pin first, ahead of
9353 // any downstream diagnostic-shape drift. Peer of the sibling
9354 // standalone-ctor equivalence + routing pins on the sibling one-off
9355 // variants across `UpgradeError`
9356 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9357 // paired three-slot `{ from, kind, module }` envelope for the sibling
9358 // load → cleanup ordering axis;
9359 // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9360 // the paired four-slot `{ from, script, prior_cleanup_kind,
9361 // prior_cleanup_module }` envelope for the migrate → cleanup
9362 // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9363 // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9364 // `:from` gate).
9365
9366 #[test]
9367 fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9368 // Equivalence pin: the ctor produces byte-equal
9369 // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9370 // three-field struct-literal on the same `(&str, &str,
9371 // Vec<&'static str>)` fixture. Guards any future field-addition /
9372 // reordering / string-conversion tweak on the variant. Same
9373 // equivalence-pin shape as the sibling
9374 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9375 // (9752da1) on the peer three-slot envelope.
9376 let from = "0.1.0";
9377 let module = "x-old";
9378 let kinds: Vec<&'static str> = vec![
9379 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9380 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9381 ];
9382 assert_eq!(
9383 UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9384 UpgradeError::DuplicateCleanup {
9385 from: from.to_string(),
9386 module: module.to_string(),
9387 kinds,
9388 },
9389 "generated duplicate_cleanup ctor must produce byte-equal \
9390 UpgradeError to the open-coded struct-literal wrap on the \
9391 same (&str, &str, Vec<&'static str>) fixture",
9392 );
9393 }
9394
9395 #[test]
9396 fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9397 // Cross-axis routing pin: sweep the three constructor input axes
9398 // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9399 // through distinct-per-axis fixtures across every ordered pair of
9400 // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9401 // four `(prior_kind, kind)` combinations `validate_cleanup_
9402 // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9403 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9404 // build-metadata, zero) + DNS-1123 module shapes (leaf,
9405 // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9406 // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9407 // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9408 // owned `Vec<&'static str>`) on the three-field construction
9409 // surfaces at assert time rather than at a downstream diagnostic
9410 // consumer that reads the fields back and gets a different value
9411 // than the one it stored. Both `&str`-literal and `&String` (via
9412 // Deref coercion) carriers are exercised for `from` / `module`
9413 // because the sole wire-up hands `self.prior_versao()` (a `&str`
9414 // accessor) and `module` (also `&str`, from `declared_module().
9415 // expect(…)`) — the ctor must accept both shapes without a
9416 // pre-conversion.
9417 let all_kinds: [&'static str; 2] = [
9418 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9419 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9420 ];
9421 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9422 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9423 for prior_kind in all_kinds {
9424 for kind in all_kinds {
9425 for from in froms {
9426 for module in modules {
9427 let from_owned: String = from.to_string();
9428 let module_owned: String = module.to_string();
9429 for (from_in, module_in) in
9430 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9431 {
9432 let kinds: Vec<&'static str> = vec![prior_kind, kind];
9433 assert_eq!(
9434 UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
9435 UpgradeError::DuplicateCleanup {
9436 from: from.to_string(),
9437 module: module.to_string(),
9438 kinds,
9439 },
9440 "duplicate_cleanup must route from → from, \
9441 module → module, kinds → kinds in declared \
9442 field order verbatim on ({from:?}, \
9443 {module:?}, [{prior_kind:?}, {kind:?}])",
9444 );
9445 }
9446 }
9447 }
9448 }
9449 }
9450 }
9451
9452 #[test]
9453 fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
9454 // End-to-end wire-up pin: build an entry whose declared
9455 // `:instructions` list front-loads a `:load-module` (so the
9456 // sibling `validate_purge_ordering` load → cleanup gate returns
9457 // `Ok(())` on the fixture) and then places two cleanup
9458 // instructions targeting the same module so
9459 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9460 // cleanup-family dedup arm surfaces
9461 // `UpgradeError::DuplicateCleanup`, then pin that the observed
9462 // `Err` byte-equals the substrate-primitive
9463 // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
9464 // fixture. A future silent de-lift of the wire-up back to the
9465 // open-coded struct-literal (or a silent axis-swap on the three-
9466 // field construction at the wire-up site, or a kinds-pair
9467 // reorder) trips at caixa-core test time rather than at a
9468 // downstream diagnostic consumer far from the wire-up commit.
9469 // Same end-to-end-wire-up discipline as the sibling
9470 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9471 // on the peer load → cleanup ordering gate and
9472 // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
9473 // on the peer migrate → cleanup boundary; all three key off
9474 // exactly one typed dispatch on the substrate primitive.
9475 let cases: [(
9476 &str,
9477 UpgradeInstruction,
9478 UpgradeInstruction,
9479 &str,
9480 [&'static str; 2],
9481 ); 4] = [
9482 (
9483 "0.1.0",
9484 UpgradeInstruction::SoftPurge {
9485 module: "hello-rio-old".into(),
9486 },
9487 UpgradeInstruction::SoftPurge {
9488 module: "hello-rio-old".into(),
9489 },
9490 "hello-rio-old",
9491 [
9492 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9493 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9494 ],
9495 ),
9496 (
9497 "1.2.3-rc.1",
9498 UpgradeInstruction::Purge {
9499 module: "cache-v2-ancient".into(),
9500 },
9501 UpgradeInstruction::Purge {
9502 module: "cache-v2-ancient".into(),
9503 },
9504 "cache-v2-ancient",
9505 [
9506 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9507 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9508 ],
9509 ),
9510 (
9511 "0.2.0-alpha.7+build.5",
9512 UpgradeInstruction::SoftPurge {
9513 module: "x-old".into(),
9514 },
9515 UpgradeInstruction::Purge {
9516 module: "x-old".into(),
9517 },
9518 "x-old",
9519 [
9520 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9521 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9522 ],
9523 ),
9524 (
9525 "0.0.0",
9526 UpgradeInstruction::Purge {
9527 module: "x-old".into(),
9528 },
9529 UpgradeInstruction::SoftPurge {
9530 module: "x-old".into(),
9531 },
9532 "x-old",
9533 [
9534 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9535 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9536 ],
9537 ),
9538 ];
9539 for (from, first, second, module, kinds) in cases {
9540 let e = entry(
9541 from,
9542 vec![
9543 UpgradeInstruction::LoadModule {
9544 module: "hello-rio".into(),
9545 },
9546 first,
9547 second,
9548 ],
9549 );
9550 let observed = e.validate().unwrap_err();
9551 assert_eq!(
9552 observed,
9553 UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
9554 "validate_cleanup_singularity must route its refusal \
9555 through UpgradeError::duplicate_cleanup(from, module, \
9556 kinds) on a two-cleanup {kinds:?} entry targeting the \
9557 same module, byte-equal to the pre-lift open-coded \
9558 struct-literal wrap on the same fixture",
9559 );
9560 }
9561 }
9562
9563 #[test]
9564 fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
9565 // Equivalence pin: the ctor produces byte-equal
9566 // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
9567 // three-field struct-literal on the same `(&str, usize,
9568 // Vec<&'static str>)` fixture. Guards any future field-addition /
9569 // reordering / string-conversion tweak on the variant. Same
9570 // equivalence-pin shape as the sibling
9571 // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
9572 // on the peer three-slot envelope.
9573 let from = "0.1.0";
9574 let restart_count: usize = 1;
9575 let other_kinds: Vec<&'static str> =
9576 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
9577 assert_eq!(
9578 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9579 UpgradeError::RestartNotExclusive {
9580 from: from.to_string(),
9581 restart_count,
9582 other_kinds,
9583 },
9584 "generated restart_not_exclusive ctor must produce byte-equal \
9585 UpgradeError to the open-coded struct-literal wrap on the \
9586 same (&str, usize, Vec<&'static str>) fixture",
9587 );
9588 }
9589
9590 #[test]
9591 fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
9592 // Cross-axis routing pin: sweep the three constructor input axes
9593 // (`from: &str`, `restart_count: usize`, `other_kinds:
9594 // Vec<&'static str>`) through distinct-per-axis fixtures across a
9595 // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
9596 // pre-release + build-metadata, zero) × non-degenerate
9597 // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
9598 // pure-duplication shape, 3 — the deeply-duplicated shape) ×
9599 // ordered `other_kinds` lisp-form lists spanning the four
9600 // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
9601 // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
9602 // empty (the `((:restart) (:restart))` shape), singleton
9603 // (`((:load-module …) (:restart))`), and the full typed sequence
9604 // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
9605 // …) (:restart))`) — so any wrapper-side silent lowercase / trim
9606 // / truncate / silent axis-swap (`from` ↔ swap onto
9607 // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
9608 // duplicate on the four-element owned `Vec<&'static str>`,
9609 // `other_kinds` reorder against declared instruction order) on
9610 // the three-field construction surfaces at assert time rather
9611 // than at a downstream diagnostic consumer that reads the fields
9612 // back and gets a different value than the one it stored. Both
9613 // `&str`-literal and `&String` (via Deref coercion) carriers are
9614 // exercised for `from` because the sole wire-up hands
9615 // `self.prior_versao()` (a `&str` accessor).
9616 let all_typed_kinds: [&'static str; 4] = [
9617 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9618 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9619 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9620 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9621 ];
9622 let other_kinds_matrix: [Vec<&'static str>; 3] =
9623 [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
9624 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9625 let restart_counts: [usize; 3] = [1, 2, 3];
9626 for other_kinds in &other_kinds_matrix {
9627 for restart_count in restart_counts {
9628 for from in froms {
9629 let from_owned: String = from.to_string();
9630 for from_in in [from, from_owned.as_str()] {
9631 assert_eq!(
9632 UpgradeError::restart_not_exclusive(
9633 from_in,
9634 restart_count,
9635 other_kinds.clone(),
9636 ),
9637 UpgradeError::RestartNotExclusive {
9638 from: from.to_string(),
9639 restart_count,
9640 other_kinds: other_kinds.clone(),
9641 },
9642 "restart_not_exclusive must route from → from, \
9643 restart_count → restart_count, other_kinds → \
9644 other_kinds in declared field order verbatim \
9645 on ({from:?}, {restart_count:?}, \
9646 {other_kinds:?})",
9647 );
9648 }
9649 }
9650 }
9651 }
9652 }
9653
9654 #[test]
9655 fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
9656 // End-to-end wire-up pin: sweep the three canonical exclusivity-
9657 // violation shapes the `validate_restart_exclusive` gate can
9658 // refuse — restart + one typed instruction (`restart_count: 1,
9659 // other_kinds: [load-module]`), restart + full typed sequence
9660 // (`restart_count: 1, other_kinds: [load-module, state-change,
9661 // soft-purge, purge]`), and duplicated restart only
9662 // (`restart_count: 2, other_kinds: []`) — and pin that each
9663 // observed `Err` byte-equals the substrate-primitive
9664 // [`UpgradeError::restart_not_exclusive`] ctor's output on the
9665 // same fixture. A future silent de-lift of the wire-up back to
9666 // the open-coded struct-literal (or a silent axis-swap on the
9667 // three-field construction at the wire-up site, or an
9668 // `other_kinds` reorder / drop) trips at caixa-core test time
9669 // rather than at a downstream diagnostic consumer far from the
9670 // wire-up commit. Same end-to-end-wire-up discipline as the
9671 // sibling
9672 // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
9673 // (10a5b48) on the peer per-module cleanup-singularity axis and
9674 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9675 // on the peer load → cleanup ordering gate; all three key off
9676 // exactly one typed dispatch on the substrate primitive.
9677 let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
9678 (
9679 "0.1.0",
9680 vec![
9681 UpgradeInstruction::LoadModule {
9682 module: "hello-rio".into(),
9683 },
9684 UpgradeInstruction::Restart,
9685 ],
9686 1,
9687 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
9688 ),
9689 (
9690 "1.2.3-rc.1",
9691 vec![
9692 UpgradeInstruction::LoadModule {
9693 module: "hello-rio".into(),
9694 },
9695 UpgradeInstruction::StateChange {
9696 script: PathBuf::from("lib/m.lisp"),
9697 },
9698 UpgradeInstruction::SoftPurge {
9699 module: "hello-rio-old".into(),
9700 },
9701 UpgradeInstruction::Purge {
9702 module: "hello-rio-old".into(),
9703 },
9704 UpgradeInstruction::Restart,
9705 ],
9706 1,
9707 vec![
9708 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9709 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9710 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9711 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9712 ],
9713 ),
9714 (
9715 "0.0.0",
9716 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
9717 2,
9718 vec![],
9719 ),
9720 ];
9721 for (from, instructions, restart_count, other_kinds) in cases {
9722 let e = entry(from, instructions);
9723 let observed = e.validate().unwrap_err();
9724 assert_eq!(
9725 observed,
9726 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9727 "validate_restart_exclusive must route its refusal \
9728 through UpgradeError::restart_not_exclusive(from, \
9729 restart_count, other_kinds) on a mixed-`(:restart)` \
9730 entry, byte-equal to the pre-lift open-coded struct-\
9731 literal wrap on the same fixture",
9732 );
9733 }
9734 }
9735
9736 #[test]
9737 fn module_invalid_ctor_matches_struct_literal_wrap() {
9738 // Fail-before-pass-after equivalence pin on
9739 // [`UpgradeError::module_invalid`] — the constructor must
9740 // produce a byte-equal `UpgradeError` to the pre-lift open-
9741 // coded `Self::ModuleInvalid { kind, module: module.to_string(),
9742 // reason }` struct-literal on the same `(:load-module …)` /
9743 // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
9744 // mismatched constructor body (a stray `.trim()`, a rebased
9745 // field order, a `String::new()` reason substitution) would
9746 // trip this pin first, byte-for-byte against the sibling
9747 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
9748 // [`crate::SupervisorError::child_caixa_invalid`] /
9749 // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
9750 // discipline on the peer three-slot `{ *, reason: String }`
9751 // invalid-arm ctor family.
9752 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
9753 let module = "Hello-Rio";
9754 let reason = "must be lowercase alphanumeric or `-`";
9755 assert_eq!(
9756 UpgradeError::module_invalid(kind, module, reason),
9757 UpgradeError::ModuleInvalid {
9758 kind,
9759 module: module.to_string(),
9760 reason: reason.to_string(),
9761 },
9762 "generated module_invalid ctor must produce byte-equal \
9763 UpgradeError to the open-coded struct-literal wrap on the \
9764 same (kind, module, reason) fixture",
9765 );
9766 }
9767
9768 #[test]
9769 fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
9770 // Cross-axis pin: sweep the constructor's `kind: &'static str`
9771 // input across every [`UpgradeInstruction::declared_module`]-
9772 // bearing variant's canonical
9773 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
9774 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
9775 // canonical `":phantom"` fourth arm proving the ctor does not
9776 // silently clamp `kind` to the three-arm roster. The
9777 // `reason: impl Into<String>` bound accepts both `&str`
9778 // literals and the [`String`] the underlying
9779 // [`crate::render::is_dns_1123_label`] predicate returns via
9780 // `.into()`, matching the peer
9781 // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
9782 // sweep on the sibling `:contratos` per-edge envelope.
9783 let module = "Hello-Rio";
9784 let reason = "must be lowercase alphanumeric or `-`";
9785 for kind in [
9786 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9787 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9788 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9789 ":phantom",
9790 ] {
9791 assert_eq!(
9792 UpgradeError::module_invalid(kind, module, reason),
9793 UpgradeError::ModuleInvalid {
9794 kind,
9795 module: module.to_string(),
9796 reason: reason.to_string(),
9797 },
9798 "module_invalid ctor must thread kind={kind:?} verbatim",
9799 );
9800 }
9801 }
9802
9803 #[test]
9804 fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
9805 // End-to-end wire-up pin: [`validate_module`]'s
9806 // [`crate::render::require_valid_dns_1123_label`] invalid-arm
9807 // must emit a diagnostic byte-equal to the ctor's output on the
9808 // same `(kind, module)` fixture — the fold's invariant that
9809 // [`validate_module`]'s cascade reaches the
9810 // [`UpgradeError::ModuleInvalid`] envelope through the
9811 // substrate primitive [`UpgradeError::module_invalid`] rather
9812 // than the pre-lift open-coded struct-literal. Sweep every
9813 // [`UpgradeInstruction::declared_module`]-bearing variant
9814 // against a canonical footgun (`"Hello-Rio"` — the uppercase-
9815 // lead footgun the peer `validate_rejects_non_dns_1123_module`
9816 // test above already carries) so every wire-up on the invalid-
9817 // arm cascade lands on the ctor's output. Matches the peer
9818 // sibling end-to-end pin
9819 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
9820 // carries on `validate_contrato_caixa`'s
9821 // `require_valid_dns_1123_label` invalid-arm.
9822 let module = "Hello-Rio";
9823 let cases: &[(UpgradeInstruction, &'static str)] = &[
9824 (
9825 UpgradeInstruction::LoadModule {
9826 module: module.to_string(),
9827 },
9828 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9829 ),
9830 (
9831 UpgradeInstruction::SoftPurge {
9832 module: module.to_string(),
9833 },
9834 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9835 ),
9836 (
9837 UpgradeInstruction::Purge {
9838 module: module.to_string(),
9839 },
9840 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9841 ),
9842 ];
9843 for (instr, expected_kind) in cases {
9844 let observed = instr.validate().unwrap_err();
9845 let UpgradeError::ModuleInvalid {
9846 reason: observed_reason,
9847 ..
9848 } = &observed
9849 else {
9850 panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
9851 };
9852 assert_eq!(
9853 observed,
9854 UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
9855 "validate_module must route its invalid-arm refusal \
9856 through UpgradeError::module_invalid(kind, module, \
9857 reason) on {instr:?}, byte-equal to the pre-lift open-\
9858 coded struct-literal wrap on the same fixture",
9859 );
9860 }
9861 }
9862
9863 #[test]
9864 fn module_empty_ctor_matches_struct_literal_wrap() {
9865 // Fail-before-pass-after equivalence pin on
9866 // [`UpgradeError::module_empty`] — the constructor must produce
9867 // a byte-equal `UpgradeError` to the pre-lift open-coded
9868 // `Self::ModuleEmpty { kind }` struct-literal on the same
9869 // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
9870 // axis-tag fixture. A byte-mismatched constructor body (a stray
9871 // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
9872 // one of the three canonical arms, a fixed-slot substitution)
9873 // would trip this pin first, matching the sibling
9874 // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
9875 // [`crate::behavior::BehaviorError::empty_path`] per-envelope
9876 // pin discipline on the peer one-slot `{ *: &'static str }`
9877 // empty-arm ctor family.
9878 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
9879 assert_eq!(
9880 UpgradeError::module_empty(kind),
9881 UpgradeError::ModuleEmpty { kind },
9882 "generated module_empty ctor must produce byte-equal \
9883 UpgradeError to the open-coded struct-literal wrap on the \
9884 same kind fixture",
9885 );
9886 }
9887
9888 #[test]
9889 fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
9890 // Cross-axis pin: sweep the constructor's `kind: &'static str`
9891 // input across every [`UpgradeInstruction::declared_module`]-
9892 // bearing variant's canonical
9893 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
9894 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
9895 // canonical `":phantom"` fourth arm proving the ctor does not
9896 // silently clamp `kind` to the three-arm roster (a future
9897 // fourth `declared_module`-bearing `UpgradeInstruction` variant
9898 // lands on this ctor without a per-arm rewrite). Matches the
9899 // sibling [`Self::module_invalid`] cross-axis sweep at
9900 // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
9901 // on the paired three-slot invalid-arm envelope so both arms of
9902 // the [`validate_module`] two-closure cascade carry the same
9903 // axis-invariance guarantee.
9904 for kind in [
9905 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9906 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9907 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9908 ":phantom",
9909 ] {
9910 assert_eq!(
9911 UpgradeError::module_empty(kind),
9912 UpgradeError::ModuleEmpty { kind },
9913 "module_empty ctor must thread kind={kind:?} verbatim",
9914 );
9915 }
9916 }
9917
9918 #[test]
9919 fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
9920 // End-to-end wire-up pin: [`validate_module`]'s
9921 // [`crate::render::require_valid_dns_1123_label`] empty-arm
9922 // must emit a diagnostic byte-equal to the ctor's output on the
9923 // same `(kind, "")` fixture — the fold's invariant that
9924 // [`validate_module`]'s cascade reaches the
9925 // [`UpgradeError::ModuleEmpty`] envelope through the substrate
9926 // primitive [`UpgradeError::module_empty`] rather than the
9927 // pre-lift open-coded struct-literal. Sweep every
9928 // [`UpgradeInstruction::declared_module`]-bearing variant
9929 // against the empty-string module value so every wire-up on the
9930 // empty-arm cascade lands on the ctor's output. Closes the pair
9931 // on the [`validate_module`] two-closure cascade the sibling
9932 // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
9933 // (3d0d64a) already anchors on the invalid-arm.
9934 let cases: &[(UpgradeInstruction, &'static str)] = &[
9935 (
9936 UpgradeInstruction::LoadModule {
9937 module: String::new(),
9938 },
9939 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9940 ),
9941 (
9942 UpgradeInstruction::SoftPurge {
9943 module: String::new(),
9944 },
9945 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9946 ),
9947 (
9948 UpgradeInstruction::Purge {
9949 module: String::new(),
9950 },
9951 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9952 ),
9953 ];
9954 for (instr, expected_kind) in cases {
9955 assert_eq!(
9956 instr.validate().unwrap_err(),
9957 UpgradeError::module_empty(expected_kind),
9958 "validate_module must route its empty-arm refusal \
9959 through UpgradeError::module_empty(kind) on {instr:?}, \
9960 byte-equal to the pre-lift open-coded struct-literal \
9961 wrap on the same fixture",
9962 );
9963 }
9964 }
9965}