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()).map_err(|e| UpgradeError::FromInvalid {
324 from: self.prior_versao().to_string(),
325 reason: e.to_string(),
326 })?;
327 // Per-instruction typed shape: kind-tagged `:module` /
328 // `:script` value-shape gates fire here, *before* the
329 // within-entry restart-exclusivity gate below — so a
330 // malformed-shape diagnostic on a Module/Script-bearing
331 // instruction surfaces with its narrower self-locating
332 // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
333 // `AbsoluteScript`, `ParentEscapeScript`) rather than
334 // collapsing two unrelated authoring errors into a single
335 // exclusivity diagnostic. Same empty-first cascade discipline
336 // every peer DNS-1123 / path-shape gate inside this module
337 // uses (`validate_module`'s ModuleEmpty arm precedes the
338 // DNS-1123 predicate; `validate` on `StateChange` consults
339 // the lifted `is_sandboxed_relative_path` shape gate first).
340 // Route the per-instruction shape-check fan-out through the
341 // lifted [`Self::instructions`] slice-return accessor rather
342 // than the raw `self.instructions` field access — first of
343 // nine paired production consumers of the per-`:upgrade-from
344 // :instructions` OTP-appup migration-instruction-list surface
345 // that now key off exactly one typed dispatch on the substrate
346 // primitive.
347 for instr in self.instructions() {
348 instr.validate()?;
349 }
350 self.validate_restart_exclusive()?;
351 self.validate_state_change_ordering()?;
352 self.validate_purge_ordering()?;
353 self.validate_state_change_before_cleanup()?;
354 self.validate_load_singularity()?;
355 self.validate_state_change_singularity()?;
356 self.validate_cleanup_singularity()?;
357 Ok(())
358 }
359
360 /// Reject `:upgrade-from :instructions` lists that carry
361 /// `(:restart)` alongside any other instruction, or that carry
362 /// more than one `(:restart)`. The valid Restart-bearing shape is
363 /// exactly `((:restart))` — a single `Restart` as the entry's
364 /// whole instructions list.
365 ///
366 /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
367 /// is the *fallback* for an entry whose typed upgrade is
368 /// impossible (wasm component-model world incompatibility,
369 /// irreversible state shape change). The fallback is terminal by
370 /// construction: the operator restarts the pod and the new version
371 /// comes up fresh, so any other instructions in the same entry
372 /// are dead code in both directions — either the typed sequence
373 /// would have succeeded and `(:restart)` is unreached, or it
374 /// wouldn't and the typed instructions are dead because the
375 /// operator restarts anyway. Two canonical authoring footguns
376 /// close here:
377 ///
378 /// - `((:load-module …) (:state-change …) (:restart))` — the
379 /// "I'll try the typed path *then* restart anyway" footgun.
380 /// There is no coherent OTP-shaped semantic for this: if the
381 /// typed sequence succeeds, the trailing restart discards the
382 /// work that just succeeded (defeating the whole point of
383 /// declaring it); if it fails, the restart is never reached
384 /// because the entry already failed.
385 /// - `((:restart) (:restart))` — multiple `Restart` variants in
386 /// one entry. The fallback is a single semantic; repeating it
387 /// is at best redundant, at worst suggests the author thought
388 /// the second one would re-trigger after the first.
389 ///
390 /// Same within-entry exclusivity discipline OTP's `relup` enforces
391 /// at the `restart_new_emulator | restart_emulator` instruction
392 /// boundary — those instructions are terminal in the upgrade
393 /// script (`systools(3)` rejects sequences that continue past
394 /// them); pleme-io lifts the same shape to a build-time gate,
395 /// matching the CAIXA-SDLC §III "build errors, not runtime
396 /// surprises" frame.
397 ///
398 /// Same within-entry cross-instruction discipline the
399 /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
400 /// shard-key partition (934bc58) and
401 /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
402 /// precedence partition (de7ab1a) apply on cross-slot axes — now
403 /// extended onto the first within-list cross-instruction axis on
404 /// the `:upgrade-from` typed slot.
405 fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
406 // Route the paired restart-count / instructions-len / other-
407 // kind projections through the lifted [`Self::instructions`]
408 // slice-return accessor rather than the raw `self.instructions`
409 // field access — three raw-access sites in one gate collapse
410 // onto exactly one typed dispatch on the substrate primitive.
411 //
412 // The paired positive / negated `Self::Restart` arm-discriminator
413 // predicates route through the `gen_platform::IsVariant`
414 // derive-generated [`UpgradeInstruction::is_restart`] rather than
415 // the raw `matches!(i, UpgradeInstruction::Restart)` /
416 // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
417 // matches — same closed-set-typed-enum arm-discriminator dispatch
418 // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
419 // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
420 // / `!= CaixaKind::X` production sites in the substrate's own
421 // layout invariant verifier + typed-view projection gates,
422 // extended here onto the last unlifted `matches!`-based
423 // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
424 // typed enum. A future sixth `UpgradeInstruction` arm (an
425 // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
426 // wasm-operator's hot-upgrade runtime could adopt to bracket the
427 // typed instruction sequence against a per-cluster readiness
428 // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
429 // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
430 // materializer could resolve per-CR) migrates as a single
431 // enum-declaration edit — the derive auto-generates the paired
432 // `.is_<new_arm>()` predicate; every consumer inherits the new
433 // arm on the next re-derive, rather than the two `matches!` sites
434 // here having to be threaded through in lockstep.
435 let instructions = self.instructions();
436 let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
437 if restart_count == 0 {
438 return Ok(());
439 }
440 if restart_count == 1 && instructions.len() == 1 {
441 return Ok(());
442 }
443 let other_kinds: Vec<&'static str> = instructions
444 .iter()
445 .filter(|i| !i.is_restart())
446 .map(UpgradeInstruction::lisp_form)
447 .collect();
448 Err(UpgradeError::RestartNotExclusive {
449 from: self.prior_versao().to_string(),
450 restart_count,
451 other_kinds,
452 })
453 }
454
455 /// Reject an entry whose `(:state-change …)` is not preceded by a
456 /// `(:load-module …)` in the same `:instructions` list.
457 ///
458 /// `StateChange` is the `gen_server:code_change/3` analog
459 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
460 /// it runs the migration script that folds the *old* state into the
461 /// shape the *new* code expects. In OTP, `code_change/3` is invoked
462 /// in the context of the newly-loaded code — `release_handler`
463 /// always loads the new module before running the advanced update
464 /// that triggers the callback. caixa decomposes that into two
465 /// explicit instructions (`LoadModule` brings the new version up
466 /// "alongside the current one"; `StateChange` migrates the state),
467 /// and the module doc pins that the operator "runs the instructions
468 /// in order" and only swaps traffic after all succeed. So a
469 /// `:state-change` with no preceding `:load-module` migrates state
470 /// into code that was never loaded — the migration script runs while
471 /// the only resident version is still the *old* one, which expects
472 /// the *old* state. Two authoring footguns close here:
473 ///
474 /// - `((:state-change "…"))` — the "I wrote the migration but
475 /// forgot to load the new module" footgun. The new code that
476 /// defines the new state representation (and that the migration
477 /// output is destined for) never comes up; the operator runs
478 /// the script against the old code and either no-ops or corrupts
479 /// live state.
480 /// - `((:state-change "…") (:load-module "…"))` — the
481 /// right-instructions-wrong-order footgun. Because the operator
482 /// executes in declared order, the migration runs *before* the
483 /// new code is resident, then the load brings up code expecting
484 /// already-migrated state that the just-run script produced
485 /// against the old version's shape. The canonical order is
486 /// `(:load-module …) (:state-change …) (:soft-purge …)`
487 /// (module doc example).
488 ///
489 /// Same within-entry cross-instruction discipline as
490 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
491 /// exclusivity gate it runs beside): both reject an
492 /// `:instructions` list whose instructions are individually
493 /// well-shaped but jointly incoherent, at the typed build surface
494 /// rather than as a runtime surprise. Runs *after*
495 /// `validate_restart_exclusive` so a `((:state-change …)
496 /// (:restart))` shape still surfaces the more-fundamental
497 /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
498 /// alone, so no Restart-bearing entry reaches this gate carrying a
499 /// `StateChange`).
500 fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
501 // Route the per-instruction load-family arm-discriminator through
502 // the `gen_platform::IsVariant`-derive-generated
503 // [`UpgradeInstruction::is_load_module`] predicate and the
504 // per-instruction migration-family `:script` scalar projection
505 // through the sibling lifted [`UpgradeInstruction::declared_path`]
506 // `Option<&PathBuf>` accessor rather than the raw two-arm
507 // `match instr { UpgradeInstruction::LoadModule { .. } =>
508 // loaded = true, UpgradeInstruction::StateChange { script } if
509 // !loaded => …, _ => {} }` open-coded pattern-match — closes the
510 // last unlifted `match`-shaped per-arm-hand-rolled load-family
511 // arm-discriminator + migration-family script-projection pair
512 // inside `impl UpgradeFromEntry`. Sibling of the peer
513 // [`Self::validate_purge_ordering`] (580d0f1) routing already
514 // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
515 // load → cleanup ordering axis, the peer
516 // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
517 // onto the [`UpgradeInstruction::is_load_module`] +
518 // [`UpgradeInstruction::declared_module`] pair on the singularity
519 // axis, and the peer [`Self::validate_state_change_singularity`]
520 // routing already lifted onto the sibling
521 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
522 // accessor on the migration-family script-projection axis — both
523 // ordering-gate load-family sticky-latch dispatches now key off
524 // exactly one typed dispatch on the substrate primitive for
525 // their load-family arm-discriminator, and both migration-family
526 // projection sites (this ordering gate + the peer singularity
527 // gate) now key off exactly one typed dispatch on the substrate
528 // primitive for the `:script`-carrying axis. A future sixth arm
529 // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
530 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
531 // `CanaryTraffic` split-traffic variant the M4 CR materializer
532 // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
533 // enum-declaration edit through the derive rather than a
534 // coordinated rewrite of every ordering / singularity gate's
535 // per-arm hand-rolled pattern-match. Byte-identity of this
536 // dispatch against the pre-lift `match` shape is pinned by
537 // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
538 let mut loaded = false;
539 for instr in self.instructions() {
540 if instr.is_load_module() {
541 loaded = true;
542 } else if !loaded && let Some(script) = instr.declared_path() {
543 return Err(UpgradeError::state_change_without_prior_load(
544 self.prior_versao(),
545 script,
546 ));
547 }
548 }
549 Ok(())
550 }
551
552 /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
553 /// preceded by a `(:load-module …)` in the same `:instructions` list.
554 ///
555 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
556 /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
557 /// *old* module from memory after the new one is resident. OTP's
558 /// two-phase code load is `code:load_module/1` *then*
559 /// `code:soft_purge/1` — load the new version alongside the old
560 /// (both in memory, new requests route to new), then purge the old
561 /// after in-flight callers drain. caixa decomposes that into two
562 /// explicit instructions (`LoadModule` brings the new version up
563 /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
564 /// doc; `SoftPurge` "waits for in-flight requests on a named module
565 /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
566 /// and the module doc pins that the operator "runs the instructions
567 /// in order". So a `:soft-purge` / `:purge` with no preceding
568 /// `:load-module` purges old code while the only resident version is
569 /// still the *same* old code, leaving the upgrade entry asking the
570 /// operator to drain or discard the live module with no replacement
571 /// resident. Two authoring footguns close here:
572 ///
573 /// - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
574 /// cleanup but forgot to load the new module" footgun. The new
575 /// code never comes up alongside; the operator either drains the
576 /// old version to nothing (`SoftPurge`) or discards it outright
577 /// mid-request (`Purge`), with no replacement to route in-flight
578 /// or future requests to.
579 /// - `((:soft-purge "…") (:load-module "…"))` /
580 /// `((:purge "…") (:load-module "…"))` — the right-instructions-
581 /// wrong-order footgun. Because the operator executes in declared
582 /// order, the cleanup runs *before* the new code is resident,
583 /// leaving a window during which neither version is available;
584 /// the canonical order is `(:load-module …) (:state-change …)
585 /// (:soft-purge …)` (module doc example).
586 ///
587 /// Same within-entry cross-instruction discipline as
588 /// [`Self::validate_state_change_ordering`] (the `:state-change`-
589 /// ordering gate it runs beside): both close the same load-before-X
590 /// post-condition on the OTP appup ordering contract, now extending
591 /// the typed coverage from "new code resident before its state
592 /// migration runs" to "new code resident before the old code is
593 /// drained or discarded" — the second half of OTP's two-phase code
594 /// load. Runs *after* `validate_state_change_ordering` so an entry
595 /// like `((:state-change …) (:soft-purge …))` surfaces the more-
596 /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
597 /// are load-less, but state-change is the load-bearing semantic — the
598 /// purge is meaningless either way without a preceding load, so the
599 /// author should see the migration-side diagnostic first).
600 fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
601 let mut loaded = false;
602 for instr in self.instructions() {
603 // Route the per-instruction cleanup-family arm-discriminator
604 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
605 // predicate rather than the raw
606 // `UpgradeInstruction::SoftPurge { module } |
607 // UpgradeInstruction::Purge { module }` open-coded per-arm
608 // union pattern-match — the first of three within-entry cross-
609 // instruction cleanup-facing gates now keys off exactly one
610 // typed dispatch on the substrate primitive, so any future
611 // fifth cleanup-shaped variant (a `Discard` variant the
612 // `code:delete/1` peer inspires) added to
613 // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
614 // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
615 // through the accessor's one body. The paired cleanup-arm
616 // `:module` scalar is routed through the sibling
617 // [`UpgradeInstruction::declared_module`] accessor rather than
618 // the raw pattern-bound `module` binding — same substrate-
619 // primitive-owns-the-scalar discipline every peer
620 // per-`UpgradeInstruction` scalar-value axis already routes
621 // through, with the `is_cleanup`-implies-`declared_module`-is-
622 // `Some` composition pin at
623 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
624 // making the `.expect(…)` structurally infallible at build
625 // time. Peer of the sibling
626 // [`UpgradeFromEntry::validate_restart_exclusive`]
627 // paired positive / negated
628 // [`UpgradeInstruction::is_restart`] routing (915a934) on the
629 // per-arm terminal-fallback partition — same closed-set-typed-
630 // enum arm-discriminator dispatch discipline extended from
631 // the single-arm terminal-fallback family onto the two-arm
632 // cleanup family here.
633 //
634 // Route the paired load-family arm-discriminator through the
635 // `gen_platform::IsVariant`-derive-generated
636 // [`UpgradeInstruction::is_load_module`] predicate rather than
637 // the raw `matches!(instr, UpgradeInstruction::LoadModule
638 // { .. })` open-coded pattern-match — closes the last
639 // unlifted `matches!`-based per-variant arm-discriminator
640 // axis on the [`UpgradeInstruction`] closed-set typed enum,
641 // sibling of the [`UpgradeInstruction::is_restart`] terminal-
642 // fallback routing (915a934) and the
643 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
644 // routing (0bc469f) that already lifted the paired
645 // arm-discriminator sites in this method. Every arm-family
646 // partition the gate keys off — load-family (`LoadModule`),
647 // cleanup-family (`SoftPurge | Purge`), terminal-fallback
648 // (`Restart`) — now consults exactly one typed dispatch on
649 // the substrate primitive, so a future sixth arm added to
650 // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
651 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
652 // a `CanaryTraffic` split-traffic variant the M4 CR
653 // materializer could resolve per-CR — INSPIRATIONS §II.4)
654 // migrates as a single enum-declaration edit through the
655 // derive rather than a scattered per-consumer rewrite. The
656 // partition invariant is pinned by
657 // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
658 // and the byte-identity of this dispatch against the pre-lift
659 // `matches!` pattern by
660 // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
661 if instr.is_load_module() {
662 loaded = true;
663 } else if instr.is_cleanup() && !loaded {
664 return Err(UpgradeError::PurgeWithoutPriorLoad {
665 from: self.prior_versao().to_string(),
666 kind: instr.lisp_form(),
667 module: instr
668 .declared_module()
669 .expect("is_cleanup() implies declared_module() is Some")
670 .to_string(),
671 });
672 }
673 }
674 Ok(())
675 }
676
677 /// Reject an entry whose `(:state-change …)` appears after any
678 /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
679 /// list — completing the canonical OTP appup `code:load_module/1`
680 /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
681 /// chain on the typed `:upgrade-from` slot.
682 ///
683 /// `StateChange` is the `gen_server:code_change/3` analog
684 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
685 /// verbatim: "State migration uses `gen_server:code_change/3` …
686 /// migrate state from v0.1.0 shape to current shape"). The
687 /// callback's input is the *prior* version's state shape, which
688 /// only exists while the prior code is still resident — the running
689 /// `gen_server` processes hold the v0.1.0 state, and the operator's
690 /// dispatch invokes `code_change/3` to fold that state into the
691 /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
692 /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
693 /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
694 /// *old* module after the new one is resident. The operator runs
695 /// instructions in declared order (module doc), so a cleanup ahead
696 /// of a state-change discards the prior code before the migration
697 /// fold runs against the state it held — the canonical OTP error
698 /// mode "`code_change/3` invoked on a purged module" the
699 /// `release_handler` enforces by always emitting the migration
700 /// callback before the soft-purge step.
701 ///
702 /// `systools`-generated `.relup` files always emit `code_change`
703 /// before `soft_purge` for this reason; the appup cookbook's
704 /// canonical pattern (`[{load_module, m}, {update, m, soft},
705 /// {soft_purge, m}]`) places the migration-triggering `update`
706 /// strictly between the load and the cleanup. The caixa module
707 /// doc pins the same canonical order verbatim — `(:load-module
708 /// …) (:state-change …) (:soft-purge …)` — and this gate makes
709 /// that ordering a structural property at build time. Three
710 /// authoring footguns close here:
711 ///
712 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
713 /// "lib/m.lisp"))` — the right-instructions-wrong-order
714 /// footgun on the migrate ↔ cleanup axis. Because the operator
715 /// executes in declared order, the cleanup drains the v0.1.0
716 /// module to nothing before the migration callback runs, and
717 /// the script either no-ops (no v0.1.0 state left to fold) or
718 /// crashes (`code_change/3` invoked on an unloaded version).
719 /// The canonical order is `(:load-module …) (:state-change
720 /// …) (:soft-purge …)` (module doc example).
721 /// - `((:load-module "x") (:purge "x-old") (:state-change
722 /// "lib/m.lisp"))` — same shape on the more catastrophic
723 /// `:purge` variant. The immediate-discard semantic destroys
724 /// v0.1.0 state mid-request; the trailing migration script
725 /// has nothing to fold from and the `gen_server` processes that
726 /// held v0.1.0 state were killed by the `:purge`.
727 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
728 /// "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
729 /// sandwiched between two cleanups" footgun. The first
730 /// cleanup discards v0.1.0; the migration runs against
731 /// drained state; the second cleanup is irrelevant. The first
732 /// cleanup → state-change boundary is the load-bearing defect
733 /// surfaced.
734 ///
735 /// Same within-entry cross-instruction discipline as
736 /// [`Self::validate_state_change_ordering`] (the load → state-
737 /// change ordering gate it runs after) and
738 /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
739 /// gate it runs after): all three close one boundary of the OTP
740 /// canonical sequence `code:load_module/1` →
741 /// `gen_server:code_change/3` → `code:soft_purge/1`. The
742 /// state-change-ordering gate closes the load → migrate boundary;
743 /// the purge-ordering gate closes the load → cleanup boundary;
744 /// this gate closes the migrate → cleanup boundary, completing
745 /// the typed coverage of the canonical sequence. Runs *after*
746 /// [`Self::validate_purge_ordering`] (and therefore after
747 /// [`Self::validate_state_change_ordering`]) so an entry like
748 /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
749 /// which violates *both* the purge-without-load gate and this
750 /// state-change-after-cleanup gate — surfaces the more-
751 /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
752 /// defect is load-bearing; once a coherent `(:load-module …)`
753 /// precedes both, the migrate ↔ cleanup ordering becomes the
754 /// next live defect). Runs *before* the per-instruction-class
755 /// singularity gates ([`Self::validate_load_singularity`],
756 /// [`Self::validate_state_change_singularity`],
757 /// [`Self::validate_cleanup_singularity`]) so an entry like
758 /// `((:load-module "x") (:soft-purge "x-old") (:state-change
759 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
760 /// *both* this ordering gate and the state-change-singularity
761 /// gate — surfaces the ordering defect first; the canonical
762 /// "ordering before singularity" precedence the peer
763 /// `validate_state_change_ordering` / `validate_purge_ordering`
764 /// gates already establish.
765 ///
766 /// Detection: linear scan of the instructions list with a
767 /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
768 /// recording the first cleanup encountered; on any subsequent
769 /// `StateChange` the gate fires with the script + the prior
770 /// cleanup's kind/module. Diagnostic-order pin: the first
771 /// colliding state-change-after-cleanup pair surfaces, not the
772 /// last — mirrors every peer ordering gate's first-collision
773 /// posture ([`Self::validate_state_change_ordering`] returns on
774 /// the first `StateChange` without prior load,
775 /// [`Self::validate_purge_ordering`] on the first cleanup
776 /// without prior load).
777 fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
778 let mut prior_cleanup: Option<(&str, &'static str)> = None;
779 for instr in self.instructions() {
780 // Route the per-instruction cleanup-family arm-discriminator
781 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
782 // predicate rather than the raw
783 // `UpgradeInstruction::SoftPurge { module } |
784 // UpgradeInstruction::Purge { module }` open-coded per-arm
785 // union pattern-match — the second of three within-entry
786 // cross-instruction cleanup-facing gates the peer
787 // [`Self::validate_purge_ordering`] routing already lifted;
788 // both now key off exactly one typed dispatch on the substrate
789 // primitive so the "which arms belong to the cleanup family"
790 // question resolves at exactly one caixa-core edit. The
791 // sticky-once latch's `:module` scalar is routed through the
792 // sibling [`UpgradeInstruction::declared_module`] accessor
793 // rather than the raw pattern-bound `module.as_str()`
794 // projection, with the `is_cleanup`-implies-`declared_module`-
795 // is-`Some` composition pin at
796 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
797 // making the `.expect(…)` structurally infallible at build
798 // time.
799 if instr.is_cleanup() && prior_cleanup.is_none() {
800 prior_cleanup = Some((
801 instr
802 .declared_module()
803 .expect("is_cleanup() implies declared_module() is Some"),
804 instr.lisp_form(),
805 ));
806 } else if let Some(script) = instr.declared_path()
807 && let Some((prior_module, prior_kind)) = prior_cleanup
808 {
809 // Route the per-instruction `StateChange`-arm script-path
810 // projection through the sibling lifted
811 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
812 // accessor rather than the raw
813 // `if let UpgradeInstruction::StateChange { script } = instr`
814 // open-coded pattern-match — the last unlifted per-
815 // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
816 // inside `impl UpgradeFromEntry`, sibling to the four peer
817 // per-`UpgradeInstruction` consumers already routed through
818 // the accessor: [`UpgradeInstruction::validate`]'s per-
819 // `StateChange` sandbox-path fan-out, the layout-side per-
820 // `StateChange` script-existence fan-out at
821 // [`crate::layout::StandardLayout::verify`]
822 // (caixa-core/src/layout.rs:1058), the within-entry
823 // [`UpgradeFromEntry::validate_state_change_singularity`]
824 // per-`StateChange` script-projection fan-out, and the
825 // cross-slot
826 // [`validate_upgrade_from_against_behavior`]
827 // per-`StateChange` detection loop. Byte-equal today
828 // (`declared_path` returns `Some(script)` iff the
829 // instruction is [`UpgradeInstruction::StateChange`], per
830 // the sibling `declared_path_only_for_state_change` pin),
831 // so a state-change-after-cleanup surfaces
832 // `StateChangeAfterCleanup` byte-identical to the pattern-
833 // match shape. Any future accessor extension that promotes
834 // an additional variant onto the `PathBuf`-carrying axis
835 // reaches this gate through one caixa-core edit rather
836 // than a coordinated rewrite of five call sites — the
837 // migrate→cleanup ordering discipline extends to the
838 // promoted variant by construction. Same "one typed
839 // dispatch on the substrate primitive, thin projections at
840 // each consumer" trajectory the sibling
841 // [`UpgradeInstruction::declared_module`] `String`-axis
842 // per-variant unifier already established.
843 return Err(UpgradeError::StateChangeAfterCleanup {
844 from: self.prior_versao().to_string(),
845 script: script.clone(),
846 prior_cleanup_kind: prior_kind,
847 prior_cleanup_module: prior_module.to_string(),
848 });
849 }
850 }
851 Ok(())
852 }
853
854 /// Reject an entry whose `:instructions` list names the same module
855 /// as the target of more than one cleanup instruction (`:soft-purge`
856 /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
857 /// module) axis, narrowed to the cleanup class.
858 ///
859 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
860 /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
861 /// `code:load_module/1` — load v2 alongside v1 … 2.
862 /// `code:soft_purge/1` — wait until no process is running v1, then
863 /// discard. (`code:purge/1` kills v1 immediately if you don't
864 /// care.)"). The author picks *one* cleanup semantic per old
865 /// module — `:soft-purge` (preferred: waits for in-flight callers
866 /// to drain) or `:purge` (when the drain isn't possible) — and the
867 /// operator runs that one in declared order alongside any other
868 /// distinct-module cleanups. systools-generated `.relup` files
869 /// always emit at most one purge per module for this reason; any
870 /// retry / fallback decision is the operator's job on
871 /// instruction failure, not authored into the entry. Three
872 /// authoring footguns close here:
873 ///
874 /// - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
875 /// — the "I copy-pasted the cleanup line twice" footgun. The
876 /// second `:soft-purge` is a no-op (the module is already gone
877 /// after the first drain-and-discard) or undefined depending
878 /// on the operator's handling of a non-resident-module purge
879 /// request; either way the second instruction carries no
880 /// observable semantic, far from the source caixa.lisp.
881 /// - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
882 /// — the "soft-then-hard fallback" footgun. The author wrote
883 /// "drain, and if drain didn't clean it up, force-discard",
884 /// but the operator runs instructions unconditionally in
885 /// declared order — the `:purge` fires whether the
886 /// `:soft-purge` already discarded the module or not, so the
887 /// fallback semantic the author imagined is missing; the
888 /// pair is incoherent (drain *and* force-discard semantics
889 /// on one module is two contradictory dispositions). The
890 /// operator's failure-handling surface is its own
891 /// responsibility: if `:soft-purge` doesn't drain within its
892 /// cooldown the operator escalates, not the author's entry.
893 /// - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
894 /// — same shape on the reversed ordering. The `:purge`
895 /// discards immediately; the trailing `:soft-purge` has no
896 /// module to drain.
897 ///
898 /// Same within-entry exclusivity discipline as
899 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
900 /// exclusivity gate it joins on the per-module cleanup axis): both
901 /// reject an `:instructions` list whose instructions are
902 /// individually well-shaped but jointly incoherent on a chosen
903 /// semantic axis (restart-fallback for the whole entry there;
904 /// cleanup-semantic for one module here), at the typed build
905 /// surface rather than as a runtime surprise. Runs *after*
906 /// [`Self::validate_purge_ordering`] (the load-before-cleanup
907 /// ordering gate) so an entry like `((:soft-purge "x-old")
908 /// (:soft-purge "x-old"))` surfaces the more-fundamental
909 /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
910 /// the missing-load defect is the load-bearing one — the duplicate
911 /// is meaningless either way without the preceding load).
912 ///
913 /// Same set-not-multiset discipline applied to every peer
914 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
915 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
916 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
917 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
918 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
919 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
920 /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
921 /// Each closes the same authoring footgun: a Vec authoring surface
922 /// that silently accepts duplicate entries and renders the "second
923 /// wins" (or "operator processes both, second is a no-op or
924 /// errors") shape downstream, far from the source caixa.lisp.
925 /// This gate extends the discipline onto the within-entry
926 /// instruction-target axis — duplicate cleanup targets *within*
927 /// one `:upgrade-from` entry — the peer of the cross-entry
928 /// duplicate-`:from` axis at one level of nesting deeper.
929 ///
930 /// Detection: linear scan of the instructions list collecting
931 /// the (module, kind) pair from every `SoftPurge` / `Purge`
932 /// encountered; on the second occurrence of any module the gate
933 /// fires with the prior kind and the colliding kind in declaration
934 /// order. Diagnostic-order pin: the first colliding pair surfaces,
935 /// not the last — mirrors
936 /// [`validate_upgrade_from`]'s
937 /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
938 /// posture (the first detected collision wins) and every peer
939 /// duplicate gate's first-collision discipline.
940 fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
941 let mut seen: Vec<(&str, &'static str)> = Vec::new();
942 for instr in self.instructions() {
943 // Route the per-instruction cleanup-family arm-discriminator
944 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
945 // predicate rather than the raw two-arm
946 // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
947 // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
948 // `UpgradeInstruction::Purge { module } => (module.as_str(),
949 // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
950 // per-arm dispatch — the third of three within-entry cross-
951 // instruction cleanup-facing gates the peer
952 // [`Self::validate_purge_ordering`] +
953 // [`Self::validate_state_change_before_cleanup`] routing
954 // already lifted; all three now key off exactly one typed
955 // dispatch on the substrate primitive, structurally. The
956 // cleanup-target `(module, kind)` pair is projected through
957 // the peer [`UpgradeInstruction::declared_module`] /
958 // [`UpgradeInstruction::lisp_form`] accessors rather than
959 // the per-arm-hand-rolled scalar-value + kind-const pair,
960 // with the `is_cleanup`-implies-`declared_module`-is-`Some`
961 // composition pin at
962 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
963 // making the `.expect(…)` structurally infallible at build
964 // time. Any future fifth cleanup-shaped variant added under
965 // the `is_cleanup` predicate + registered through the peer
966 // `lisp_form` per-arm kebab-case-const dispatch reaches this
967 // dedup gate through the accessor's one body rather than a
968 // fourth per-arm-hand-rolled scalar/kind projection here.
969 if !instr.is_cleanup() {
970 continue;
971 }
972 let module = instr
973 .declared_module()
974 .expect("is_cleanup() implies declared_module() is Some");
975 let kind = instr.lisp_form();
976 if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
977 let prior_kind = seen[prior_idx].1;
978 return Err(UpgradeError::DuplicateCleanup {
979 from: self.prior_versao().to_string(),
980 module: module.to_string(),
981 kinds: vec![prior_kind, kind],
982 });
983 }
984 seen.push((module, kind));
985 }
986 Ok(())
987 }
988
989 /// Reject an entry whose `:instructions` list names the same module
990 /// as the target of more than one `(:load-module …)` instruction —
991 /// set-not-multiset on the `LoadModule` axis.
992 ///
993 /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
994 /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
995 /// new code is 'current', old code is 'old'."). The instruction
996 /// brings the new wasm component up resident alongside the old
997 /// one so the operator can route new traffic to the new code
998 /// while in-flight callers drain on the old — and the operator's
999 /// dispatch table reads the module *name* (a caixa name) to bind
1000 /// the component, so two `(:load-module "x")` instructions in one
1001 /// entry ask the operator to re-bind the same component twice.
1002 /// `systools`-generated `.relup` files emit at most one
1003 /// `load_module` per module per upgrade step for this reason; the
1004 /// second load has no observable semantic relative to the first
1005 /// (the component is already resident). Three authoring footguns
1006 /// close here:
1007 ///
1008 /// - `((:load-module "x") (:load-module "x"))` — the "I
1009 /// copy-pasted the load line twice" footgun. The second
1010 /// `:load-module` re-reads the same module name and re-binds
1011 /// the same wasm component — a no-op in both directions
1012 /// (no new code becomes resident; no old code is purged) —
1013 /// and any cleanup / migration the author intended for a
1014 /// *distinct* module is silently absent from the entry.
1015 /// - `((:load-module "x") (:load-module "x") (:state-change …))`
1016 /// — the "I meant to load two distinct modules" typo. The
1017 /// author intended `((:load-module "x") (:load-module "y"))`
1018 /// but renamed both to "x" (or copied the first line and
1019 /// forgot to change the module). The migration runs against
1020 /// code that's resident only on one module name, and the
1021 /// second module the author imagined was being loaded never
1022 /// comes up at all — far from the source caixa.lisp.
1023 /// - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1024 /// — same shape with a trailing cleanup. The duplicate load
1025 /// is dead code; the cleanup still fires correctly, masking
1026 /// the load-side duplication as a silently-passing entry.
1027 ///
1028 /// Same within-entry exclusivity discipline as
1029 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1030 /// singularity gate this runs beside) on the sibling
1031 /// `LoadModule` axis: both reject an `:instructions` list whose
1032 /// instructions are individually well-shaped but jointly
1033 /// incoherent on a per-module-per-class basis (load-once for the
1034 /// load axis here; cleanup-once for the cleanup axis there), at
1035 /// the typed build surface rather than as a runtime surprise.
1036 /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1037 /// before-cleanup ordering gate) so an entry like
1038 /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1039 /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1040 /// first (the missing-load defect is load-bearing — the migration
1041 /// runs against unloaded code; the duplicate is meaningless either
1042 /// way without the preceding load). Runs *before*
1043 /// [`Self::validate_cleanup_singularity`] so an entry like
1044 /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1045 /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1046 /// the load axis precedes the cleanup axis in the canonical OTP
1047 /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1048 /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1049 /// before `SoftPurge`/`Purge`), so the load-side singularity is
1050 /// the load-bearing diagnostic when both fire.
1051 ///
1052 /// Same set-not-multiset discipline applied to every peer
1053 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1054 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1055 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1056 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1057 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1058 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1059 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1060 /// the per-module cleanup-target axis (9cedd8b —
1061 /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1062 /// discipline onto the within-entry `LoadModule` instruction-target
1063 /// axis — the third within-entry per-module singularity completing
1064 /// the load+cleanup pair across the OTP two-phase code-load
1065 /// contract.
1066 ///
1067 /// Detection: linear scan of the instructions list collecting the
1068 /// module name from every `LoadModule` encountered; on the second
1069 /// occurrence of any module the gate fires. Diagnostic-order pin:
1070 /// the first colliding occurrence surfaces, not the last — mirrors
1071 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1072 /// and every peer duplicate gate's first-collision discipline.
1073 fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1074 let mut seen: Vec<&str> = Vec::new();
1075 for instr in self.instructions() {
1076 // Route the per-instruction load-family arm-discriminator
1077 // through the `gen_platform::IsVariant`-derive-generated
1078 // [`UpgradeInstruction::is_load_module`] predicate rather
1079 // than the raw single-arm `match instr {
1080 // UpgradeInstruction::LoadModule { module } =>
1081 // module.as_str(), _ => continue }` open-coded pattern-
1082 // match — closes the last unlifted `matches!`-shaped
1083 // per-arm-hand-rolled scalar-value + arm-discriminator
1084 // pair inside `impl UpgradeFromEntry`, sibling of the
1085 // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1086 // routing already lifted onto the two-arm cleanup-family
1087 // axis's per-arm arm-discriminator + `:module` projection
1088 // dispatch. The load-target `:module` scalar is projected
1089 // through the sibling [`UpgradeInstruction::declared_module`]
1090 // accessor rather than the per-arm-hand-rolled scalar-
1091 // value binding, with the
1092 // `is_load_module`-implies-`declared_module`-is-`Some`
1093 // composition pin at
1094 // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1095 // making the `.expect(…)` structurally infallible at
1096 // build time. Every arm-family partition the three
1097 // within-entry per-instruction-class singularity gates
1098 // key off — load-family
1099 // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1100 // ([`UpgradeInstruction::SoftPurge`] |
1101 // [`UpgradeInstruction::Purge`]), migration-family
1102 // ([`UpgradeInstruction::StateChange`]) — now consults
1103 // exactly one typed dispatch on the substrate primitive
1104 // (`is_load_module()` here, `is_cleanup()` at
1105 // [`Self::validate_cleanup_singularity`],
1106 // `declared_path()` at
1107 // [`Self::validate_state_change_singularity`]), so a
1108 // future sixth arm added to [`UpgradeInstruction`] (an
1109 // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1110 // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1111 // split-traffic variant the M4 CR materializer could
1112 // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1113 // single enum-declaration edit through the derive rather
1114 // than a scattered per-consumer rewrite. Byte-identity of
1115 // this dispatch against the pre-lift match-pattern is
1116 // pinned by
1117 // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1118 if !instr.is_load_module() {
1119 continue;
1120 }
1121 let module = instr
1122 .declared_module()
1123 .expect("is_load_module() implies declared_module() is Some");
1124 if seen.contains(&module) {
1125 return Err(UpgradeError::DuplicateLoadModule {
1126 from: self.prior_versao().to_string(),
1127 module: module.to_string(),
1128 });
1129 }
1130 seen.push(module);
1131 }
1132 Ok(())
1133 }
1134
1135 /// Reject an entry whose `:instructions` list names the same script
1136 /// as the target of more than one `(:state-change …)` instruction —
1137 /// set-not-multiset on the `StateChange` axis.
1138 ///
1139 /// `StateChange` is the `gen_server:code_change/3` analog
1140 /// (INSPIRATIONS §II.4: "State migration uses
1141 /// `gen_server:code_change/3`"). The instruction folds the *old*
1142 /// state into the shape the *new* code expects — a one-shot
1143 /// transition from one declared state representation to another.
1144 /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1145 /// exactly once per upgrade per `gen_server`; `systools`-generated
1146 /// `.relup` files emit at most one `code_change` per `gen_server` per
1147 /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1148 /// instruction targeting the same script in one entry re-runs the
1149 /// migration fold — at best a no-op (idempotent script masking a
1150 /// typo where the author intended two distinct scripts) and at
1151 /// worst silent state corruption (non-idempotent fold double-
1152 /// applied: an `add column` migration that runs twice, an
1153 /// `increment counter` that double-bumps, a `rename field` that
1154 /// renames-then-fails the second time). Three authoring footguns
1155 /// close here:
1156 ///
1157 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1158 /// (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1159 /// migration line twice" footgun. The second `:state-change`
1160 /// re-runs the same fold on the already-migrated state — a
1161 /// no-op if the script is idempotent (dead code masking the
1162 /// duplication) or state corruption if not (the migration's
1163 /// pre-condition no longer holds because the post-condition is
1164 /// already in place).
1165 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1166 /// (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1167 /// "duplicate migrate masked by trailing cleanup" footgun. The
1168 /// cleanup still fires correctly, masking the migration-side
1169 /// duplication as a silently-passing entry.
1170 /// - `((:load-module "x") (:state-change "lib/m1.lisp")
1171 /// (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1172 /// two distinct modules" typo. The author intended
1173 /// `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1174 /// but renamed both to `m1.lisp` (or copy-pasted the first line
1175 /// and forgot to change the script). The migration that should
1176 /// have folded the second module's state never runs, far from
1177 /// the source caixa.lisp.
1178 ///
1179 /// Same within-entry exclusivity discipline as
1180 /// [`Self::validate_load_singularity`] (the per-module load-
1181 /// singularity gate it runs after) and
1182 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1183 /// singularity gate it runs before) on the sibling `StateChange`
1184 /// axis: each rejects an `:instructions` list whose instructions
1185 /// are individually well-shaped but jointly incoherent on a per-
1186 /// instruction-class basis (load-once per module for the load
1187 /// axis; migrate-once per script for the migration axis here;
1188 /// cleanup-once per module for the cleanup axis), at the typed
1189 /// build surface rather than as a runtime surprise. Runs *after*
1190 /// [`Self::validate_load_singularity`] so an entry like
1191 /// `((:load-module "x") (:load-module "x") (:state-change
1192 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1193 /// `DuplicateLoadModule` first — the load axis precedes the
1194 /// migration axis in the canonical OTP sequence
1195 /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1196 /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1197 /// `StateChange`), so the load-side singularity is the load-
1198 /// bearing diagnostic when both fire. Runs *before*
1199 /// [`Self::validate_cleanup_singularity`] so an entry like
1200 /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1201 /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1202 /// surfaces `DuplicateStateChange` first — the migration axis
1203 /// precedes the cleanup axis in the canonical OTP sequence
1204 /// (`code:code_change/3` then `code:soft_purge/1`) and in
1205 /// [`UpgradeInstruction`] declaration order (`StateChange` before
1206 /// `SoftPurge`/`Purge`).
1207 ///
1208 /// Same set-not-multiset discipline applied to every peer
1209 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1210 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1211 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1212 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1213 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1214 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1215 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1216 /// per-module cleanup-target axis (9cedd8b —
1217 /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1218 /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1219 /// This gate extends the discipline onto the within-entry
1220 /// `StateChange` instruction-target axis — the third within-entry
1221 /// per-instruction-class singularity, completing the OTP two-phase
1222 /// code-load + state-migration coverage triad
1223 /// (`code:load_module/1` → `gen_server:code_change/3` →
1224 /// `code:soft_purge/1`).
1225 ///
1226 /// Detection: linear scan of the instructions list collecting the
1227 /// script path from every `StateChange` encountered; on the second
1228 /// occurrence of any script the gate fires. Diagnostic-order pin:
1229 /// the first colliding occurrence surfaces, not the last — mirrors
1230 /// [`Self::validate_load_singularity`]'s and
1231 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1232 /// and every peer duplicate gate's first-collision discipline.
1233 fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1234 // Route the per-instruction `StateChange`-arm script-path
1235 // projection through the sibling lifted
1236 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1237 // accessor rather than the raw
1238 // `match instr { UpgradeInstruction::StateChange { script } =>
1239 // script.as_path(), _ => continue }` open-coded pattern-match —
1240 // the third within-entry singularity gate's per-instruction
1241 // script-projection site now keys off exactly one typed
1242 // dispatch on the substrate primitive's `PathBuf`-carrying
1243 // axis, sibling to the four peer per-`UpgradeInstruction`
1244 // consumers ([`Self::validate`]'s per-`StateChange`
1245 // sandbox-path fan-out, the layout-side per-`StateChange`
1246 // script-existence fan-out at
1247 // `caixa-core/src/layout.rs:1017`, the cross-slot
1248 // [`validate_upgrade_from_against_behavior`] gate's
1249 // per-`StateChange` detection loop, the future wasm-operator's
1250 // per-`StateChange` runtime hook-dispatch) that already route
1251 // through `declared_path` / `declared_module`. Byte-equal
1252 // today (`declared_path` returns `Some(script)` iff the
1253 // instruction is [`UpgradeInstruction::StateChange`], per the
1254 // sibling `declared_path_only_for_state_change` pin), so a
1255 // duplicate `:state-change` script surfaces
1256 // `DuplicateStateChange` byte-identical to the pattern-match
1257 // shape. Same "one typed dispatch on the substrate primitive,
1258 // thin projections at each consumer" discipline the sibling
1259 // [`UpgradeInstruction::declared_module`] accessor established
1260 // (b13c4f9) on the peer `String`-carrying axis's per-variant
1261 // consumers, extended here onto the last unlifted
1262 // pattern-match on the `PathBuf`-carrying axis inside
1263 // `impl UpgradeFromEntry`.
1264 let mut seen: Vec<&std::path::Path> = Vec::new();
1265 for instr in self.instructions() {
1266 let Some(script) = instr.declared_path() else {
1267 continue;
1268 };
1269 let script = script.as_path();
1270 if seen.contains(&script) {
1271 return Err(UpgradeError::duplicate_state_change(
1272 self.prior_versao(),
1273 script,
1274 ));
1275 }
1276 seen.push(script);
1277 }
1278 Ok(())
1279 }
1280}
1281
1282/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1283/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1284/// invariant — at most one `(:from <prior>)` block per parsed semver.
1285///
1286/// OTP's appup picks at most one matching block to apply to the running
1287/// release (`release_handler:install_release/1` matches the loaded
1288/// `:from` against the currently-running version and executes the
1289/// associated instruction sequence; the wasm-operator picks the matching
1290/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1291/// the same parsed-semver `:from` are an ambiguous edge in the typed
1292/// upgrade graph — the operator can pick either set deterministically,
1293/// but each set may carry different `LoadModule | StateChange |
1294/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1295/// non-deterministic relative to the source caixa.lisp. The author's
1296/// intent is one path per prior version; the typed graph must enforce
1297/// that shape.
1298///
1299/// Same set-not-multiset discipline already applied to every peer
1300/// typed-graph axis: `:children :caixa` (dbf50a9 —
1301/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1302/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1303/// `AplicacaoError::MembroDuplicate`), `:contratos`
1304/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1305/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1306/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1307/// Each closes the same authoring footgun: a Vec authoring surface that
1308/// silently accepts duplicate entries and renders the "second wins"
1309/// (or "operator picks arbitrarily") shape downstream, far from the
1310/// source caixa.lisp.
1311///
1312/// Duplicates are detected by [`semver::Version`] equality (the
1313/// crate's `PartialEq` compares the full identity — major.minor.patch +
1314/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1315/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1316/// The conservative choice mirrors what the wasm-operator's
1317/// `:from`-match dispatch can see; collapsing build metadata to catch
1318/// a wider net of duplicates is a future tightening that requires
1319/// coordinating with the operator's match step.
1320///
1321/// Per-entry shape errors fire before the duplicate gate so the
1322/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1323/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1324/// errors into a single duplicate diagnostic. Mirrors the
1325/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1326/// axis ([`crate::SupervisorSpec::validate`],
1327/// [`crate::AplicacaoSpec::validate_membros`],
1328/// [`crate::AplicacaoSpec::validate_placement`]).
1329pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1330 use semver::Version;
1331 let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1332 for entry in entries {
1333 entry.validate()?;
1334 // `entry.validate()` accepted this `:from`, so parse cannot
1335 // fail here — the FromInvalid arm above is the only gate
1336 // and both call `Version::parse(entry.prior_versao())`.
1337 let parsed = Version::parse(entry.prior_versao()).expect(
1338 "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1339 two gates aligned",
1340 );
1341 if seen.contains(&parsed) {
1342 return Err(UpgradeError::DuplicateFrom {
1343 from: entry.prior_versao().to_string(),
1344 });
1345 }
1346 seen.push(parsed);
1347 }
1348 Ok(())
1349}
1350
1351/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1352/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1353/// same ordering [`semver::Version::cmp`] implements, with build
1354/// metadata ignored per [SemVer §11][semver-11]).
1355///
1356/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1357/// declarative answer to "given the wasm-operator is loading a node
1358/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1359/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1360/// semantic). The operator's `:from`-match dispatch loads the
1361/// current `:versao` and matches the *running* version against each
1362/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1363/// unreachable — the operator never runs a version greater than or
1364/// equal to the current `:versao` that it could then "upgrade *to*"
1365/// the current `:versao`. Two authoring footguns close here:
1366///
1367/// - `:from > :versao` (downgrade-shaped) — the canonical
1368/// "I copy-pasted from the next minor version and forgot to bump
1369/// `:versao`" / "I bumped `:versao` then reverted but left the
1370/// `:upgrade-from` entry behind" footgun. Until this gate landed
1371/// `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1372/// silently passed `feira build` and the wasm-operator's
1373/// `:from`-match dispatch would never fire on the entry — the
1374/// instructions sat dormant in the caixa.lisp forever, the
1375/// author's intent ("upgrade users coming from 0.2.0") permanently
1376/// unreached because they actually meant to bump `:versao`.
1377///
1378/// - `:from == :versao` (precedence-equal self-upgrade) — the
1379/// "I declared an upgrade from myself to myself" no-op the
1380/// operator's dispatch would either skip silently (no semantic
1381/// transition) or attempt and trivially "succeed" with no
1382/// observable state change. Includes the build-metadata-only
1383/// difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1384/// SemVer-2 precedence ignores build metadata so they compare
1385/// equal under [`semver::Version::cmp`] — the gate rejects this
1386/// even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1387/// gate uses derived `PartialEq` which keeps them distinct;
1388/// they're distinct dispatch keys but the same "from" version
1389/// for our purposes here).
1390///
1391/// Same cross-slot value-shape discipline as
1392/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1393/// partition (934bc58 — the typed partition between two declared
1394/// slots): one slot's value constrains the valid set of another's,
1395/// and the constraint is a structural property visible at validate
1396/// time. The validated set after this gate satisfies
1397/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1398/// for every entry, so the future operator-side hot-upgrade dispatch
1399/// step can reach for `entry.from` knowing the precedence relation
1400/// holds without re-deriving it from inline checks.
1401///
1402/// Silent-pass semantics on malformed inputs:
1403///
1404/// - When `versao` itself doesn't parse as semver, this gate
1405/// returns `Ok(())` silently — the narrower
1406/// [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1407/// diagnostics are the load-bearing surfaces for those failure
1408/// modes, and surfacing a `FromNotBeforeVersao` over an
1409/// unparseable `:versao` would mask the more actionable root
1410/// cause.
1411/// - Likewise, an entry whose `:from` itself doesn't parse falls
1412/// through to its narrower diagnostic surface
1413/// ([`UpgradeError::FromInvalid`]), which is expected to fire
1414/// via [`validate_upgrade_from`] *before* this gate runs at the
1415/// [`crate::LayoutInvariants`] call site.
1416///
1417/// [semver-11]: https://semver.org/#spec-item-11
1418pub fn validate_upgrade_from_against_versao(
1419 entries: &[UpgradeFromEntry],
1420 versao: &str,
1421) -> Result<(), UpgradeError> {
1422 use semver::Version;
1423 let Ok(current) = Version::parse(versao) else {
1424 // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1425 // surfacing a precedence-relation diagnostic over an unparseable
1426 // top-level version would mask the more actionable root cause.
1427 return Ok(());
1428 };
1429 for entry in entries {
1430 // Per-entry shape — including a malformed `:from` — is gated
1431 // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1432 // upstream at the LayoutInvariants call site; an unparseable
1433 // `:from` here falls through silently to keep the
1434 // FromInvalid diagnostic load-bearing. Same fall-through
1435 // posture as the `versao` arm above.
1436 let Ok(prior) = Version::parse(entry.prior_versao()) else {
1437 continue;
1438 };
1439 if prior >= current {
1440 return Err(UpgradeError::FromNotBeforeVersao {
1441 from: entry.prior_versao().to_string(),
1442 versao: versao.to_string(),
1443 });
1444 }
1445 }
1446 Ok(())
1447}
1448
1449/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1450/// `(:state-change <script>)` instruction unless the caixa also declares
1451/// `:behavior :on-state-change` — the runtime callback the per-version
1452/// migration script is delivered through during hot upgrade.
1453///
1454/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1455/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1456/// callback to deliver state migration during hot upgrades." The peer
1457/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1458/// promise from the callback side: the slot is the
1459/// `gen_server:code_change/3` analog — "receives old state + version,
1460/// returns new state. Composes with the `:upgrade-from` slot declared at
1461/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1462/// the composition by invoking the running `gen_server`'s
1463/// `code_change/3` callback during the appup's `code_change` /
1464/// `update, m, soft` step — the appup's instruction triggers the
1465/// callback, the callback folds the prior-version state shape into the
1466/// current-version shape, and the operator advances to the next
1467/// instruction only after the callback returns successfully. caixa
1468/// decomposes the same composition into two typed slots: the per-version
1469/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1470/// instruction's `:script` (the `:upgrade-from` author surface), and the
1471/// runtime hook the operator dispatches the migration through lives in
1472/// the `:behavior :on-state-change` callback (the `:behavior` author
1473/// surface). A `:state-change` instruction declared without the callback
1474/// is half the composition: the per-version script the author wrote has
1475/// no runtime delivery path, and the operator's hot-upgrade dispatch
1476/// reaches for `caixa.behavior.on_state_change` at the migration step,
1477/// finds `None`, and either fails the upgrade mid-flight (the
1478/// transactional rollback the module doc names — "On any failure, the
1479/// current version stays load-bearing — a typed atomic upgrade") or
1480/// silently skips the migration depending on the operator's handling of
1481/// a missing callback, both far from the source caixa.lisp.
1482///
1483/// Two authoring footguns close here:
1484///
1485/// - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1486/// :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1487/// (:soft-purge "x-old")))))` — the "I declared the migration script
1488/// but forgot the callback" footgun. The author wrote the per-version
1489/// fold against the prior state shape, the typed `:upgrade-from`
1490/// slot validated every per-instruction shape + ordering + singularity
1491/// gate, and the missing callback only surfaces at upgrade time as
1492/// either a transactional rollback to the prior version (no progress
1493/// across the upgrade) or as a silently-skipped migration that leaves
1494/// v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1495/// state shape).
1496/// - `:behavior` absent entirely + `:upgrade-from` carrying any
1497/// `:state-change` — the "I added the upgrade path but never declared
1498/// `:behavior`" footgun. `:behavior` is optional at the typed root
1499/// ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1500/// `:upgrade-from` slot validates on its own merits, but a `Caixa`
1501/// with `behavior: None` and a `:state-change` instruction is the
1502/// same missing-callback shape — the operator's dispatch can't reach
1503/// a callback that doesn't exist.
1504///
1505/// Same cross-slot composition discipline as
1506/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1507/// precedence gate at the peer wire-up site): one slot's value
1508/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1509/// the valid set of another's (the entry must be dispatchable there; the
1510/// callback must be declared here), and the constraint is a structural
1511/// property visible at validate time. The validated set after this gate
1512/// satisfies the documented composition: every `:state-change`
1513/// instruction the operator iterates at hot-upgrade time has a
1514/// corresponding `:on-state-change` callback declared on the same caixa,
1515/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1516/// `release_handler` canonical-sequence loop) can reach for
1517/// `behavior.on_state_change` at the migration step knowing the
1518/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1519/// from inline checks.
1520///
1521/// Diagnostic-precedence:
1522///
1523/// - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1524/// shape + the within-entry ordering / singularity gates) and
1525/// [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1526/// gate), so a malformed `:state-change` (`EmptyScript`,
1527/// `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1528/// (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1529/// duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1530/// self-locating diagnostic first — the canonical "per-instr-shape +
1531/// within-entry ordering + cross-entry uniqueness before
1532/// cross-slot composition" precedence the peer
1533/// `validate_upgrade_from_against_versao` gate establishes at the
1534/// same wire-up site. Without this precedence pin a malformed
1535/// `:state-change` instruction would surface this gate's
1536/// missing-callback diagnostic over the narrower
1537/// `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1538/// load-bearing per-instruction defect with a cross-slot composition
1539/// diagnostic.
1540/// - Within the entries, walks the list in declaration order and
1541/// surfaces the *first* `:state-change` instruction encountered —
1542/// mirrors every peer first-collision diagnostic posture on this
1543/// module (`validate_state_change_ordering` returns on the first
1544/// `StateChange` without prior load,
1545/// `validate_load_singularity` returns on the second matching
1546/// module, etc.). A future entry's later `:state-change` doesn't
1547/// surface a different diagnostic — the missing callback is the same
1548/// defect regardless of which entry's `:state-change` exposes it.
1549///
1550/// Silent-pass semantics:
1551///
1552/// - Entries carrying no `:state-change` instruction (load-only,
1553/// cleanup-only, restart-only, or empty `:instructions`) leave the
1554/// gate vacuous — no per-version migration means no callback to
1555/// dispatch through, so the absence of `:on-state-change` is
1556/// coherent. Pins the gate's identity element on the empty-set side
1557/// of the composition.
1558/// - `behavior: None` is *not* a free pass when a `:state-change`
1559/// instruction is present — the same missing-callback shape as
1560/// `behavior: Some(_)` with `on_state_change: None`. The gate reads
1561/// `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1562/// surface the same diagnostic.
1563pub fn validate_upgrade_from_against_behavior(
1564 entries: &[UpgradeFromEntry],
1565 behavior: Option<&crate::BehaviorSpec>,
1566) -> Result<(), UpgradeError> {
1567 if behavior
1568 .and_then(crate::BehaviorSpec::on_state_change)
1569 .is_some()
1570 {
1571 return Ok(());
1572 }
1573 for entry in entries {
1574 // Route the per-instruction `StateChange`-arm script-path
1575 // projection through the sibling lifted
1576 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1577 // accessor rather than the raw
1578 // `if let UpgradeInstruction::StateChange { script } = instr`
1579 // open-coded pattern-match — the cross-slot
1580 // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1581 // script-projection site now keys off exactly one typed dispatch
1582 // on the substrate primitive's `PathBuf`-carrying axis, sibling
1583 // to the four peer per-`UpgradeInstruction` consumers
1584 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1585 // sandbox-path fan-out, the layout-side per-`StateChange`
1586 // script-existence fan-out at
1587 // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1588 // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1589 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1590 // peer [`UpgradeInstruction::declared_module`] `String`-axis
1591 // per-variant unifier) that already route through
1592 // `declared_path` / `declared_module`. Byte-equal today
1593 // (`declared_path` returns `Some(script)` iff the instruction is
1594 // [`UpgradeInstruction::StateChange`], per the sibling
1595 // `declared_path_only_for_state_change` pin), so a
1596 // `:state-change`-without-`:on-state-change`-callback
1597 // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1598 // byte-identical to the pattern-match shape. Fourth (and last)
1599 // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1600 // axis now routed through the accessor — closes the last
1601 // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1602 // site outside `impl UpgradeFromEntry`, so the peer four
1603 // consumer set named in the sibling
1604 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1605 // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1606 // closed.
1607 for instr in entry.instructions() {
1608 if let Some(script) = instr.declared_path() {
1609 return Err(UpgradeError::state_change_without_on_state_change_callback(
1610 entry.prior_versao(),
1611 script,
1612 ));
1613 }
1614 }
1615 }
1616 Ok(())
1617}
1618
1619impl UpgradeInstruction {
1620 /// Kebab-case lisp form name for this instruction, used as the
1621 /// `:kind` tag in [`UpgradeError::ModuleEmpty`] /
1622 /// [`UpgradeError::ModuleInvalid`] diagnostics so the author can
1623 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)`
1624 /// / `(:purge …)` and fix it in one edit. Mirrors the kebab-case
1625 /// slot tags `BehaviorError::EmptyPath` (b0c8389) and
1626 /// `UpgradeFromEntry`'s `:from` field already carry.
1627 #[must_use]
1628 const fn lisp_form(&self) -> &'static str {
1629 match self {
1630 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1631 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1632 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1633 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1634 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1635 }
1636 }
1637
1638 /// Validate the instruction's typed shape. Path existence is
1639 /// checked separately by [`crate::layout::StandardLayout`].
1640 ///
1641 /// The per-variant scalar the value-shape gates fire against is
1642 /// read through this method's two sibling accessors — the
1643 /// `String`-carrying axis via [`Self::declared_module`] (the
1644 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1645 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1646 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1647 /// variant's tatara-lisp `:script`) — rather than the per-arm
1648 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1649 /// Self::Purge { module }` pattern the module-axis previously
1650 /// open-coded and the per-arm `Self::StateChange { script }` the
1651 /// script-axis previously open-coded. Every scalar this enum
1652 /// carries now flows through one of the two `Option<&…>`
1653 /// accessors, so a future extension of either axis (a fifth
1654 /// module-bearing variant, an operator-side pre-parsed scalar
1655 /// cache the accessors materialize behind the same return
1656 /// contract, an M4 typed sub-slot the accessors could route
1657 /// alongside the existing scalar) migrates as a single edit on
1658 /// the accessor rather than a coordinated rewrite of every
1659 /// downstream value-shape gate. `Restart` (the only variant that
1660 /// carries neither scalar) falls through both `Option` checks and
1661 /// returns `Ok(())` — the terminal-fallback shape the
1662 /// [`Self::Restart`] variant doc pins.
1663 pub fn validate(&self) -> Result<(), UpgradeError> {
1664 if let Some(module) = self.declared_module() {
1665 return validate_module(self.lisp_form(), module);
1666 }
1667 if let Some(script) = self.declared_path() {
1668 // Delegate the four-arm cascade (empty / absolute /
1669 // parent-escape / non-`.lisp`-extension) to the lifted
1670 // [`crate::render::require_sandboxed_lisp_path`] helper —
1671 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1672 // arm-ordering this method previously inlined verbatim,
1673 // now shared with [`crate::BehaviorSpec::validate`]'s
1674 // per-`:on-*`-callback gate so every author-supplied
1675 // tatara-lisp source path on every M2 typed slot consults
1676 // one gate, not two-and-counting verbatim copies of the
1677 // same four-arm cascade. Each closure wraps the tag in
1678 // the same `*Script` variant the original inline code
1679 // raised, so the diagnostic shape every caller depends
1680 // on (the `:state-change :script` self-locating error)
1681 // is preserved by construction. See
1682 // [`crate::render::require_sandboxed_lisp_path`] for the
1683 // smallest-scope-arm-fires-last ordering rationale.
1684 crate::render::require_sandboxed_lisp_path(
1685 script,
1686 || UpgradeError::EmptyScript,
1687 || UpgradeError::absolute_script(script),
1688 || UpgradeError::parent_escape_script(script),
1689 || UpgradeError::non_lisp_extension_script(script),
1690 )?;
1691 }
1692 // `Restart` (the only variant with no `Option<&…>`-carrying
1693 // scalar) falls through both accessor gates and returns
1694 // `Ok(())` — the terminal-fallback shape.
1695 Ok(())
1696 }
1697
1698 /// The `:module` scalar carried by this instruction — the
1699 /// K8s DNS-1123-label OTP-appup caixa-name reference every
1700 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1701 /// variant declares against, and every author expects `feira lint`
1702 /// to name verbatim in per-instruction diagnostics. Returns `None`
1703 /// on [`Self::StateChange`] (which carries a `:script` — closed by
1704 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1705 /// (which carries no data at all, the OTP terminal-fallback
1706 /// shape).
1707 ///
1708 /// Sibling in shape to [`Self::declared_path`] on the second and
1709 /// final scalar-carrying axis of [`UpgradeInstruction`]:
1710 /// `declared_path` closes the `PathBuf`-carrying arm
1711 /// (`StateChange`); `declared_module` closes the `String`-carrying
1712 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1713 /// enum carries now routes through one of the two `Option<&…>`
1714 /// accessors — a caller that doesn't care which variant declared
1715 /// the scalar reads through one `if let Some(…)` rather than a
1716 /// per-variant pattern match. The pair is the enum-variant-
1717 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1718 /// on the M3 side ([`crate::WitContract::source`] /
1719 /// [`crate::WitContract::destination`] /
1720 /// [`crate::WitContract::world_ref`] closing `:contratos`;
1721 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1722 /// closing `:entrada`; [`crate::Membro::nome`] /
1723 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1724 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1725 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1726 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1727 /// closed OTP-shape supervisor family) — those peer accessors
1728 /// return a struct field verbatim; this pair unifies enum-
1729 /// variant-carried scalars into one accessor per typed axis.
1730 ///
1731 /// Byte-for-byte from the typed variant's own `String` storage;
1732 /// no cloning, no re-parsing. A future extension of the axis (an
1733 /// M4 typed sub-slot the module string is derived from, an
1734 /// operator-side pre-parsed caixa-name cache the accessor could
1735 /// materialize behind the same `&str` return contract, a fifth
1736 /// module-bearing OTP-appup variant the enum grows) migrates as
1737 /// a single caixa-core edit rather than a coordinated rewrite
1738 /// of every downstream module-axis consumer (currently
1739 /// [`Self::validate`]'s DNS-1123-label gate through
1740 /// [`validate_module`]; extensible to future consumers on the
1741 /// same axis without further per-variant match sites).
1742 #[must_use]
1743 pub const fn declared_module(&self) -> Option<&str> {
1744 match self {
1745 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1746 Some(module.as_str())
1747 }
1748 Self::StateChange { .. } | Self::Restart => None,
1749 }
1750 }
1751
1752 /// If the instruction references an on-disk path, return it —
1753 /// used by the layout checker to verify the path resolves.
1754 ///
1755 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1756 /// on the `String`-carrying axis: `declared_path` closes the
1757 /// `StateChange` arm's `:script`; `declared_module` closes the
1758 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1759 /// they route every scalar this enum carries through one of two
1760 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1761 /// gates dispatch on the accessor return rather than a per-variant
1762 /// pattern match on the enum shape itself.
1763 ///
1764 /// Four per-`UpgradeInstruction` consumers now key off this
1765 /// accessor's `PathBuf`-carrying axis:
1766 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1767 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1768 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1769 /// within-entry
1770 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1771 /// per-`StateChange` script-projection fan-out, and the cross-slot
1772 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1773 /// :behavior` composition gate's per-`StateChange` detection loop
1774 /// — every downstream consumer of the `PathBuf`-carrying axis
1775 /// reaches through this one dispatch, so a future accessor
1776 /// extension (an M4 typed sub-slot the script path is derived from,
1777 /// an operator-side pre-resolved-path cache the accessor
1778 /// materializes behind the same `Option<&PathBuf>` return contract,
1779 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1780 /// migrates as a single caixa-core edit rather than a coordinated
1781 /// rewrite of four call sites.
1782 #[must_use]
1783 pub const fn declared_path(&self) -> Option<&PathBuf> {
1784 match self {
1785 Self::StateChange { script } => Some(script),
1786 _ => None,
1787 }
1788 }
1789
1790 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1791 /// family arm-discriminator predicate every within-entry cross-
1792 /// instruction cleanup-facing gate keys off — true iff `self` is
1793 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1794 /// named module until no process is running it, then GC) or
1795 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1796 /// module immediately, without waiting for drain), the two OTP
1797 /// two-phase-code-load cleanup arms the closed-set enum's
1798 /// non-terminal / non-migration / non-load variants exhaust.
1799 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1800 /// two-phase-load half, [`Self::StateChange`] on the
1801 /// `gen_server:code_change/3`-analog migration axis,
1802 /// [`Self::Restart`] on the OTP terminal-fallback shape)
1803 /// returns `false`.
1804 ///
1805 /// Prior to this lift the `Self::SoftPurge { module } |
1806 /// Self::Purge { module }` two-arm cleanup-family pattern-
1807 /// match sat inline at three within-entry cross-instruction
1808 /// gate sites, each hand-rolling its own copy of the union
1809 /// with no compile-time link back to the substrate primitive's
1810 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1811 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1812 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1813 /// arriving before a preceding [`Self::LoadModule`]),
1814 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1815 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1816 /// recording the first-encountered cleanup so a subsequent
1817 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1818 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1819 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1820 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1821 /// second cleanup targeting the same `:module`). Three open-
1822 /// coded per-arm-union pattern-matches that expressed no
1823 /// compile-time link back to the substrate primitive. A future
1824 /// fifth cleanup-shaped variant (a `Discard` variant the
1825 /// `code:delete/1` peer inspires that folds under the same
1826 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1827 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1828 /// cool-down policy grows a two-arm shape, an operator-side
1829 /// pre-resolved cleanup-decision cache the predicate could
1830 /// route through the same `bool` return contract) would have
1831 /// had to be threaded through every open-coded per-arm-union
1832 /// pattern-match in lockstep or one gate would silently
1833 /// classify the new arm outside the cleanup family while the
1834 /// peer gates classified it in (or vice versa) — a
1835 /// classification split across the three within-entry cross-
1836 /// instruction gates at build time that lands far from the
1837 /// source [`UpgradeInstruction`] declaration with no field
1838 /// naming which gate carries the drifted arm-set. Lifting the
1839 /// resolution to a typed predicate on the substrate primitive
1840 /// means every downstream cleanup-facing consumer of the
1841 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1842 /// one typed dispatch — the resolver's arm-set migrates as a
1843 /// unit on any future arm addition composing under this
1844 /// predicate's `||` chain.
1845 ///
1846 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1847 /// derive-generated [`Self::is_restart`] terminal-fallback
1848 /// arm-discriminator predicate on the same closed-set
1849 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1850 /// family partition as one typed dispatch on the substrate
1851 /// primitive; `is_restart` on the single-arm terminal-
1852 /// fallback family, `is_cleanup` on the two-arm cleanup
1853 /// family), extended here from the single-arm case onto the
1854 /// two-arm arm-family union case. Composes through the
1855 /// [`gen_platform::IsVariant`]-derive-generated
1856 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1857 /// predicates rather than an open-coded raw `matches!`
1858 /// pattern-match, so a future rebrand on either underlying
1859 /// per-arm classifier flows through this predicate's one
1860 /// body without a coordinated per-consumer rewrite across
1861 /// the three within-entry cross-instruction gates that route
1862 /// through it. Peer of the sibling per-`:contratos`
1863 /// shape-family union predicates [`crate::WitContract::is_http`] /
1864 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
1865 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
1866 /// per-shape WIT-prefix rule the substrate primitive's arm-
1867 /// family partition names as one typed dispatch) — the same
1868 /// "one typed dispatch on the substrate primitive, thin
1869 /// projections at each consumer" discipline extended onto the
1870 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
1871 /// cleanup-family axis.
1872 ///
1873 /// The name `is_cleanup` maps directly onto the canonical
1874 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
1875 /// `code:soft_purge/1` — wait until no process is running v1,
1876 /// then discard. (`code:purge/1` kills v1 immediately if you
1877 /// don't care.)" — the two `code:*_purge/1` operations are
1878 /// the two-phase-load contract's cleanup half, paired under
1879 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
1880 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1881 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
1882 /// reaches for the same "cleanup" vocabulary in identifier +
1883 /// diagnostic form.
1884 #[must_use]
1885 pub const fn is_cleanup(&self) -> bool {
1886 self.is_soft_purge() || self.is_purge()
1887 }
1888}
1889
1890/// Reject upgrade instruction `:module` values that aren't K8s
1891/// DNS-1123 labels. Thin wrapper around
1892/// [`crate::render::is_dns_1123_label`] that maps the shared
1893/// parser-shaped reason into the kind-tagged
1894/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
1895/// diagnostics, so the author can grep their caixa.lisp for the
1896/// offending `(:<kind> <module>)` form and fix it in one edit.
1897///
1898/// The contract — the same DNS-1123 label rule the K8s apiserver
1899/// enforces on every `metadata.name` / Service name / label value the
1900/// module name lands in. Each upgrade instruction's `:module` is a
1901/// reference to a caixa name (the wasm-engine resolves it through the
1902/// same `ComputeUnit` registry the operator manages), so the value must
1903/// match every downstream apiserver-side schema: the per-Servico
1904/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
1905/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
1906/// against the loaded-module table at hot-upgrade dispatch, and the
1907/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
1908/// per-module reference axis. Same trajectory as `:children :caixa`
1909/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
1910/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
1911/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
1912///
1913/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
1914/// variant before this predicate is consulted, mirroring
1915/// `validate_membro_caixa`'s empty-first cascade.
1916fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
1917 // Routes through the shared
1918 // [`crate::render::require_valid_dns_1123_label`] gate the peer
1919 // name axes each land on. The `kind: &'static str` field flows
1920 // through both error variants so the diagnostic names which
1921 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
1922 // offending value came from.
1923 crate::render::require_valid_dns_1123_label(
1924 module,
1925 || UpgradeError::ModuleEmpty { kind },
1926 |reason| UpgradeError::ModuleInvalid {
1927 kind,
1928 module: module.to_string(),
1929 reason,
1930 },
1931 )
1932}
1933
1934#[derive(Debug, Error, PartialEq, Eq)]
1935pub enum UpgradeError {
1936 #[error(
1937 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
1938 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
1939 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
1940 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
1941 the running version through `semver::Version::parse` and matches it against each entry's \
1942 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
1943 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
1944 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
1945 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
1946 )]
1947 FromInvalid { from: String, reason: String },
1948 #[error(
1949 "upgrade instruction `{kind}` :module is empty (every appup module reference \
1950 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
1951 the instruction entirely)"
1952 )]
1953 ModuleEmpty { kind: &'static str },
1954 #[error(
1955 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
1956 {reason} (every appup module reference resolves to a caixa name, which lands \
1957 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
1958 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
1959 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
1960 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
1961 `\"cache-v2\"`)"
1962 )]
1963 ModuleInvalid {
1964 kind: &'static str,
1965 module: String,
1966 reason: String,
1967 },
1968 #[error("instruction's :script is empty")]
1969 EmptyScript,
1970 #[error(
1971 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
1972 root (Path::join would otherwise escape the project sandbox)",
1973 script.display()
1974 )]
1975 AbsoluteScript { script: PathBuf },
1976 #[error(
1977 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
1978 above the caixa root",
1979 script.display()
1980 )]
1981 ParentEscapeScript { script: PathBuf },
1982 #[error(
1983 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
1984 wasm-engine instantiator reads every migration script as tatara-lisp source through \
1985 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
1986 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
1987 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
1988 parser error far from the source caixa.lisp, with no field naming the offending \
1989 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
1990 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
1991 `\"lib/migrations/v01-to-v02.lisp\"`).",
1992 script.display()
1993 )]
1994 NonLispExtensionScript { script: PathBuf },
1995 #[error(
1996 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
1997 one matching block per running version (`release_handler:install_release/1` dispatches \
1998 on the loaded `:from` against the currently-running release), so two entries with the \
1999 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2000 pick either set non-deterministically). Author one path per prior version; if two \
2001 distinct instruction sequences are needed, fold them into one ordered list under the \
2002 single matching `(:from {from:?} :instructions (…))` block."
2003 )]
2004 DuplicateFrom { from: String },
2005 #[error(
2006 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2007 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2008 greater than or equal to the caixa's own version is structurally unreachable \
2009 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2010 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2011 is never reached because the operator never runs a version greater than or equal to \
2012 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2013 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2014 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2015 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2016 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2017 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2018 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2019 here as a self-upgrade no-op."
2020 )]
2021 FromNotBeforeVersao { from: String, versao: String },
2022 #[error(
2023 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2024 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2025 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2026 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2027 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2028 component-model world incompatibility, irreversible state shape change), and the \
2029 fallback is terminal by construction (the operator restarts the pod and the new \
2030 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2031 in both directions: if the typed instructions would succeed, `(:restart)` is \
2032 unreached; if they wouldn't, the typed instructions are dead because the operator \
2033 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2034 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2035 never repeated. If two distinct upgrade strategies are needed for the same prior \
2036 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2037 dispatch picks exactly one block per running version) — keep the typed sequence; \
2038 the fallback restart is what the operator does on any typed-sequence failure \
2039 already."
2040 )]
2041 RestartNotExclusive {
2042 from: String,
2043 restart_count: usize,
2044 other_kinds: Vec<&'static str>,
2045 },
2046 #[error(
2047 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2048 `(:load-module …)` in its :instructions list — a state migration is the \
2049 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2050 code, but the operator executes instructions in declared order, so this migration \
2051 runs while the only resident version is still the prior one (which expects the \
2052 pre-migration state shape). Load the new module first: author the canonical \
2053 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2054 resident before its state migration runs.",
2055 script.display(),
2056 script.display()
2057 )]
2058 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2059 #[error(
2060 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2061 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2062 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2063 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2064 then `code:soft_purge/1`), but the operator executes instructions in declared \
2065 order, so this cleanup runs while the only resident version is still the same \
2066 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2067 mid-request), leaving no replacement to route in-flight or future requests \
2068 to. Load the new module first: author the canonical `(:load-module …) \
2069 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2070 before the old code is drained or discarded."
2071 )]
2072 PurgeWithoutPriorLoad {
2073 from: String,
2074 kind: &'static str,
2075 module: String,
2076 },
2077 #[error(
2078 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2079 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2080 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2081 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2082 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2083 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2084 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2085 module for this reason. A second cleanup on the same module is at best redundant (the \
2086 module is already gone after the first cleanup, so the second is a no-op or undefined \
2087 depending on the operator's handling of a non-resident-module purge request) and at \
2088 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2089 wanted a fallback, but the operator runs declared instructions unconditionally — \
2090 fallback on cleanup failure is the operator's job, not authored into the entry). \
2091 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2092 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2093 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2094 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2095 )]
2096 DuplicateCleanup {
2097 from: String,
2098 module: String,
2099 kinds: Vec<&'static str>,
2100 },
2101 #[error(
2102 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2103 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2104 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2105 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2106 dispatch table reads the module name and brings up the corresponding component \
2107 alongside the running version. systools-generated `.relup` files emit at most one \
2108 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2109 {module:?})` instruction has no observable semantic relative to the first (the \
2110 component is already resident) — either dead code (copy-pasted load line) or a typo \
2111 masking a distinct module the author intended to load alongside (renamed both to \
2112 {module:?} by mistake), leaving the second module silently absent from the entry. \
2113 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2114 versions need loading alongside the running one, name them distinctly (e.g. \
2115 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2116 )]
2117 DuplicateLoadModule { from: String, module: String },
2118 #[error(
2119 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2120 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2121 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2122 state shape into the current-version shape: a one-shot transition, not a step that \
2123 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2124 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2125 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2126 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2127 author intended two distinct migration scripts) and at worst silent state corruption \
2128 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2129 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2130 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2131 transitions are needed (e.g. one module's schema *and* another module's projection), \
2132 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2133 script.display(),
2134 script.display(),
2135 script.display(),
2136 script.display()
2137 )]
2138 DuplicateStateChange { from: String, script: PathBuf },
2139 #[error(
2140 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2141 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2142 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2143 current shape, but the prior version's state only exists while the prior code is \
2144 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2145 analogs and drain or discard that prior code. The operator executes instructions in \
2146 declared order, so a cleanup ahead of a state-change has already drained the prior \
2147 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2148 the migration script runs, leaving the script either no-op (no prior-version state \
2149 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2150 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2151 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2152 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2153 strictly between load and cleanup. Author the canonical `(:load-module …) \
2154 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2155 migration runs against the prior-version state before the cleanup drains it.",
2156 script.display(),
2157 script.display()
2158 )]
2159 StateChangeAfterCleanup {
2160 from: String,
2161 script: PathBuf,
2162 prior_cleanup_kind: &'static str,
2163 prior_cleanup_module: String,
2164 },
2165 #[error(
2166 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2167 declare `:behavior :on-state-change` — the per-version migration script is the \
2168 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2169 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2170 realizes the composition by invoking the running gen_server's code_change/3 callback \
2171 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2172 composition into two typed slots, the per-version migration logic in this \
2173 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2174 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2175 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2176 migration during hot upgrades\"). The missing callback leaves the per-version script \
2177 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2178 callback at the migration step, finds it absent, and either fails the upgrade \
2179 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2180 current version stays load-bearing\") or silently skips the migration leaving the \
2181 new code running against unmigrated prior-version state. Add the callback: \
2182 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2183 path) alongside the existing `(:state-change {})` instruction (the per-version \
2184 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2185 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2186 migration — is the canonical shape).",
2187 script.display(),
2188 script.display()
2189 )]
2190 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2191}
2192
2193// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2194// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2195// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2196// two-slot struct-variant wire-up sites at
2197// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2198// / `script` from `instr.declared_path()`),
2199// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2200// (`self.prior_versao()` / `script.as_path()` from
2201// `instr.declared_path()`), and
2202// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2203// / `script` from `instr.declared_path()`) onto one substrate primitive
2204// per typed variant — the paired `{ from: String, script: PathBuf }`
2205// two-slot sibling on [`UpgradeError`] of the peer
2206// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2207// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2208// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2209// (8580068, 4 variants on `{ de, para }`),
2210// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2211// `{ de, para, wit, expected }`),
2212// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2213// variants on `{ <field>: String, reason: String }`), and
2214// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2215// variants on `{ de, para, <field>: String, reason: String }`) on the
2216// sibling `AplicacaoError` envelopes, and the peer
2217// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2218// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2219// (0419438, 4 variants on `{ caixa, kind, slots }`),
2220// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2221// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2222// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2223// `LayoutError` envelopes, plus the peer
2224// [`crate::limits::limits_codec_value_only_ctors!`] /
2225// [`crate::limits::limits_codec_value_byte_ctors!`] /
2226// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2227// wire-ups) on the sibling `LimitsError` envelopes.
2228//
2229// Each of the three wire-up sites on this shape opens the identical
2230// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2231// script: <script>.to_path_buf() }` struct-literal against a local
2232// `prior_versao()` and `declared_path()` accessor pair — the exact
2233// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2234// names as a bug, on the same altitude the peer `SupervisorError` /
2235// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2236// closed on their sibling envelopes. The three variants share one
2237// `{ from: String, script: PathBuf }` shape, so the fold routes each
2238// wire-up site through one dispatch per typed variant.
2239//
2240// The macro below generates one `#[must_use]` inherent constructor per
2241// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2242// Self`, so every wire-up site collapses onto one dispatch:
2243// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2244// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2245// uniform two-field construction (`from.to_string()` /
2246// `script.to_path_buf()`) is spelled once — inside the macro — rather
2247// than at every wire-up site. The `&Path` parameter accepts both
2248// `&Path` (from `script.as_path()` at the uniqueness gate) and
2249// `&PathBuf` (from `instr.declared_path()` at the ordering /
2250// callback-declaration gates, via Deref coercion), so every existing
2251// wire-up threads through the ctor without a pre-conversion.
2252//
2253// Every future consumer that wants to construct one of these three
2254// variants outside the three in-crate `UpgradeFromEntry` /
2255// `validate_state_change_on_state_change_callback` gates (a deferred
2256// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2257// re-checker at hot-upgrade dispatch time, a future
2258// `feira validate --upgrade-from` per-caixa admission verb re-checking
2259// the three axes, a per-`Caixa` overlay resolver rejecting an
2260// ordering / uniqueness / callback-declaration invariant against a
2261// cluster-local snapshot) now reaches each variant through one call
2262// rather than re-inlining the three-line struct-literal in lockstep
2263// with the three in-crate wire-up sites.
2264macro_rules! upgrade_from_script_ctors {
2265 ($($ctor:ident => $variant:ident),* $(,)?) => {
2266 impl UpgradeError {
2267 $(
2268 #[doc = concat!(
2269 "Construct an [`UpgradeError::",
2270 stringify!($variant),
2271 "`] naming the offending `(:from <prior-versao>)` and ",
2272 "`(:state-change <script>)` pair. Folds the uniform ",
2273 "`Self::",
2274 stringify!($variant),
2275 " { from: from.to_string(), script: script.to_path_buf() }` ",
2276 "two-field struct-literal onto one substrate primitive so ",
2277 "every wire-up on this variant reads through one dispatch ",
2278 "rather than the pre-lift three-line open-coded block. The ",
2279 "`from` string threads verbatim from ",
2280 "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2281 "from [`UpgradeInstruction::declared_path`] at the call site."
2282 )]
2283 #[must_use]
2284 pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2285 Self::$variant {
2286 from: from.to_string(),
2287 script: script.to_path_buf(),
2288 }
2289 }
2290 )*
2291 }
2292 };
2293}
2294
2295upgrade_from_script_ctors! {
2296 state_change_without_prior_load => StateChangeWithoutPriorLoad,
2297 duplicate_state_change => DuplicateStateChange,
2298 state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2299}
2300
2301// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2302// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2303// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2304// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2305// onto one substrate primitive per typed variant — the paired
2306// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2307// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2308// `{ from: String, script: PathBuf }`) two-slot family on the same
2309// envelope, and of the peer
2310// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2311// variants on `{ caixa: String }`) and
2312// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2313// `{ nome: String }`) single-slot families on the sibling
2314// `SupervisorError` / `DepError` envelopes, and of the peer
2315// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2316// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2317// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2318// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2319// variants on `{ <field>: String, reason: String }`), and
2320// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2321// variants on `{ de, para, <field>: String, reason: String }`) on the
2322// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2323// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2324// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2325// [`crate::LayoutError::missing_entry`] 1b09f9d;
2326// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2327// `LimitsError` codec families (81c856c), and the sibling
2328// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2329// `{ nome, caminho }`) two-slot family.
2330//
2331// The three wire-up sites this fold closes are the three closures
2332// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2333// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2334// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2335// passed to [`crate::render::require_sandboxed_lisp_path`] at
2336// [`UpgradeInstruction::validate`] — each opens the identical
2337// `UpgradeError::<Variant> { script: script.clone() }` three-line
2338// struct-literal against the same `script: &PathBuf` local threaded
2339// from [`UpgradeInstruction::declared_path`], the exact "same block
2340// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2341// bug. The three variants share one `{ script: PathBuf }` shape, so
2342// the fold routes each closure through one dispatch per typed variant.
2343// The sibling `EmptyScript` unit-variant on the same envelope stays on
2344// its pre-lift open-coded shape — it carries no `script` field (the
2345// offending `:script` value *is* the empty path this variant catches),
2346// so the uniform `fn(script: &Path) -> Self` signature this macro
2347// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2348// closure is already a one-liner. This is the second fold family on
2349// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2350// two-slot family established in 8e67041, which explicitly named this
2351// `{ script: PathBuf }` single-slot family as the next fold to land
2352// on the envelope; per that commit's coverage roster, both of the two
2353// most-populated shapes on `UpgradeError` — the two-slot
2354// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2355//
2356// The macro below generates one `#[must_use]` inherent constructor per
2357// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2358// every closure collapses onto one dispatch:
2359// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2360// struct-literal on the same `&Path` fixture. The uniform one-field
2361// construction (`script.to_path_buf()`) is spelled once — inside the
2362// macro — rather than at every wire-up site. The `&Path` parameter
2363// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2364// `instr.declared_path()` at the three closures, via Deref coercion),
2365// so every existing closure threads through the ctor without a
2366// pre-conversion.
2367//
2368// Every future consumer that wants to construct one of these three
2369// variants outside the three in-crate closures (a deferred
2370// wasm-operator's `install_release/1` per-instruction script-shape
2371// re-checker at hot-upgrade dispatch time, a future
2372// `feira validate --upgrade-from` per-caixa admission verb re-checking
2373// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2374// an author-supplied `:state-change :script` against a cluster-local
2375// snapshot) now reaches each variant through one call rather than
2376// re-inlining the three-line struct-literal in lockstep with the three
2377// in-crate closure sites.
2378macro_rules! upgrade_script_only_ctors {
2379 ($($ctor:ident => $variant:ident),* $(,)?) => {
2380 impl UpgradeError {
2381 $(
2382 #[doc = concat!(
2383 "Construct an [`UpgradeError::",
2384 stringify!($variant),
2385 "`] naming the offending `(:state-change <script>)`. ",
2386 "Folds the uniform `Self::",
2387 stringify!($variant),
2388 " { script: script.to_path_buf() }` one-field ",
2389 "struct-literal onto one substrate primitive so every ",
2390 "closure passed to ",
2391 "[`crate::render::require_sandboxed_lisp_path`] at ",
2392 "[`UpgradeInstruction::validate`] on this variant reads ",
2393 "through one dispatch rather than the pre-lift three-line ",
2394 "open-coded block. The `script` path threads verbatim ",
2395 "from [`UpgradeInstruction::declared_path`] at the call ",
2396 "site."
2397 )]
2398 #[must_use]
2399 pub fn $ctor(script: &std::path::Path) -> Self {
2400 Self::$variant {
2401 script: script.to_path_buf(),
2402 }
2403 }
2404 )*
2405 }
2406 };
2407}
2408
2409upgrade_script_only_ctors! {
2410 absolute_script => AbsoluteScript,
2411 parent_escape_script => ParentEscapeScript,
2412 non_lisp_extension_script => NonLispExtensionScript,
2413}
2414
2415#[cfg(test)]
2416mod tests {
2417 use std::path::Path;
2418
2419 use super::*;
2420
2421 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
2422 UpgradeFromEntry {
2423 from: from.into(),
2424 instructions: instrs,
2425 }
2426 }
2427
2428 #[test]
2429 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
2430 // Fail-before-pass-after pin on
2431 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
2432 // posture. The accessor projects the per-`:upgrade-from :from`
2433 // [`String`] storage through the `pub const fn`
2434 // [`String::as_str`] (const-stable since Rust 1.87, well within
2435 // the workspace MSRV) — any future accidental downgrade to
2436 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
2437 // build time with E0015 (`cannot call non-const method`),
2438 // strictly stronger than a runtime `assert!`. Sibling of the
2439 // peer M2/M3 slot family pins on the sibling `const`-eval-
2440 // surface passes ([`crate::Caixa::nome`] /
2441 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
2442 // [`crate::aplicacao::Membro::nome`] /
2443 // [`crate::aplicacao::Membro::versao_requirement`],
2444 // [`crate::aplicacao::Entrada::hostname`] /
2445 // [`crate::aplicacao::Entrada::destination`],
2446 // [`crate::supervisor::ChildSpec::nome`] /
2447 // [`crate::supervisor::ChildSpec::versao_requirement`],
2448 // [`crate::dep::Dep::nome`] /
2449 // [`crate::dep::Dep::versao_requirement`], and the
2450 // per-`:contratos`
2451 // [`crate::aplicacao::WitContract::source`] /
2452 // [`crate::aplicacao::WitContract::destination`] /
2453 // [`crate::aplicacao::WitContract::world_ref`] trio the
2454 // sibling pin at 279823b already anchors).
2455 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
2456 e.prior_versao()
2457 }
2458 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
2459 let e = entry(from, vec![]);
2460 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
2461 assert_eq!(e.prior_versao(), from);
2462 }
2463 }
2464
2465 #[test]
2466 fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
2467 // Fail-before-pass-after pin on
2468 // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
2469 // posture. The accessor destructures the per-`:upgrade-from
2470 // :instructions` `Vec<UpgradeInstruction>` storage through the
2471 // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
2472 // 1.66, well within the workspace MSRV) — any future
2473 // accidental downgrade to non-`const` fails
2474 // `instructions_via_const_fn` at caixa-core build time with
2475 // E0015 (`cannot call non-const method`), strictly stronger
2476 // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
2477 // slot `Vec → &[T]` slice-return accessor family pin
2478 // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
2479 // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
2480 // per-`:membros` / per-`:contratos` slice-return axes, and of
2481 // the peer M2 supervisor-tree axis pin
2482 // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
2483 // on the per-`:children` slice-return axis.
2484 const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
2485 e.instructions()
2486 }
2487 // Sweep both the empty-instructions arm (author-declared
2488 // per-`:from` entry with no migration steps — the degenerate
2489 // shape the appup `restart`-only path folds through) and the
2490 // populated-instructions arm (the canonical OTP-appup shape
2491 // carrying a `LoadModule` + `StateChange` + `SoftPurge`
2492 // chain) so the accessor carries a const-dispatch pin on
2493 // both arms.
2494 let e_empty = entry("0.1.0", vec![]);
2495 assert!(instructions_via_const_fn(&e_empty).is_empty());
2496 assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
2497 let e_full = entry(
2498 "0.1.0",
2499 vec![
2500 UpgradeInstruction::LoadModule {
2501 module: "hello-rio".into(),
2502 },
2503 UpgradeInstruction::StateChange {
2504 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
2505 },
2506 UpgradeInstruction::SoftPurge {
2507 module: "hello-rio-old".into(),
2508 },
2509 ],
2510 );
2511 assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
2512 assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
2513 }
2514
2515 #[test]
2516 fn round_trip_load_module() {
2517 let i = UpgradeInstruction::LoadModule {
2518 module: "hello-rio".into(),
2519 };
2520 let json = serde_json::to_string(&i).unwrap();
2521 assert!(json.contains("\"kind\":\"load-module\""));
2522 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2523 assert_eq!(i, back);
2524 }
2525
2526 #[test]
2527 fn round_trip_all_variants() {
2528 let cases = vec![
2529 UpgradeInstruction::LoadModule { module: "x".into() },
2530 UpgradeInstruction::StateChange {
2531 script: PathBuf::from("lib/migrations.lisp"),
2532 },
2533 UpgradeInstruction::SoftPurge {
2534 module: "x-old".into(),
2535 },
2536 UpgradeInstruction::Purge {
2537 module: "x-old".into(),
2538 },
2539 UpgradeInstruction::Restart,
2540 ];
2541 for c in cases {
2542 let json = serde_json::to_string(&c).unwrap();
2543 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
2544 assert_eq!(c, back);
2545 }
2546 }
2547
2548 #[test]
2549 fn validate_accepts_well_formed() {
2550 let e = entry(
2551 "0.1.0",
2552 vec![
2553 UpgradeInstruction::LoadModule {
2554 module: "hello-rio".into(),
2555 },
2556 UpgradeInstruction::StateChange {
2557 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
2558 },
2559 UpgradeInstruction::SoftPurge {
2560 module: "hello-rio-old".into(),
2561 },
2562 ],
2563 );
2564 e.validate().unwrap();
2565 }
2566
2567 #[test]
2568 fn validate_rejects_non_semver_from() {
2569 let e = entry("not-a-semver", vec![]);
2570 let err = e.validate().unwrap_err();
2571 assert!(
2572 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
2573 );
2574 }
2575
2576 #[test]
2577 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
2578 // Diagnostic-shape pin: the error names the offending
2579 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
2580 // reason, so a `feira lint` run can render the diagnostic
2581 // without re-parsing — the author can grep their caixa.lisp for
2582 // `:from "<value>"` and fix it in one edit. Mirrors the peer
2583 // `versao_invalid_diagnostic_carries_offending_versao` pin on
2584 // the sibling SemVer-2 axis (the top-level `:versao`), the
2585 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
2586 // pin on `:membros :versao`, and the peer
2587 // `deps_invalid_diagnostic_carries_offending_value` pin on
2588 // `:deps :versao` — every SemVer-2-parsing slot's invalid
2589 // diagnostic is now structurally equivalent.
2590 let e = entry("v0.1.0", vec![]);
2591 let err = e.validate().unwrap_err();
2592 let UpgradeError::FromInvalid { from, reason } = err else {
2593 panic!("expected FromInvalid variant, got {err:?}");
2594 };
2595 assert_eq!(from, "v0.1.0");
2596 assert!(
2597 !reason.is_empty(),
2598 "FromInvalid `reason` must carry the parser's wording verbatim"
2599 );
2600 }
2601
2602 #[test]
2603 fn prior_versao_returns_from_byte_equal_across_permutations() {
2604 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
2605 // accessor across the SemVer-2 shape lattice every consumer
2606 // reaches through it — the numeric-triad canonical shape, a
2607 // pre-release build with a dotted identifier chain, a full-
2608 // metadata build, a large-magnitude triad, and the empty
2609 // string (which reaches this accessor unchanged before any
2610 // validate gate rejects it). Sibling to the peer
2611 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
2612 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
2613 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
2614 // family — extended here onto the first M2 slot scalar-value
2615 // axis. Any silent detour on the accessor (a `.to_string()`
2616 // + retained ownership shape, a canonicalization pass, a
2617 // trim-whitespace on the return path) surfaces as a byte-
2618 // inequality failure here rather than as a downstream error-
2619 // diagnostic drift.
2620 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
2621 for from in cases {
2622 let e = entry(from, vec![]);
2623 assert_eq!(
2624 e.prior_versao(),
2625 from,
2626 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
2627 );
2628 assert_eq!(
2629 e.prior_versao().len(),
2630 from.len(),
2631 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
2632 );
2633 }
2634 }
2635
2636 #[test]
2637 fn prior_versao_borrows_from_from_storage() {
2638 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
2639 // a borrow into `self.from`'s heap allocation, never a fresh
2640 // owned copy. Guards against a future silent detour where
2641 // the accessor materializes a `Cow<'_, str>` / `String` /
2642 // `Rc<str>` intermediate — the return path stays zero-cost
2643 // even under a refactor that reshapes the storage. Sibling
2644 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
2645 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
2646 // (4a32abf) pins — extended onto the M2 slot's first
2647 // scalar-value axis.
2648 let e = entry("0.1.0", vec![]);
2649 assert!(
2650 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
2651 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
2652 );
2653 }
2654
2655 #[test]
2656 fn validate_parses_prior_versao_through_lifted_accessor() {
2657 // Coherence pin between the accessor and the SemVer-2 parse
2658 // gate: every `:upgrade-from :from` value the validator
2659 // accepts (resp. rejects) must be identical to what
2660 // `Version::parse(entry.prior_versao())` accepts (resp.
2661 // rejects) — the two must remain in lockstep across the
2662 // shape lattice so `validate_upgrade_from`'s
2663 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
2664 // assertion holds by construction. If a future extension of
2665 // `prior_versao` reshapes the return (a canonicalization
2666 // pass, a leading/trailing whitespace trim, an empty-to-
2667 // "0.0.0" fallback) it would either loosen the validator
2668 // (silently accepting shapes the parser rejects) or
2669 // tighten the parser's re-parse (silently panicking on
2670 // shapes the validator accepts) — this pin catches either
2671 // shift at caixa-core build time.
2672 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
2673 for from in accepted {
2674 let e = entry(from, vec![]);
2675 e.validate().unwrap_or_else(|err| {
2676 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
2677 });
2678 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
2679 panic!(
2680 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
2681 got {err:?}",
2682 );
2683 });
2684 }
2685 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
2686 for from in rejected {
2687 let e = entry(from, vec![]);
2688 assert!(
2689 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
2690 "validate() must reject {from:?} that Version::parse rejects",
2691 );
2692 assert!(
2693 semver::Version::parse(e.prior_versao()).is_err(),
2694 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
2695 );
2696 }
2697 }
2698
2699 #[test]
2700 fn validate_rejects_empty_module() {
2701 // Per-arm coverage: every Module-bearing variant surfaces the
2702 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
2703 // so the author can grep their caixa.lisp for `(:load-module
2704 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
2705 // edit — same self-locating shape `BehaviorError::EmptyPath`
2706 // (b0c8389) carries on the peer M2 typed slot.
2707 let cases: &[(UpgradeInstruction, &'static str)] = &[
2708 (
2709 UpgradeInstruction::LoadModule {
2710 module: String::new(),
2711 },
2712 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
2713 ),
2714 (
2715 UpgradeInstruction::SoftPurge {
2716 module: String::new(),
2717 },
2718 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
2719 ),
2720 (
2721 UpgradeInstruction::Purge {
2722 module: String::new(),
2723 },
2724 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
2725 ),
2726 ];
2727 for (instr, expected_kind) in cases {
2728 assert_eq!(
2729 instr.validate().unwrap_err(),
2730 UpgradeError::ModuleEmpty {
2731 kind: expected_kind
2732 },
2733 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
2734 );
2735 }
2736 }
2737
2738 #[test]
2739 fn validate_rejects_non_dns_1123_module() {
2740 // Every appup `:module` reference is a caixa name (the
2741 // wasm-engine resolves it through the same ComputeUnit
2742 // registry the operator manages), so the value-shape gate
2743 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
2744 // the canonical authoring footguns — uppercase letters, `_`
2745 // separator, embedded `.`, leading/trailing `-`, an embedded
2746 // whitespace byte, the >63-byte UUID-shaped slug — across
2747 // every Module-bearing variant; each must surface as
2748 // `ModuleInvalid { kind, module, reason }` carrying the
2749 // offending value verbatim and the parser-shaped reason.
2750 type Build = fn(String) -> UpgradeInstruction;
2751 let footguns: &[&str] = &[
2752 "Hello-Rio",
2753 "hello_rio",
2754 "hello.rio",
2755 "-hello",
2756 "hello-",
2757 "hello rio",
2758 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
2759 ];
2760 let variants: &[(Build, &'static str)] = &[
2761 (
2762 |m| UpgradeInstruction::LoadModule { module: m },
2763 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
2764 ),
2765 (
2766 |m| UpgradeInstruction::SoftPurge { module: m },
2767 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
2768 ),
2769 (
2770 |m| UpgradeInstruction::Purge { module: m },
2771 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
2772 ),
2773 ];
2774 for (build, expected_kind) in variants {
2775 for module in footguns {
2776 let instr = build((*module).to_string());
2777 let err = instr.validate().unwrap_err();
2778 match err {
2779 UpgradeError::ModuleInvalid {
2780 kind,
2781 module: m,
2782 reason,
2783 } => {
2784 assert_eq!(
2785 kind, *expected_kind,
2786 ":module footgun on {instr:?} must tag the lisp-form"
2787 );
2788 assert_eq!(
2789 m, *module,
2790 "ModuleInvalid must carry the offending value verbatim"
2791 );
2792 assert!(
2793 !reason.is_empty(),
2794 "ModuleInvalid reason must name the specific violation \
2795 (the predicate's parser-shaped wording from \
2796 `is_dns_1123_label`), got empty"
2797 );
2798 }
2799 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
2800 }
2801 }
2802 }
2803 }
2804
2805 #[test]
2806 fn validate_accepts_canonical_module_names() {
2807 // Positive control: every documented authoring shape — bare
2808 // identifier, with hyphens, with digits, the
2809 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
2810 // references — passes the gate. Drift here = a future
2811 // tighten that rejects any of these surfaces as a
2812 // test-failure at the predicate boundary, not piecemeal
2813 // across per-instruction call sites.
2814 let canonical: &[&str] = &[
2815 "hello-rio",
2816 "hello-rio-old",
2817 "cache",
2818 "cache-v2",
2819 "x",
2820 "a1",
2821 "0a",
2822 "abc-123-def",
2823 ];
2824 for module in canonical {
2825 UpgradeInstruction::LoadModule {
2826 module: (*module).to_string(),
2827 }
2828 .validate()
2829 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
2830 UpgradeInstruction::SoftPurge {
2831 module: (*module).to_string(),
2832 }
2833 .validate()
2834 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
2835 UpgradeInstruction::Purge {
2836 module: (*module).to_string(),
2837 }
2838 .validate()
2839 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
2840 }
2841 }
2842
2843 #[test]
2844 fn validate_empty_takes_precedence_over_invalid() {
2845 // Empty input is rejected via the narrower `ModuleEmpty`
2846 // diagnostic before the DNS-1123 predicate is consulted, so
2847 // a future tighten that adds another stage between the two
2848 // doesn't accidentally reorder the diagnostic precedence.
2849 // Mirrors the empty-first cascade on every peer DNS-1123
2850 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
2851 // `SupervisorSpec::validate`'s child-name arm).
2852 let err = UpgradeInstruction::LoadModule {
2853 module: String::new(),
2854 }
2855 .validate()
2856 .unwrap_err();
2857 assert_eq!(
2858 err,
2859 UpgradeError::ModuleEmpty {
2860 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
2861 }
2862 );
2863 }
2864
2865 #[test]
2866 fn validate_rejects_empty_script() {
2867 let i = UpgradeInstruction::StateChange {
2868 script: PathBuf::new(),
2869 };
2870 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
2871 }
2872
2873 #[test]
2874 fn validate_rejects_absolute_script() {
2875 let i = UpgradeInstruction::StateChange {
2876 script: PathBuf::from("/etc/migrations.lisp"),
2877 };
2878 assert!(matches!(
2879 i.validate().unwrap_err(),
2880 UpgradeError::AbsoluteScript { .. }
2881 ));
2882 }
2883
2884 #[test]
2885 fn validate_rejects_parent_escape_script() {
2886 let i = UpgradeInstruction::StateChange {
2887 script: PathBuf::from("../sibling/migrations.lisp"),
2888 };
2889 assert!(matches!(
2890 i.validate().unwrap_err(),
2891 UpgradeError::ParentEscapeScript { .. }
2892 ));
2893 // mid-path `..` is also caught
2894 let i2 = UpgradeInstruction::StateChange {
2895 script: PathBuf::from("lib/../../escaped.lisp"),
2896 };
2897 assert!(matches!(
2898 i2.validate().unwrap_err(),
2899 UpgradeError::ParentEscapeScript { .. }
2900 ));
2901 }
2902
2903 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
2904 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
2905 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
2906 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
2907 // consumer; the file-type contract is identical, so the per-axis
2908 // test grid is mirrored leg-for-leg.
2909
2910 #[test]
2911 fn validate_rejects_no_extension_script() {
2912 // Fail-before-pass-after: the canonical "I declared the
2913 // migration script but forgot the `.lisp` extension"
2914 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
2915 // The wasm-engine's `tatara_lisp::read` consumer needs a
2916 // file-type contract beyond the structural-shape gate; a
2917 // no-extension path past `is_sandboxed_relative_path` would
2918 // surface a parser-shaped diagnostic at hot-upgrade migration
2919 // time far from the source caixa.lisp.
2920 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
2921 let i = UpgradeInstruction::StateChange {
2922 script: PathBuf::from(relpath),
2923 };
2924 let err = i.validate().unwrap_err();
2925 assert!(
2926 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2927 if s == Path::new(relpath)),
2928 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
2929 carrying the offending path verbatim, got {err:?}"
2930 );
2931 }
2932 }
2933
2934 #[test]
2935 fn validate_rejects_non_lisp_extension_script() {
2936 // Wrong-extension sweep across common authoring footguns: the
2937 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
2938 // drag in from the workspace tree, the `.rs` shape that an
2939 // IDE auto-complete might propose, the `.lisp.bak` shape an
2940 // editor might leave behind, and the `.lispx` near-miss that
2941 // a typo would produce. Each must surface as
2942 // `NonLispExtensionScript` carrying the offending path
2943 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
2944 // rejects all of these at hot-upgrade migration time, and
2945 // the gate lifts that contract to validate time. Mirrors the
2946 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
2947 // the `:behavior :on-*` axis leg-for-leg — same downstream
2948 // consumer, same accepted set, same per-axis test grid.
2949 let footguns: &[&str] = &[
2950 "lib/migrations.rs",
2951 "lib/migrations.txt",
2952 "lib/migrations.md",
2953 "lib/migrations.json",
2954 "lib/migrations.yaml",
2955 "lib/migrations.toml",
2956 "lib/migrations.lisp.bak",
2957 "lib/migrations.lispx",
2958 "lib/migrations.lis",
2959 ];
2960 for relpath in footguns {
2961 let i = UpgradeInstruction::StateChange {
2962 script: PathBuf::from(relpath),
2963 };
2964 let err = i.validate().unwrap_err();
2965 assert!(
2966 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2967 if s == Path::new(relpath)),
2968 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
2969 carrying the offending path verbatim, got {err:?}"
2970 );
2971 }
2972 }
2973
2974 #[test]
2975 fn validate_rejects_uppercase_lisp_extension_script() {
2976 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
2977 // case-folded shapes a case-insensitive volume's existence
2978 // check would match the on-disk file — but the
2979 // canonical-form codec emits lowercase `.lisp` verbatim, so
2980 // a case-folded shape mismatches the round-trip-stable
2981 // canonical form (THEORY.md §V.2.7 render-determinism).
2982 // Same case-sensitive discipline the byte-size / duration
2983 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
2984 // and every other shape-gate predicate in `render.rs` (label
2985 // / scheme / unit boundaries). Mirrors the peer
2986 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
2987 for relpath in [
2988 "lib/migrations.LISP",
2989 "lib/migrations.Lisp",
2990 "lib/migrations.LiSp",
2991 "lib/migrations.lISP",
2992 ] {
2993 let i = UpgradeInstruction::StateChange {
2994 script: PathBuf::from(relpath),
2995 };
2996 let err = i.validate().unwrap_err();
2997 assert!(
2998 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
2999 if s == Path::new(relpath)),
3000 "case-folded `.lisp` extension {relpath:?} must surface as \
3001 NonLispExtensionScript (strict lowercase, canonical-form \
3002 round-trip pin), got {err:?}"
3003 );
3004 }
3005 }
3006
3007 #[test]
3008 fn validate_accepts_canonical_lisp_extension_scripts() {
3009 // Positive-control sweep across every canonical in-tree
3010 // authoring shape: bare filename, standard `lib/`
3011 // subdirectory, deeply-nested migrations subdirectory,
3012 // explicit current-dir-relative prefix, mid-path `./`
3013 // segment, multi-dot stem (the version-suffix shape
3014 // `lib/migrations/v.0.1.lisp` an author might use to encode
3015 // the migration's `:from` version into the filename). Drift
3016 // here = a future tightening that rejects any of these
3017 // surfaces as a test-failure at the per-axis validator
3018 // boundary, not piecemeal across renderer / layout-checker
3019 // call sites. Mirrors the peer `BehaviorSpec` positive-set
3020 // sweep (c97815a).
3021 let canonical: &[&str] = &[
3022 "lib/migrations.lisp",
3023 "lib/migrations/v01-to-v02.lisp",
3024 "migrations.lisp",
3025 "a.lisp",
3026 "./lib/migrations.lisp",
3027 "lib/./migrations.lisp",
3028 "lib/migrations/v.0.1.lisp",
3029 ];
3030 for relpath in canonical {
3031 UpgradeInstruction::StateChange {
3032 script: PathBuf::from(relpath),
3033 }
3034 .validate()
3035 .unwrap_or_else(|e| {
3036 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3037 });
3038 }
3039 }
3040
3041 #[test]
3042 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3043 // Cross-arm precedence pin: a script that is *both*
3044 // sandbox-escaping (Empty / Absolute / ParentEscape) and
3045 // non-`.lisp` must surface the more-fundamental
3046 // sandbox-shape diagnostic first — the canonical fix
3047 // collapses both into "pin a relative `.lisp` path under the
3048 // caixa root", and the `.lisp` remediation would be
3049 // misleading when the offending path can never resolve under
3050 // the caixa root anyway. Mirrors the peer
3051 // `BehaviorError` cross-arm precedence (c97815a) and the
3052 // sibling `LimitsError`
3053 // (`MemoryZero` → `MemoryBelowWasm32Page` →
3054 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3055 // smallest-scope-arm-fires-last posture.
3056 let i_empty = UpgradeInstruction::StateChange {
3057 script: PathBuf::new(),
3058 };
3059 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3060 let i_abs = UpgradeInstruction::StateChange {
3061 script: PathBuf::from("/etc/migrations.txt"),
3062 };
3063 assert!(
3064 matches!(
3065 i_abs.validate().unwrap_err(),
3066 UpgradeError::AbsoluteScript { .. }
3067 ),
3068 "absolute + non-`.lisp` must surface AbsoluteScript first"
3069 );
3070 let i_esc = UpgradeInstruction::StateChange {
3071 script: PathBuf::from("../sibling/migrations.rs"),
3072 };
3073 assert!(
3074 matches!(
3075 i_esc.validate().unwrap_err(),
3076 UpgradeError::ParentEscapeScript { .. }
3077 ),
3078 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3079 );
3080 }
3081
3082 #[test]
3083 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3084 // Diagnostic-shape pin: the surfaced error message names the
3085 // offending path verbatim (so the author can grep their
3086 // caixa.lisp for the literal value), the `.lisp` extension
3087 // is named in the remediation, and the downstream consumer
3088 // (`tatara_lisp::read` at hot-upgrade migration time) is
3089 // named so the author can trace the contract back to its
3090 // source. Same self-locating shape every per-axis variant
3091 // carries (`BehaviorError::NonLispExtension`, c97815a;
3092 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3093 let bad = PathBuf::from("lib/migrations.txt");
3094 let err = UpgradeInstruction::StateChange {
3095 script: bad.clone(),
3096 }
3097 .validate()
3098 .unwrap_err();
3099 let msg = err.to_string();
3100 assert!(
3101 msg.contains("lib/migrations.txt"),
3102 "diagnostic must name the offending path verbatim, got {msg:?}"
3103 );
3104 assert!(
3105 msg.contains(".lisp"),
3106 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
3107 );
3108 assert!(
3109 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
3110 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
3111 );
3112 match err {
3113 UpgradeError::NonLispExtensionScript { script } => {
3114 assert_eq!(
3115 script, bad,
3116 "variant must carry the offending path verbatim"
3117 );
3118 }
3119 other => panic!("expected NonLispExtensionScript, got {other:?}"),
3120 }
3121 }
3122
3123 #[test]
3124 fn declared_path_only_for_state_change() {
3125 let load = UpgradeInstruction::LoadModule { module: "x".into() };
3126 assert!(load.declared_path().is_none());
3127 let mig = UpgradeInstruction::StateChange {
3128 script: PathBuf::from("lib/m.lisp"),
3129 };
3130 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
3131 }
3132
3133 #[test]
3134 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
3135 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3136 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
3137 // predicate: [`UpgradeInstruction::Restart`] is the only variant
3138 // that satisfies `.is_restart()`; every module-bearing arm
3139 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
3140 // `StateChange` arm all return `false`. This pin makes the
3141 // partition invariant load-bearing at caixa-core test time so a
3142 // future derive regression (a hole that returns `false` for
3143 // `Restart` too, or a byte-collision that flips a second variant
3144 // to `true`) trips here rather than laundering the arm at
3145 // [`Self::validate_restart_exclusive`]'s paired positive /
3146 // negated filter sites (a hole flips restart-count to 0 →
3147 // vacuous OK; a collision flips restart-count > 1 → false
3148 // `RestartNotExclusive` on an entry the author declared without
3149 // any `(:restart)`). Peer of the sibling
3150 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
3151 // pin on the M0 `CaixaKind` axis.
3152 let cases: &[(UpgradeInstruction, bool)] = &[
3153 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3154 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3155 (UpgradeInstruction::Purge { module: "c".into() }, false),
3156 (
3157 UpgradeInstruction::StateChange {
3158 script: PathBuf::from("lib/m.lisp"),
3159 },
3160 false,
3161 ),
3162 (UpgradeInstruction::Restart, true),
3163 ];
3164 for (variant, expected) in cases {
3165 assert_eq!(
3166 variant.is_restart(),
3167 *expected,
3168 "UpgradeInstruction::{variant:?}.is_restart() must \
3169 return {expected} (partition invariant on the \
3170 IsVariant-derived arm-discriminator predicate)"
3171 );
3172 }
3173 }
3174
3175 #[test]
3176 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
3177 // Byte-identity pin on the paired positive / negated
3178 // `.is_restart()` filters at
3179 // [`Self::validate_restart_exclusive`] against the pre-lift
3180 // `matches!(i, UpgradeInstruction::Restart)` /
3181 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
3182 // consumer of the gate previously coupled to inline. Asserts
3183 // the two projections agree byte-for-byte on every arm of the
3184 // enum, so a future derive regression that flipped either
3185 // predicate's arm-set would surface here at caixa-core test
3186 // time rather than at
3187 // [`Self::validate_restart_exclusive`]'s per-entry restart-
3188 // count / other-kinds tabulation far from the derive site.
3189 // Same peer-shape pin every sibling
3190 // `IsVariant`-derive-routed gate carries on the substrate's
3191 // closed-set typed-enum surface.
3192 let cases: Vec<UpgradeInstruction> = vec![
3193 UpgradeInstruction::LoadModule { module: "a".into() },
3194 UpgradeInstruction::SoftPurge { module: "b".into() },
3195 UpgradeInstruction::Purge { module: "c".into() },
3196 UpgradeInstruction::StateChange {
3197 script: PathBuf::from("lib/m.lisp"),
3198 },
3199 UpgradeInstruction::Restart,
3200 ];
3201 for instr in &cases {
3202 let via_predicate = instr.is_restart();
3203 let via_matches = matches!(instr, UpgradeInstruction::Restart);
3204 assert_eq!(
3205 via_predicate, via_matches,
3206 "UpgradeInstruction::{instr:?}: is_restart() must \
3207 byte-equal matches!(_, UpgradeInstruction::Restart) — \
3208 the pre-lift open-coded pattern and the \
3209 IsVariant-derived predicate are the same axis, \
3210 one typed dispatch"
3211 );
3212 }
3213 }
3214
3215 #[test]
3216 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
3217 // The fail-before-pass-after pin on the lifted
3218 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
3219 // arm-discriminator predicate:
3220 // [`UpgradeInstruction::SoftPurge`] and
3221 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
3222 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
3223 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
3224 // on the paired two-phase-load half,
3225 // [`UpgradeInstruction::StateChange`] on the
3226 // `gen_server:code_change/3`-analog migration axis,
3227 // [`UpgradeInstruction::Restart`] on the OTP terminal-
3228 // fallback shape) returns `false`. This pin makes the
3229 // partition invariant load-bearing at caixa-core test time
3230 // so a future accessor regression (a hole that returns
3231 // `false` for `SoftPurge` or `Purge`, or a byte-collision
3232 // that flips `LoadModule` / `StateChange` / `Restart` to
3233 // `true`) trips here rather than laundering the arm at the
3234 // three within-entry cross-instruction cleanup-facing gates
3235 // ([`UpgradeFromEntry::validate_purge_ordering`],
3236 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
3237 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
3238 // hole would silently accept a cleanup-shaped entry the
3239 // three gates should refuse; a collision would fire a
3240 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
3241 // `DuplicateCleanup` refusal on a well-shaped
3242 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
3243 // `Restart` arm the three gates should pass through. Peer
3244 // of the sibling
3245 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3246 // pin on the single-arm terminal-fallback partition —
3247 // extended here from the single-arm case onto the two-arm
3248 // cleanup-family union case.
3249 let cases: &[(UpgradeInstruction, bool)] = &[
3250 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
3251 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
3252 (UpgradeInstruction::Purge { module: "c".into() }, true),
3253 (
3254 UpgradeInstruction::StateChange {
3255 script: PathBuf::from("lib/m.lisp"),
3256 },
3257 false,
3258 ),
3259 (UpgradeInstruction::Restart, false),
3260 ];
3261 for (variant, expected) in cases {
3262 assert_eq!(
3263 variant.is_cleanup(),
3264 *expected,
3265 "UpgradeInstruction::{variant:?}.is_cleanup() must \
3266 return {expected} (partition invariant on the \
3267 lifted OTP-appup two-arm cleanup-family arm-\
3268 discriminator predicate)"
3269 );
3270 }
3271 }
3272
3273 #[test]
3274 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
3275 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
3276 // composition against the two [`gen_platform::IsVariant`]-
3277 // derive-generated per-variant classifiers it routes through
3278 // — the accessor's one body must byte-equal
3279 // `self.is_soft_purge() || self.is_purge()` across every arm
3280 // of the closed-set enum, so a future silent detour that
3281 // reintroduced a raw `matches!` pattern or that stopped
3282 // composing through the derive-generated per-variant
3283 // predicates (an accidental `self.is_soft_purge()` on its
3284 // own — silently dropping the `Purge` arm; an accidental
3285 // `self.is_purge() || self.is_state_change()` — silently
3286 // folding the migration arm into the cleanup family; a
3287 // typo `&&` for the union `||` — silently classifying no
3288 // arm as cleanup) trips here at caixa-core test time
3289 // rather than laundering the arm at the three within-entry
3290 // cross-instruction cleanup-facing gates. Same peer-shape
3291 // pin the sibling
3292 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3293 // carries on the paired terminal-fallback axis.
3294 let cases: Vec<UpgradeInstruction> = vec![
3295 UpgradeInstruction::LoadModule { module: "a".into() },
3296 UpgradeInstruction::SoftPurge { module: "b".into() },
3297 UpgradeInstruction::Purge { module: "c".into() },
3298 UpgradeInstruction::StateChange {
3299 script: PathBuf::from("lib/m.lisp"),
3300 },
3301 UpgradeInstruction::Restart,
3302 ];
3303 for instr in &cases {
3304 let via_predicate = instr.is_cleanup();
3305 let via_composition = instr.is_soft_purge() || instr.is_purge();
3306 assert_eq!(
3307 via_predicate, via_composition,
3308 "UpgradeInstruction::{instr:?}: is_cleanup() must \
3309 byte-equal is_soft_purge() || is_purge() — the \
3310 lifted union predicate and its per-variant \
3311 composition are the same axis, one typed dispatch"
3312 );
3313 }
3314 }
3315
3316 #[test]
3317 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
3318 // Composition-pin the load-bearing invariant every consumer
3319 // that routes through `is_cleanup()` + `declared_module()`
3320 // relies on: any [`UpgradeInstruction`] value whose
3321 // `.is_cleanup()` returns `true` must have a `Some(_)`
3322 // `.declared_module()`. This makes the three within-entry
3323 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
3324 // implies declared_module() is Some")` structurally
3325 // infallible at build time — a future refactor that added
3326 // a cleanup-shaped variant carrying no `:module` would trip
3327 // here rather than panic at
3328 // [`UpgradeFromEntry::validate_purge_ordering`] /
3329 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
3330 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
3331 // runtime on the offending author's caixa.lisp.
3332 let cases: Vec<UpgradeInstruction> = vec![
3333 UpgradeInstruction::LoadModule { module: "a".into() },
3334 UpgradeInstruction::SoftPurge { module: "b".into() },
3335 UpgradeInstruction::Purge { module: "c".into() },
3336 UpgradeInstruction::StateChange {
3337 script: PathBuf::from("lib/m.lisp"),
3338 },
3339 UpgradeInstruction::Restart,
3340 ];
3341 for instr in &cases {
3342 if instr.is_cleanup() {
3343 assert!(
3344 instr.declared_module().is_some(),
3345 "UpgradeInstruction::{instr:?}: is_cleanup() \
3346 must imply declared_module().is_some() — the \
3347 three within-entry cross-instruction cleanup-\
3348 facing gates rely on this invariant to route \
3349 the cleanup-target :module scalar through the \
3350 sibling declared_module accessor without a \
3351 pattern-bound `module` binding"
3352 );
3353 }
3354 }
3355 }
3356
3357 #[test]
3358 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
3359 // Composition-pin the load-bearing invariant
3360 // [`UpgradeFromEntry::validate_load_singularity`] relies on
3361 // when routing the per-instruction load-family arm-discriminator
3362 // through the sibling
3363 // [`UpgradeInstruction::is_load_module`] +
3364 // [`UpgradeInstruction::declared_module`] accessor pair: any
3365 // [`UpgradeInstruction`] value whose `.is_load_module()`
3366 // returns `true` must have a `Some(_)` `.declared_module()`.
3367 // This makes the gate's `.expect("is_load_module() implies
3368 // declared_module() is Some")` structurally infallible at
3369 // build time — a future refactor that added a load-shaped
3370 // variant carrying no `:module` would trip here rather than
3371 // panic at [`UpgradeFromEntry::validate_load_singularity`]
3372 // at runtime on the offending author's caixa.lisp. Sibling
3373 // of the peer
3374 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3375 // composition pin on the two-arm cleanup-family axis — same
3376 // "predicate implies accessor" discipline extended onto the
3377 // single-arm load-family axis, closes the load-vs-cleanup
3378 // pair on the substrate primitive's typed dispatch discipline.
3379 let cases: Vec<UpgradeInstruction> = vec![
3380 UpgradeInstruction::LoadModule { module: "a".into() },
3381 UpgradeInstruction::SoftPurge { module: "b".into() },
3382 UpgradeInstruction::Purge { module: "c".into() },
3383 UpgradeInstruction::StateChange {
3384 script: PathBuf::from("lib/m.lisp"),
3385 },
3386 UpgradeInstruction::Restart,
3387 ];
3388 for instr in &cases {
3389 if instr.is_load_module() {
3390 assert!(
3391 instr.declared_module().is_some(),
3392 "UpgradeInstruction::{instr:?}: is_load_module() \
3393 must imply declared_module().is_some() — the \
3394 within-entry load-singularity gate relies on this \
3395 invariant to route the load-target :module scalar \
3396 through the sibling declared_module accessor \
3397 without a pattern-bound `module` binding"
3398 );
3399 }
3400 }
3401 }
3402
3403 #[test]
3404 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
3405 {
3406 // Byte-identity pin on the
3407 // [`UpgradeFromEntry::validate_load_singularity`] load-family
3408 // dispatch against the pre-lift
3409 // `match instr { UpgradeInstruction::LoadModule { module } =>
3410 // module.as_str(), _ => continue }` open-coded pattern-match
3411 // the site previously carried. Asserts the two projections
3412 // agree byte-for-byte on every arm of the enum — the
3413 // arm-discriminator via `is_load_module()` and the `:module`
3414 // scalar via `declared_module()` — so a future derive
3415 // regression that flipped the predicate's arm-set (a hole
3416 // returning `false` for [`UpgradeInstruction::LoadModule`], a
3417 // byte-collision flipping a second variant to `true`) or an
3418 // accessor extension that promoted an additional variant onto
3419 // the `String`-carrying axis would trip here at caixa-core
3420 // test time rather than laundering the arm at the gate's
3421 // per-entry load-singularity scan far from the derive site.
3422 // Peer of the sibling
3423 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
3424 // byte-identity pin on the paired ordering-side load-family
3425 // sticky-latch dispatch (both consumers now agree on one
3426 // typed dispatch for the load-family axis) and the peer
3427 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
3428 // pin on the migration-family script-projection axis — the
3429 // three within-entry per-instruction-class singularity gates
3430 // now share one byte-identity pin apiece against their
3431 // respective substrate-primitive typed dispatches.
3432 //
3433 // Three-arm projective coverage:
3434 // (a) `LoadModule` modules project through
3435 // `declared_module()` byte-equal to the raw
3436 // `module.as_str()` field access;
3437 // (b) a duplicate-`LoadModule` input trips the gate on the
3438 // second occurrence with `DuplicateLoadModule` carrying
3439 // the offending module verbatim;
3440 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
3441 // `StateChange` / `Restart`) leaves the gate vacuous
3442 // with `Ok(())` — the `!instr.is_load_module()`
3443 // `continue` fall-through pins.
3444 //
3445 // Fail-before-pass-after verified locally: swapping the
3446 // production `if !instr.is_load_module() { continue; } let
3447 // module = instr.declared_module().expect(…);` back to `let
3448 // module = match instr { UpgradeInstruction::LoadModule
3449 // { module } => module.as_str(), _ => continue, };` keeps
3450 // arms (a)-(c) passing but silently detaches the gate from
3451 // the accessor's typed dispatch — any future
3452 // `is_load_module` / `declared_module` extension (a hole in
3453 // either predicate, a promotion of an additional variant
3454 // onto the `String`-carrying axis, an operator-side
3455 // pre-parsed caixa-name cache the accessor materializes)
3456 // would then silently disagree between this gate's raw
3457 // pattern-match and the peer per-`UpgradeInstruction`
3458 // consumers that route through the accessor pair.
3459
3460 // (a) LoadModule projection byte-equal via
3461 // is_load_module() + declared_module().
3462 let lm = UpgradeInstruction::LoadModule {
3463 module: "hello-rio".into(),
3464 };
3465 assert!(
3466 lm.is_load_module(),
3467 "LoadModule must satisfy is_load_module() — the gate's \
3468 load-family arm-discriminator relies on this partition"
3469 );
3470 assert_eq!(
3471 lm.declared_module(),
3472 Some("hello-rio"),
3473 "declared_module() must project the LoadModule :module \
3474 byte-equal to the raw field access — accessor divergence \
3475 would silently detach the gate from the projection every \
3476 peer per-`UpgradeInstruction` consumer routes through"
3477 );
3478
3479 // (b) Duplicate-LoadModule input trips the gate.
3480 let dup = entry(
3481 "0.1.0",
3482 vec![
3483 UpgradeInstruction::LoadModule { module: "x".into() },
3484 UpgradeInstruction::LoadModule { module: "x".into() },
3485 ],
3486 );
3487 assert_eq!(
3488 dup.validate_load_singularity(),
3489 Err(UpgradeError::DuplicateLoadModule {
3490 from: "0.1.0".into(),
3491 module: "x".into(),
3492 }),
3493 "duplicate LoadModule modules within one entry must fire \
3494 DuplicateLoadModule byte-identical to the pre-lift \
3495 pattern-match shape"
3496 );
3497
3498 // (c) Non-LoadModule-only input leaves the gate vacuous.
3499 let no_load = entry(
3500 "0.1.0",
3501 vec![
3502 UpgradeInstruction::StateChange {
3503 script: PathBuf::from("lib/m.lisp"),
3504 },
3505 UpgradeInstruction::Restart,
3506 ],
3507 );
3508 assert_eq!(
3509 no_load.validate_load_singularity(),
3510 Ok(()),
3511 "non-LoadModule-only entries must leave the load-\
3512 singularity gate vacuous — the `!is_load_module()` \
3513 continue fall-through pins"
3514 );
3515 }
3516
3517 #[test]
3518 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
3519 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
3520 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
3521 // predicate: [`UpgradeInstruction::LoadModule`] is the only
3522 // variant that satisfies `.is_load_module()`; every cleanup arm
3523 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
3524 // and the terminal-fallback arm (`Restart`) all return `false`.
3525 // This pin makes the partition invariant load-bearing at
3526 // caixa-core test time so a future derive regression (a hole
3527 // that returns `false` for `LoadModule` too, or a byte-collision
3528 // that flips a second variant to `true`) trips here rather than
3529 // laundering the arm at
3530 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
3531 // dispatch — a hole would silently keep `loaded = false` through
3532 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
3533 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
3534 // a collision would flip `loaded = true` on a well-shaped
3535 // cleanup-only entry and silently swallow the load-less
3536 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
3537 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
3538 // and
3539 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
3540 // pins on the paired terminal-fallback and cleanup-family
3541 // arm-discriminator axes — closes the last unlifted `matches!`-
3542 // based arm-discriminator axis on the OTP-appup closed-set
3543 // typed enum.
3544 let cases: &[(UpgradeInstruction, bool)] = &[
3545 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
3546 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
3547 (UpgradeInstruction::Purge { module: "c".into() }, false),
3548 (
3549 UpgradeInstruction::StateChange {
3550 script: PathBuf::from("lib/m.lisp"),
3551 },
3552 false,
3553 ),
3554 (UpgradeInstruction::Restart, false),
3555 ];
3556 for (variant, expected) in cases {
3557 assert_eq!(
3558 variant.is_load_module(),
3559 *expected,
3560 "UpgradeInstruction::{variant:?}.is_load_module() must \
3561 return {expected} (partition invariant on the \
3562 IsVariant-derived arm-discriminator predicate)"
3563 );
3564 }
3565 }
3566
3567 #[test]
3568 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
3569 // Byte-identity pin on the [`Self::validate_purge_ordering`]
3570 // load-family sticky-latch dispatch against the pre-lift
3571 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
3572 // predicate the site previously open-coded. Asserts the two
3573 // projections agree byte-for-byte on every arm of the enum, so
3574 // a future derive regression that flipped the predicate's
3575 // arm-set would surface here at caixa-core test time rather
3576 // than at [`Self::validate_purge_ordering`]'s per-entry
3577 // load-before-cleanup ordering scan far from the derive site.
3578 // Same peer-shape pin the sibling
3579 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
3580 // carries on the paired terminal-fallback axis and the
3581 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
3582 // carries on the two-arm cleanup-family axis — the third and
3583 // final byte-identity pin closes the substrate primitive's
3584 // arm-discriminator dispatch discipline on the OTP-appup
3585 // closed-set typed enum.
3586 let cases: Vec<UpgradeInstruction> = vec![
3587 UpgradeInstruction::LoadModule { module: "a".into() },
3588 UpgradeInstruction::SoftPurge { module: "b".into() },
3589 UpgradeInstruction::Purge { module: "c".into() },
3590 UpgradeInstruction::StateChange {
3591 script: PathBuf::from("lib/m.lisp"),
3592 },
3593 UpgradeInstruction::Restart,
3594 ];
3595 for instr in &cases {
3596 let via_predicate = instr.is_load_module();
3597 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
3598 assert_eq!(
3599 via_predicate, via_matches,
3600 "UpgradeInstruction::{instr:?}: is_load_module() must \
3601 byte-equal matches!(_, UpgradeInstruction::LoadModule \
3602 {{ .. }}) — the pre-lift open-coded pattern and the \
3603 IsVariant-derived predicate are the same axis, one \
3604 typed dispatch"
3605 );
3606 }
3607 }
3608
3609 #[test]
3610 fn declared_module_only_for_module_bearing_variants() {
3611 // Pinned partition of the `UpgradeInstruction` closed-set
3612 // variant space against the sibling of the peer
3613 // `declared_path` accessor: every OTP-appup module-bearing
3614 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
3615 // `:module` string byte-for-byte through the lifted
3616 // `declared_module` accessor; every non-module-bearing variant
3617 // (`StateChange` on the peer `:script`-carrying axis;
3618 // `Restart` on the OTP terminal-fallback data-less axis)
3619 // returns `None`. Mirrors the peer
3620 // `declared_path_only_for_state_change` pin — the pair now
3621 // closes both scalar-carrying axes on the enum on one lifted
3622 // `Option<&…>` accessor apiece.
3623 let load = UpgradeInstruction::LoadModule {
3624 module: "hello-rio".into(),
3625 };
3626 assert_eq!(load.declared_module(), Some("hello-rio"));
3627 let soft = UpgradeInstruction::SoftPurge {
3628 module: "hello-rio-old".into(),
3629 };
3630 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
3631 let hard = UpgradeInstruction::Purge {
3632 module: "hello-rio-ancient".into(),
3633 };
3634 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
3635 let mig = UpgradeInstruction::StateChange {
3636 script: PathBuf::from("lib/m.lisp"),
3637 };
3638 assert!(mig.declared_module().is_none());
3639 assert!(UpgradeInstruction::Restart.declared_module().is_none());
3640 }
3641
3642 #[test]
3643 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
3644 // Byte-identity pin on the two-accessor partition: every
3645 // `UpgradeInstruction` variant returns `Some` from *exactly
3646 // one* of {`declared_module`, `declared_path`} (the two
3647 // module-bearing / script-carrying axes) or from *neither*
3648 // (the OTP terminal-fallback `Restart` shape). No variant
3649 // returns `Some` from both — the two axes are disjoint by
3650 // construction, and this pin closes the disjointness at the
3651 // test surface so a future variant that leaks a scalar across
3652 // both axes fails at build time. Mirrors the peer
3653 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
3654 // discipline on the `BehaviorSpec` per-slot family.
3655 let cases: Vec<UpgradeInstruction> = vec![
3656 UpgradeInstruction::LoadModule { module: "a".into() },
3657 UpgradeInstruction::SoftPurge { module: "b".into() },
3658 UpgradeInstruction::Purge { module: "c".into() },
3659 UpgradeInstruction::StateChange {
3660 script: PathBuf::from("lib/m.lisp"),
3661 },
3662 UpgradeInstruction::Restart,
3663 ];
3664 for instr in &cases {
3665 let has_module = instr.declared_module().is_some();
3666 let has_path = instr.declared_path().is_some();
3667 assert!(
3668 !(has_module && has_path),
3669 "no variant may declare both a module and a path — offending: {instr:?}"
3670 );
3671 match instr {
3672 UpgradeInstruction::LoadModule { .. }
3673 | UpgradeInstruction::SoftPurge { .. }
3674 | UpgradeInstruction::Purge { .. } => {
3675 assert!(has_module && !has_path, "module axis: {instr:?}");
3676 }
3677 UpgradeInstruction::StateChange { .. } => {
3678 assert!(!has_module && has_path, "script axis: {instr:?}");
3679 }
3680 UpgradeInstruction::Restart => {
3681 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
3682 }
3683 }
3684 }
3685 }
3686
3687 #[test]
3688 fn entry_with_chain_of_versions() {
3689 // Middle entry pairs a `:load-module` with the trailing
3690 // `:soft-purge` so it satisfies the within-entry purge-ordering
3691 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
3692 // preceding `:load-module`, mirroring the state-change-ordering
3693 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
3694 // test is *cross-entry* `:from` values; the within-entry shape
3695 // is incidental — keeping it canonical (`:load-module` before
3696 // `:soft-purge`) leaves the chain assertion load-bearing.
3697 let entries = vec![
3698 entry(
3699 "0.1.0",
3700 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3701 ),
3702 entry(
3703 "0.1.5",
3704 vec![
3705 UpgradeInstruction::LoadModule { module: "x".into() },
3706 UpgradeInstruction::SoftPurge {
3707 module: "x-old".into(),
3708 },
3709 ],
3710 ),
3711 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
3712 ];
3713 for e in &entries {
3714 e.validate().unwrap();
3715 }
3716 let json = serde_json::to_string(&entries).unwrap();
3717 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
3718 assert_eq!(entries, back);
3719 }
3720
3721 #[test]
3722 fn empty_instructions_list_is_valid() {
3723 let e = entry("0.1.0", vec![]);
3724 e.validate().unwrap();
3725 }
3726
3727 #[test]
3728 fn json_uses_kebab_case_kind_tags() {
3729 let i = UpgradeInstruction::SoftPurge {
3730 module: "x-old".into(),
3731 };
3732 let json = serde_json::to_string(&i).unwrap();
3733 assert!(json.contains("\"kind\":\"soft-purge\""));
3734 let i2 = UpgradeInstruction::StateChange {
3735 script: PathBuf::from("m.lisp"),
3736 };
3737 let json2 = serde_json::to_string(&i2).unwrap();
3738 assert!(json2.contains("\"kind\":\"state-change\""));
3739 }
3740
3741 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
3742
3743 #[test]
3744 fn validate_upgrade_from_accepts_disjoint_versions() {
3745 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
3746 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
3747 // (and `entry_with_chain_of_versions` above) passes the cross-
3748 // entry gate. Different `:from` per entry is the intended
3749 // shape; the gate must not regress this baseline. Middle entry
3750 // pairs `:load-module` with `:soft-purge` to satisfy the
3751 // within-entry purge-ordering gate (see
3752 // `entry_with_chain_of_versions` for the same shape).
3753 let entries = vec![
3754 entry(
3755 "0.1.0",
3756 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3757 ),
3758 entry(
3759 "0.1.5",
3760 vec![
3761 UpgradeInstruction::LoadModule { module: "x".into() },
3762 UpgradeInstruction::SoftPurge {
3763 module: "x-old".into(),
3764 },
3765 ],
3766 ),
3767 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
3768 ];
3769 validate_upgrade_from(&entries).unwrap();
3770 }
3771
3772 #[test]
3773 fn validate_upgrade_from_accepts_empty_list() {
3774 // Absent `:upgrade-from` (the bare `feira init` shape) — the
3775 // gate must trivially pass an empty list. Mirrors the per-axis
3776 // "empty list passes" positive control on every peer typed-
3777 // graph gate (`validate_membros` empty list, `validate_placement`
3778 // requires non-empty clusters but only after a `Placement`
3779 // exists, etc.).
3780 validate_upgrade_from(&[]).unwrap();
3781 }
3782
3783 #[test]
3784 fn validate_upgrade_from_rejects_duplicate_from() {
3785 // Fail-before-pass-after pin: two entries with the same parsed-
3786 // semver `:from` are an ambiguous edge in the typed upgrade
3787 // graph (OTP appup picks at most one matching block per running
3788 // version; with two matching blocks the operator picks either
3789 // set non-deterministically — author intent is one path per
3790 // prior version). Same set-not-multiset discipline as
3791 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
3792 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
3793 // `:entrada :paths` (eb3456d) — now extended onto the fifth
3794 // typed-graph axis.
3795 let entries = vec![
3796 entry(
3797 "0.1.0",
3798 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
3799 ),
3800 entry(
3801 "0.1.0",
3802 vec![
3803 UpgradeInstruction::LoadModule { module: "x".into() },
3804 UpgradeInstruction::SoftPurge {
3805 module: "x-old".into(),
3806 },
3807 ],
3808 ),
3809 ];
3810 let err = validate_upgrade_from(&entries).unwrap_err();
3811 assert_eq!(
3812 err,
3813 UpgradeError::DuplicateFrom {
3814 from: "0.1.0".into()
3815 },
3816 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
3817 offending value verbatim"
3818 );
3819 }
3820
3821 #[test]
3822 fn validate_upgrade_from_treats_pre_release_as_distinct() {
3823 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
3824 // equal under semver (pre-release version is part of the
3825 // identity), so they're distinct upgrade paths and must not
3826 // collide. A future tightening that collapses pre-release into
3827 // the release version surfaces here.
3828 let entries = vec![
3829 entry("1.0.0", vec![UpgradeInstruction::Restart]),
3830 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
3831 ];
3832 validate_upgrade_from(&entries).unwrap();
3833 }
3834
3835 #[test]
3836 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
3837 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
3838 // compares build metadata (it derives equality across all
3839 // fields including `pre` + `build`), so `1.0.0+build1` and
3840 // `1.0.0+build2` are *not* duplicates from the gate's
3841 // perspective — the operator may treat the build-metadata
3842 // suffix as a tiebreaker even though the semver spec says
3843 // build metadata is ignored for precedence
3844 // (https://semver.org/#spec-item-10). Pin the conservative
3845 // behavior here so a future switch to a build-metadata-
3846 // stripping comparator surfaces as a test failure first; that
3847 // change would require coordinating with the wasm-operator's
3848 // `:from`-match dispatch step, which is the load-bearing
3849 // semantic we'd be mirroring.
3850 let entries = vec![
3851 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
3852 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
3853 ];
3854 validate_upgrade_from(&entries).unwrap();
3855 }
3856
3857 #[test]
3858 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
3859 // Order pin: a malformed `:from` on the second entry surfaces
3860 // its `FromInvalid` diagnostic, not a (less-useful)
3861 // `DuplicateFrom`. The per-entry shape pass runs *inline*
3862 // before the duplicate-key insert — parallel to
3863 // `child_versao_invalid_fires_before_duplicate_check`
3864 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
3865 // (9888b13). Without this pin a future shortcut that runs the
3866 // cross-entry gate first would surface a duplicate diagnostic
3867 // on a string that isn't even parsable as a version.
3868 let entries = vec![
3869 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3870 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
3871 ];
3872 let err = validate_upgrade_from(&entries).unwrap_err();
3873 assert!(
3874 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
3875 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
3876 );
3877 }
3878
3879 #[test]
3880 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
3881 // Symmetric arm: a malformed shape on the *first* entry of a
3882 // duplicate pair surfaces its per-entry diagnostic too (not
3883 // the duplicate diagnostic that would otherwise fire on the
3884 // second entry). Pinned separately so a future shortcut that
3885 // walks the duplicate-check ahead of the per-entry pass for the
3886 // first entry only — easy regression to introduce — surfaces
3887 // here.
3888 let entries = vec![
3889 entry(
3890 "0.1.0",
3891 vec![UpgradeInstruction::LoadModule {
3892 module: String::new(),
3893 }],
3894 ),
3895 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3896 ];
3897 let err = validate_upgrade_from(&entries).unwrap_err();
3898 assert_eq!(
3899 err,
3900 UpgradeError::ModuleEmpty {
3901 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3902 },
3903 "malformed instruction on the first entry of a duplicate pair must surface its \
3904 per-entry diagnostic before the duplicate gate fires, got {err:?}"
3905 );
3906 }
3907
3908 #[test]
3909 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
3910 // Diagnostic-shape pin: when three entries carry the same
3911 // `:from`, the gate reports the *first* collision (the second
3912 // entry) and stops — the third entry's duplicate is masked by
3913 // the first surfaced one. Mirrors
3914 // `validate_duplicate_child_diagnostic_names_first_collision`
3915 // (dbf50a9) on the supervisor axis.
3916 let entries = vec![
3917 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3918 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3919 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3920 ];
3921 let err = validate_upgrade_from(&entries).unwrap_err();
3922 assert_eq!(
3923 err,
3924 UpgradeError::DuplicateFrom {
3925 from: "0.1.0".into()
3926 }
3927 );
3928 }
3929
3930 #[test]
3931 fn validate_upgrade_from_single_entry_never_duplicates() {
3932 // Boundary control: a list of one entry can never produce a
3933 // duplicate, regardless of `:from` value (any single-element
3934 // set is trivially without duplicates). Pin this so a future
3935 // off-by-one in the seen-set insert doesn't accidentally flag
3936 // a single entry as duplicating itself.
3937 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
3938 validate_upgrade_from(&entries).unwrap();
3939 }
3940
3941 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
3942
3943 #[test]
3944 fn versao_gate_accepts_strict_upgrade() {
3945 // Positive control: the canonical "chain prior versions →
3946 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
3947 // `:from` strictly less than the current `:versao` under
3948 // SemVer-2 precedence. The gate must not regress this baseline.
3949 let entries = vec![
3950 entry("0.1.0", vec![UpgradeInstruction::Restart]),
3951 entry("0.1.5", vec![UpgradeInstruction::Restart]),
3952 entry("0.1.9", vec![UpgradeInstruction::Restart]),
3953 ];
3954 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
3955 }
3956
3957 #[test]
3958 fn versao_gate_accepts_empty_entries() {
3959 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
3960 // the gate is a no-op when the entries list is empty. Mirrors
3961 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
3962 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
3963 }
3964
3965 #[test]
3966 fn versao_gate_rejects_equal_from() {
3967 // Self-upgrade no-op: declaring `:from "0.2.0"` while
3968 // `:versao "0.2.0"` means "upgrade from myself to myself" —
3969 // the operator's dispatch either skips silently or
3970 // trivially "succeeds" with no observable state change.
3971 // Reject as the canonical "I forgot to bump :versao when
3972 // adding this entry" footgun.
3973 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
3974 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3975 assert_eq!(
3976 err,
3977 UpgradeError::FromNotBeforeVersao {
3978 from: "0.2.0".into(),
3979 versao: "0.2.0".into(),
3980 },
3981 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
3982 values verbatim, got {err:?}"
3983 );
3984 }
3985
3986 #[test]
3987 fn versao_gate_rejects_downgrade_from() {
3988 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
3989 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
3990 // the operator's `:from`-match dispatch can never reach (it
3991 // never runs a version >= the current one). Reject as the
3992 // canonical "I copy-pasted from the next minor version and
3993 // forgot to bump :versao" footgun.
3994 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
3995 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
3996 assert_eq!(
3997 err,
3998 UpgradeError::FromNotBeforeVersao {
3999 from: "0.3.0".into(),
4000 versao: "0.2.0".into(),
4001 }
4002 );
4003 }
4004
4005 #[test]
4006 fn versao_gate_accepts_prerelease_before_release() {
4007 // SemVer §11 precedence: pre-release versions are *less than*
4008 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4009 // FROM an RC TO the GA release is the canonical authoring
4010 // shape — must pass. A regression that collapses pre-release
4011 // into the release version (treating them as equal) surfaces
4012 // here as a false-positive rejection.
4013 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4014 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4015 }
4016
4017 #[test]
4018 fn versao_gate_rejects_release_after_prerelease() {
4019 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4020 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4021 // the typical "I'm on an RC of a release that already
4022 // shipped" footgun. The gate names both values verbatim
4023 // so the author can grep for either side and fix in one
4024 // edit.
4025 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4026 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4027 assert_eq!(
4028 err,
4029 UpgradeError::FromNotBeforeVersao {
4030 from: "0.2.0".into(),
4031 versao: "0.2.0-rc.1".into(),
4032 }
4033 );
4034 }
4035
4036 #[test]
4037 fn versao_gate_rejects_build_metadata_only_difference() {
4038 // SemVer §11 explicitly excludes build metadata from
4039 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4040 // *equal* under [`semver::Version::cmp`]. From the
4041 // operator's `:from`-match dispatch perspective this is a
4042 // self-upgrade no-op (no semantic transition between the
4043 // two), so the gate rejects it — *unlike* the peer
4044 // duplicate-`:from` gate which uses derived `PartialEq` and
4045 // treats build-metadata variants as distinct dispatch keys.
4046 // The two gates' different equality notions are deliberate:
4047 // duplicate-check is conservative (preserves operator-side
4048 // tiebreaking surface), precedence-check is permissive
4049 // (matches operator-side dispatch semantic).
4050 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4051 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4052 assert_eq!(
4053 err,
4054 UpgradeError::FromNotBeforeVersao {
4055 from: "0.2.0+build.1".into(),
4056 versao: "0.2.0".into(),
4057 }
4058 );
4059 }
4060
4061 #[test]
4062 fn versao_gate_silently_passes_on_unparseable_versao() {
4063 // Defensive arm: a malformed `:versao` (gated by the
4064 // narrower `ManifestError::VersaoInvalid` surface at the
4065 // load-bearing call site) must not regress into a
4066 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4067 // the precedence error over an unparseable `:versao` would
4068 // mask the more actionable root cause (the author meant to
4069 // type `"0.2.0"`, not `"v0.2.0"`).
4070 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4071 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4072 }
4073
4074 #[test]
4075 fn versao_gate_silently_passes_on_unparseable_from() {
4076 // Symmetric defensive arm: a malformed `:from` is gated by
4077 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4078 // upstream at the LayoutInvariants call site. Surfacing the
4079 // precedence error over an unparseable `:from` from this
4080 // gate alone would mask the narrower `FromInvalid`
4081 // diagnostic that's expected to lead — same fall-through
4082 // posture as the unparseable-`:versao` arm above. The
4083 // wiring in `LayoutInvariants::verify` runs
4084 // `validate_upgrade_from` *before* this gate, so in practice
4085 // an unparseable `:from` surfaces as `FromInvalid` first
4086 // and this gate is never reached on that input.
4087 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4088 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4089 }
4090
4091 #[test]
4092 fn versao_gate_reports_first_offending_entry() {
4093 // Determinism pin: with multiple offending entries the gate
4094 // surfaces the *first* one in declaration order — same
4095 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4096 // on the peer gate. Walks the entries in order; first
4097 // failing `:from >= :versao` short-circuits.
4098 let entries = vec![
4099 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4100 entry("0.3.0", vec![UpgradeInstruction::Restart]),
4101 entry("0.4.0", vec![UpgradeInstruction::Restart]),
4102 ];
4103 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4104 assert_eq!(
4105 err,
4106 UpgradeError::FromNotBeforeVersao {
4107 from: "0.3.0".into(),
4108 versao: "0.2.0".into(),
4109 },
4110 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
4111 );
4112 }
4113
4114 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
4115
4116 #[test]
4117 fn validate_rejects_restart_mixed_with_load_module() {
4118 // The "I'll try the typed path *then* restart anyway" footgun:
4119 // an instructions list with `(:restart)` plus `(:load-module …)`
4120 // is dead code in both directions (succeed → restart discards
4121 // the work that just succeeded, defeating the typed sequence's
4122 // whole point; fail → restart never reached because the entry
4123 // already failed). The gate names the offending entry's `:from`
4124 // verbatim plus the kebab-case lisp-form of every non-`:restart`
4125 // peer so the author can grep their caixa.lisp for either side
4126 // and fix in one edit.
4127 let e = entry(
4128 "0.1.0",
4129 vec![
4130 UpgradeInstruction::LoadModule {
4131 module: "hello-rio".into(),
4132 },
4133 UpgradeInstruction::Restart,
4134 ],
4135 );
4136 let err = e.validate().unwrap_err();
4137 assert_eq!(
4138 err,
4139 UpgradeError::RestartNotExclusive {
4140 from: "0.1.0".into(),
4141 restart_count: 1,
4142 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
4143 },
4144 "restart + load-module mix must surface as RestartNotExclusive naming the \
4145 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
4146 );
4147 }
4148
4149 #[test]
4150 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
4151 // Sweep the typed-sequence universe — every non-`:restart`
4152 // variant alongside `:restart` — and assert every typed
4153 // instruction's lisp-form appears in `other_kinds` in
4154 // declaration order. The author should be able to grep for
4155 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
4156 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
4157 // mapping surfaces here.
4158 let e = entry(
4159 "0.1.0",
4160 vec![
4161 UpgradeInstruction::LoadModule {
4162 module: "hello-rio".into(),
4163 },
4164 UpgradeInstruction::StateChange {
4165 script: PathBuf::from("lib/m.lisp"),
4166 },
4167 UpgradeInstruction::SoftPurge {
4168 module: "hello-rio-old".into(),
4169 },
4170 UpgradeInstruction::Purge {
4171 module: "hello-rio-old".into(),
4172 },
4173 UpgradeInstruction::Restart,
4174 ],
4175 );
4176 let err = e.validate().unwrap_err();
4177 assert_eq!(
4178 err,
4179 UpgradeError::RestartNotExclusive {
4180 from: "0.1.0".into(),
4181 restart_count: 1,
4182 other_kinds: vec![
4183 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
4184 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
4185 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4186 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4187 ],
4188 },
4189 );
4190 }
4191
4192 #[test]
4193 fn validate_rejects_restart_duplicated() {
4194 // `((:restart) (:restart))` — multiple Restart variants in one
4195 // entry. The fallback is a single semantic (restart the pod;
4196 // the new version comes up fresh); repeating it is at best
4197 // redundant, at worst suggests the author thought the second
4198 // would re-trigger after the first. The gate reports
4199 // `restart_count: 2` so the diagnostic surfaces the duplication
4200 // mode unambiguously even when `other_kinds` is empty.
4201 let e = entry(
4202 "0.1.0",
4203 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
4204 );
4205 let err = e.validate().unwrap_err();
4206 assert_eq!(
4207 err,
4208 UpgradeError::RestartNotExclusive {
4209 from: "0.1.0".into(),
4210 restart_count: 2,
4211 other_kinds: vec![],
4212 },
4213 );
4214 }
4215
4216 #[test]
4217 fn validate_accepts_sole_restart() {
4218 // Positive control: the canonical "this prior version's typed
4219 // upgrade is impossible — restart" authoring shape from the
4220 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
4221 // is the entry's whole instructions list and the only valid
4222 // Restart-bearing shape.
4223 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
4224 e.validate().unwrap();
4225 }
4226
4227 #[test]
4228 fn validate_accepts_typed_sequence_without_restart() {
4229 // Positive control: the canonical typed hot-upgrade authoring
4230 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
4231 // `:state-change` → `:soft-purge`. Absent `:restart` is the
4232 // only shape that lets the sequence run to completion under
4233 // the wasm-operator's `:from`-match dispatch. Drift here =
4234 // a future tighten that rejects any canonical typed-only shape
4235 // surfaces as a regression at this gate.
4236 let e = entry(
4237 "0.1.0",
4238 vec![
4239 UpgradeInstruction::LoadModule {
4240 module: "hello-rio".into(),
4241 },
4242 UpgradeInstruction::StateChange {
4243 script: PathBuf::from("lib/m.lisp"),
4244 },
4245 UpgradeInstruction::SoftPurge {
4246 module: "hello-rio-old".into(),
4247 },
4248 ],
4249 );
4250 e.validate().unwrap();
4251 }
4252
4253 // ── within-entry state-change-ordering invariant ───────────────────
4254
4255 #[test]
4256 fn validate_rejects_state_change_without_load() {
4257 // Fail-before-pass-after pin: a `:state-change` migrates state
4258 // into the newly-loaded code (gen_server:code_change/3 analog),
4259 // so an entry that runs it with no preceding `:load-module`
4260 // migrates state into code that was never loaded. The operator
4261 // runs instructions in declared order, so this is a build error,
4262 // not a runtime surprise (CAIXA-SDLC §III).
4263 let e = entry(
4264 "0.1.0",
4265 vec![UpgradeInstruction::StateChange {
4266 script: PathBuf::from("lib/m.lisp"),
4267 }],
4268 );
4269 let err = e.validate().unwrap_err();
4270 assert_eq!(
4271 err,
4272 UpgradeError::StateChangeWithoutPriorLoad {
4273 from: "0.1.0".into(),
4274 script: PathBuf::from("lib/m.lisp"),
4275 },
4276 "a `:state-change` with no preceding `:load-module` must surface as \
4277 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
4278 );
4279 }
4280
4281 #[test]
4282 fn validate_rejects_state_change_before_load() {
4283 // Right-instructions-wrong-order: the load is present but runs
4284 // *after* the migration. Because the operator executes in
4285 // declared order, the migration runs before the new code is
4286 // resident — the same incoherence as the missing-load case.
4287 let e = entry(
4288 "0.1.0",
4289 vec![
4290 UpgradeInstruction::StateChange {
4291 script: PathBuf::from("lib/m.lisp"),
4292 },
4293 UpgradeInstruction::LoadModule {
4294 module: "hello-rio".into(),
4295 },
4296 ],
4297 );
4298 let err = e.validate().unwrap_err();
4299 assert!(
4300 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4301 "a `:state-change` ahead of its `:load-module` must surface as \
4302 StateChangeWithoutPriorLoad, got {err:?}"
4303 );
4304 }
4305
4306 #[test]
4307 fn validate_accepts_state_change_after_load() {
4308 // Positive control: the canonical `(:load-module …)
4309 // (:state-change …)` order validates. The load need not name
4310 // the same module the migration targets (StateChange carries a
4311 // script, not a module ref), so any preceding `:load-module`
4312 // satisfies "new code is resident before its migration runs".
4313 let e = entry(
4314 "0.1.0",
4315 vec![
4316 UpgradeInstruction::LoadModule {
4317 module: "hello-rio".into(),
4318 },
4319 UpgradeInstruction::StateChange {
4320 script: PathBuf::from("lib/m.lisp"),
4321 },
4322 ],
4323 );
4324 e.validate().unwrap();
4325 }
4326
4327 #[test]
4328 fn validate_accepts_multiple_state_changes_after_one_load() {
4329 // A single leading `:load-module` covers every subsequent
4330 // `:state-change` — the `loaded` latch stays set once the new
4331 // code is resident.
4332 let e = entry(
4333 "0.1.0",
4334 vec![
4335 UpgradeInstruction::LoadModule {
4336 module: "hello-rio".into(),
4337 },
4338 UpgradeInstruction::StateChange {
4339 script: PathBuf::from("lib/m1.lisp"),
4340 },
4341 UpgradeInstruction::StateChange {
4342 script: PathBuf::from("lib/m2.lisp"),
4343 },
4344 ],
4345 );
4346 e.validate().unwrap();
4347 }
4348
4349 #[test]
4350 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
4351 {
4352 // Byte-identity pin on the
4353 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
4354 // migrate ordering dispatch against the pre-lift
4355 // `match instr { UpgradeInstruction::LoadModule { .. } =>
4356 // loaded = true, UpgradeInstruction::StateChange { script } if
4357 // !loaded => …, _ => {} }` open-coded pattern-match the site
4358 // previously carried. Asserts the two projections agree
4359 // byte-for-byte on every arm of the enum — the load-family
4360 // arm-discriminator via `is_load_module()` and the migration-
4361 // family `:script` scalar via `declared_path()` — so a future
4362 // derive regression that flipped the predicate's arm-set (a
4363 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
4364 // a byte-collision flipping a second variant to `true`) or an
4365 // accessor extension that promoted an additional variant onto
4366 // the `PathBuf`-carrying axis would trip here at caixa-core
4367 // test time rather than laundering the arm at the gate's
4368 // per-entry ordering scan far from the derive site.
4369 //
4370 // Peer of the sibling
4371 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
4372 // (c9ce91d) pin on the peer within-entry per-instruction-class
4373 // singularity gate's load-family + `String`-carrying dispatch,
4374 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4375 // (580d0f1) pin on the paired load → cleanup ordering gate's
4376 // load-family sticky-latch dispatch, and the
4377 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4378 // pin on the peer within-entry per-instruction-class singularity
4379 // gate's migration-family script-projection dispatch — closes
4380 // the last unlifted `match`-shaped per-arm-hand-rolled load-
4381 // family arm-discriminator + migration-family script-projection
4382 // pair inside `impl UpgradeFromEntry`. The four within-entry
4383 // ordering / singularity gates now share one byte-identity pin
4384 // apiece against their respective substrate-primitive typed
4385 // dispatches on the OTP-appup closed-set enum.
4386 //
4387 // Three-arm projective coverage:
4388 // (a) `LoadModule` satisfies `is_load_module()`, so the
4389 // sticky-latch advances byte-equal to the pre-lift
4390 // `UpgradeInstruction::LoadModule { .. }` arm; every
4391 // other variant leaves the latch untouched;
4392 // (b) a `((:state-change …))`-only entry (no preceding load)
4393 // trips the gate on the first `StateChange` with
4394 // `StateChangeWithoutPriorLoad` carrying the offending
4395 // script verbatim — the migration-family script surfaces
4396 // through `declared_path()` byte-equal to the raw
4397 // `StateChange { script }` pattern-bound field;
4398 // (c) a `((:load-module …) (:state-change …))` entry leaves
4399 // the gate vacuous with `Ok(())` — the `loaded = true`
4400 // latch on the first arm satisfies the `!loaded` guard
4401 // negation on the second, so the `declared_path()`
4402 // `Some(script)` fall-through does not fire — and a
4403 // non-`StateChange`-non-`LoadModule` sequence
4404 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
4405 // the gate vacuous because `declared_path()` is `None`
4406 // on all three of those arms.
4407 //
4408 // Fail-before-pass-after verified locally: swapping the
4409 // production `if instr.is_load_module() { loaded = true; }
4410 // else if !loaded && let Some(script) = instr.declared_path()
4411 // { … }` back to `match instr { UpgradeInstruction::LoadModule
4412 // { .. } => loaded = true, UpgradeInstruction::StateChange
4413 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
4414 // passing but silently detaches the gate from the accessor's
4415 // typed dispatch — any future `is_load_module` / `declared_path`
4416 // extension (a hole in either predicate, a promotion of an
4417 // additional variant onto either axis, an operator-side
4418 // pre-resolved-path cache the accessor materializes) would
4419 // then silently disagree between this gate's raw pattern-match
4420 // and the peer per-`UpgradeInstruction` consumers that route
4421 // through the accessor pair.
4422
4423 // (a) is_load_module() partitions the arm-set byte-equal to
4424 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
4425 // { .. })` and declared_path() surfaces the StateChange
4426 // `:script` byte-equal to the raw field access.
4427 let lm = UpgradeInstruction::LoadModule {
4428 module: "hello-rio".into(),
4429 };
4430 assert!(
4431 lm.is_load_module(),
4432 "LoadModule must satisfy is_load_module() — the gate's \
4433 load-family sticky-latch relies on this partition"
4434 );
4435 assert!(
4436 lm.declared_path().is_none(),
4437 "LoadModule must not carry a declared_path — the gate's \
4438 else-if migration-family arm must not fire on load arms"
4439 );
4440 let sc = UpgradeInstruction::StateChange {
4441 script: PathBuf::from("lib/m.lisp"),
4442 };
4443 assert!(
4444 !sc.is_load_module(),
4445 "StateChange must not satisfy is_load_module() — the gate's \
4446 sticky-latch must not advance on migration arms"
4447 );
4448 assert_eq!(
4449 sc.declared_path().map(std::path::PathBuf::as_path),
4450 Some(PathBuf::from("lib/m.lisp").as_path()),
4451 "declared_path() must project the StateChange :script \
4452 byte-equal to the raw field access — accessor divergence \
4453 would silently detach the gate from the projection every \
4454 peer per-`UpgradeInstruction` consumer routes through"
4455 );
4456
4457 // (b) A `((:state-change …))`-only entry trips
4458 // StateChangeWithoutPriorLoad byte-identical to the
4459 // pre-lift match-pattern shape.
4460 let no_prior_load = entry(
4461 "0.1.0",
4462 vec![UpgradeInstruction::StateChange {
4463 script: PathBuf::from("lib/m.lisp"),
4464 }],
4465 );
4466 assert_eq!(
4467 no_prior_load.validate_state_change_ordering(),
4468 Err(UpgradeError::StateChangeWithoutPriorLoad {
4469 from: "0.1.0".into(),
4470 script: PathBuf::from("lib/m.lisp"),
4471 }),
4472 "a `:state-change` with no preceding `:load-module` must fire \
4473 StateChangeWithoutPriorLoad carrying the offending script \
4474 verbatim through the declared_path() accessor"
4475 );
4476
4477 // (c) `((:load-module …) (:state-change …))` leaves the gate
4478 // vacuous; so does a non-StateChange-non-LoadModule
4479 // sequence (SoftPurge / Purge / Restart alone).
4480 let load_before_migrate = entry(
4481 "0.1.0",
4482 vec![
4483 UpgradeInstruction::LoadModule {
4484 module: "hello-rio".into(),
4485 },
4486 UpgradeInstruction::StateChange {
4487 script: PathBuf::from("lib/m.lisp"),
4488 },
4489 ],
4490 );
4491 assert_eq!(
4492 load_before_migrate.validate_state_change_ordering(),
4493 Ok(()),
4494 "load-before-migrate entries must leave the ordering gate \
4495 vacuous — the `loaded = true` sticky-latch on the first arm \
4496 satisfies the `!loaded` guard negation on the else-if arm"
4497 );
4498 for instr in [
4499 UpgradeInstruction::SoftPurge {
4500 module: "x-old".into(),
4501 },
4502 UpgradeInstruction::Purge {
4503 module: "x-old".into(),
4504 },
4505 UpgradeInstruction::Restart,
4506 ] {
4507 let e = entry("0.1.0", vec![instr.clone()]);
4508 assert_eq!(
4509 e.validate_state_change_ordering(),
4510 Ok(()),
4511 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
4512 leave the ordering gate vacuous — declared_path() is None \
4513 on every non-StateChange arm, so the else-if migration-\
4514 family arm never fires"
4515 );
4516 }
4517 }
4518
4519 #[test]
4520 fn validate_state_change_ordering_fires_after_restart_exclusive() {
4521 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
4522 // shape is *both* state-change-without-load and restart-mixed.
4523 // The more-fundamental `RestartNotExclusive` must win (a valid
4524 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
4525 // entry should reach the ordering gate). Guards the call order
4526 // in `validate` against silent reordering.
4527 let e = entry(
4528 "0.1.0",
4529 vec![
4530 UpgradeInstruction::StateChange {
4531 script: PathBuf::from("lib/m.lisp"),
4532 },
4533 UpgradeInstruction::Restart,
4534 ],
4535 );
4536 let err = e.validate().unwrap_err();
4537 assert!(
4538 matches!(err, UpgradeError::RestartNotExclusive { .. }),
4539 "restart-mixed must surface before the ordering gate, got {err:?}"
4540 );
4541 }
4542
4543 // ── within-entry purge-ordering invariant ──────────────────────────
4544
4545 #[test]
4546 fn validate_rejects_soft_purge_without_load() {
4547 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
4548 // module after the new one is resident (OTP's two-phase code
4549 // load — code:load_module/1 then code:soft_purge/1), so an
4550 // entry that runs it with no preceding `:load-module` drains
4551 // the live module with no replacement. The operator runs
4552 // instructions in declared order, so this is a build error,
4553 // not a runtime surprise (CAIXA-SDLC §III).
4554 let e = entry(
4555 "0.1.0",
4556 vec![UpgradeInstruction::SoftPurge {
4557 module: "x-old".into(),
4558 }],
4559 );
4560 let err = e.validate().unwrap_err();
4561 assert_eq!(
4562 err,
4563 UpgradeError::PurgeWithoutPriorLoad {
4564 from: "0.1.0".into(),
4565 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4566 module: "x-old".into(),
4567 },
4568 "a `:soft-purge` with no preceding `:load-module` must surface as \
4569 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
4570 );
4571 }
4572
4573 #[test]
4574 fn validate_rejects_purge_without_load() {
4575 // Per-arm coverage: `:purge` (immediate discard, no drain) is
4576 // the more catastrophic peer of `:soft-purge`; same gate, same
4577 // shape, kind-tag differs so the author can grep their
4578 // caixa.lisp for the offending `(:purge …)` form.
4579 let e = entry(
4580 "0.1.0",
4581 vec![UpgradeInstruction::Purge {
4582 module: "x-old".into(),
4583 }],
4584 );
4585 let err = e.validate().unwrap_err();
4586 assert_eq!(
4587 err,
4588 UpgradeError::PurgeWithoutPriorLoad {
4589 from: "0.1.0".into(),
4590 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4591 module: "x-old".into(),
4592 },
4593 );
4594 }
4595
4596 #[test]
4597 fn validate_rejects_soft_purge_before_load() {
4598 // Right-instructions-wrong-order: the load is present but runs
4599 // *after* the purge. Because the operator executes in declared
4600 // order, the cleanup drains the old code before the new code
4601 // is resident — same incoherence as the missing-load case,
4602 // leaving a window during which neither version is available.
4603 let e = entry(
4604 "0.1.0",
4605 vec![
4606 UpgradeInstruction::SoftPurge {
4607 module: "x-old".into(),
4608 },
4609 UpgradeInstruction::LoadModule { module: "x".into() },
4610 ],
4611 );
4612 let err = e.validate().unwrap_err();
4613 assert!(
4614 matches!(
4615 err,
4616 UpgradeError::PurgeWithoutPriorLoad {
4617 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4618 ..
4619 }
4620 ),
4621 "a `:soft-purge` ahead of its `:load-module` must surface as \
4622 PurgeWithoutPriorLoad, got {err:?}"
4623 );
4624 }
4625
4626 #[test]
4627 fn validate_rejects_purge_before_load() {
4628 // Symmetric arm on the `:purge` variant — the kind tag
4629 // distinguishes the diagnostic so the author lands on the
4630 // offending form directly.
4631 let e = entry(
4632 "0.1.0",
4633 vec![
4634 UpgradeInstruction::Purge {
4635 module: "x-old".into(),
4636 },
4637 UpgradeInstruction::LoadModule { module: "x".into() },
4638 ],
4639 );
4640 let err = e.validate().unwrap_err();
4641 assert!(
4642 matches!(
4643 err,
4644 UpgradeError::PurgeWithoutPriorLoad {
4645 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4646 ..
4647 }
4648 ),
4649 "a `:purge` ahead of its `:load-module` must surface as \
4650 PurgeWithoutPriorLoad, got {err:?}"
4651 );
4652 }
4653
4654 #[test]
4655 fn validate_accepts_soft_purge_after_load() {
4656 // Positive control: the canonical `(:load-module …)
4657 // (:soft-purge …)` order validates. The load need not name the
4658 // same module the purge targets — the cleanup typically targets
4659 // the *old* module name (e.g. `"x-old"`) and the load brings up
4660 // the *new* one (`"x"`); the gate only requires that *some*
4661 // `:load-module` precedes the purge, so the new code is resident
4662 // before the old one is drained.
4663 let e = entry(
4664 "0.1.0",
4665 vec![
4666 UpgradeInstruction::LoadModule { module: "x".into() },
4667 UpgradeInstruction::SoftPurge {
4668 module: "x-old".into(),
4669 },
4670 ],
4671 );
4672 e.validate().unwrap();
4673 }
4674
4675 #[test]
4676 fn validate_accepts_multiple_purges_after_one_load() {
4677 // A single leading `:load-module` covers every subsequent
4678 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
4679 // the new code is resident. Same shape as
4680 // `validate_accepts_multiple_state_changes_after_one_load` on
4681 // the peer ordering gate.
4682 let e = entry(
4683 "0.1.0",
4684 vec![
4685 UpgradeInstruction::LoadModule { module: "x".into() },
4686 UpgradeInstruction::SoftPurge {
4687 module: "x-old".into(),
4688 },
4689 UpgradeInstruction::Purge {
4690 module: "x-oldest".into(),
4691 },
4692 ],
4693 );
4694 e.validate().unwrap();
4695 }
4696
4697 #[test]
4698 fn validate_purge_ordering_fires_after_state_change_ordering() {
4699 // Diagnostic-precedence pin: an entry like `((:state-change …)
4700 // (:soft-purge …))` is *both* state-change-without-load and
4701 // purge-without-load. The state-change gate must win — it's
4702 // the load-bearing semantic on this ordering contract, and
4703 // surfacing the purge diagnostic first would mask the more-
4704 // fundamental migration-against-stale-code defect. Guards the
4705 // call order in `validate` against silent reordering.
4706 let e = entry(
4707 "0.1.0",
4708 vec![
4709 UpgradeInstruction::StateChange {
4710 script: PathBuf::from("lib/m.lisp"),
4711 },
4712 UpgradeInstruction::SoftPurge {
4713 module: "x-old".into(),
4714 },
4715 ],
4716 );
4717 let err = e.validate().unwrap_err();
4718 assert!(
4719 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4720 "state-change-without-load must surface before purge-without-load, got {err:?}"
4721 );
4722 }
4723
4724 #[test]
4725 fn validate_purge_ordering_fires_after_per_instr_shape() {
4726 // Order pin: a malformed `:module` value on a `:soft-purge` (an
4727 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
4728 // diagnostic *before* the within-entry purge-ordering gate fires.
4729 // The per-instruction shape pass walks the list inline before
4730 // the ordering checks, so the narrower self-locating diagnostic
4731 // surfaces first — mirrors the empty-first cascade on every peer
4732 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
4733 // per_instr_shape` pin on the sibling ordering gate.
4734 let e = entry(
4735 "0.1.0",
4736 vec![UpgradeInstruction::SoftPurge {
4737 module: String::new(),
4738 }],
4739 );
4740 let err = e.validate().unwrap_err();
4741 assert_eq!(
4742 err,
4743 UpgradeError::ModuleEmpty {
4744 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
4745 },
4746 "malformed instruction must surface its kind-tagged diagnostic before the \
4747 purge-ordering gate fires, got {err:?}"
4748 );
4749 }
4750
4751 #[test]
4752 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
4753 // The whole-list entry-point surfaces the per-entry ordering
4754 // error (mirrors
4755 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
4756 // the gate is reachable from the LayoutInvariants call site, not
4757 // only from a direct `entry.validate()`.
4758 let entries = vec![entry(
4759 "0.1.0",
4760 vec![UpgradeInstruction::Purge {
4761 module: "x-old".into(),
4762 }],
4763 )];
4764 let err = validate_upgrade_from(&entries).unwrap_err();
4765 assert!(
4766 matches!(
4767 err,
4768 UpgradeError::PurgeWithoutPriorLoad {
4769 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4770 ..
4771 }
4772 ),
4773 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
4774 );
4775 }
4776
4777 #[test]
4778 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
4779 // The whole-list entry-point surfaces the per-entry ordering
4780 // error (mirrors `validate_restart_exclusive_threads_through_…`):
4781 // the gate is reachable from the LayoutInvariants call site, not
4782 // only from a direct `entry.validate()`.
4783 let entries = vec![entry(
4784 "0.1.0",
4785 vec![UpgradeInstruction::StateChange {
4786 script: PathBuf::from("lib/m.lisp"),
4787 }],
4788 )];
4789 let err = validate_upgrade_from(&entries).unwrap_err();
4790 assert!(
4791 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
4792 "validate_upgrade_from must thread the ordering error, got {err:?}"
4793 );
4794 }
4795
4796 // ── within-entry cleanup-singularity invariant ─────────────────────
4797
4798 #[test]
4799 fn validate_rejects_duplicate_soft_purge_for_same_module() {
4800 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
4801 // its target module (code:soft_purge/1 analog); after the
4802 // first the module is gone, so a second `:soft-purge` of the
4803 // same module is at best a no-op and at worst undefined
4804 // (depending on the operator's handling of a non-resident-
4805 // module purge). Author one cleanup per module.
4806 let e = entry(
4807 "0.1.0",
4808 vec![
4809 UpgradeInstruction::LoadModule { module: "x".into() },
4810 UpgradeInstruction::SoftPurge {
4811 module: "x-old".into(),
4812 },
4813 UpgradeInstruction::SoftPurge {
4814 module: "x-old".into(),
4815 },
4816 ],
4817 );
4818 let err = e.validate().unwrap_err();
4819 assert_eq!(
4820 err,
4821 UpgradeError::DuplicateCleanup {
4822 from: "0.1.0".into(),
4823 module: "x-old".into(),
4824 kinds: vec![
4825 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4826 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4827 ],
4828 },
4829 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
4830 module + both kinds in declaration order, got {err:?}"
4831 );
4832 }
4833
4834 #[test]
4835 fn validate_rejects_duplicate_purge_for_same_module() {
4836 // Per-arm coverage: `:purge` (immediate discard, no drain) is
4837 // the more catastrophic peer of `:soft-purge`; same gate, same
4838 // shape, kind-tag distinguishes so the author can grep their
4839 // caixa.lisp for the offending `(:purge …)` form.
4840 let e = entry(
4841 "0.1.0",
4842 vec![
4843 UpgradeInstruction::LoadModule { module: "x".into() },
4844 UpgradeInstruction::Purge {
4845 module: "x-old".into(),
4846 },
4847 UpgradeInstruction::Purge {
4848 module: "x-old".into(),
4849 },
4850 ],
4851 );
4852 let err = e.validate().unwrap_err();
4853 assert_eq!(
4854 err,
4855 UpgradeError::DuplicateCleanup {
4856 from: "0.1.0".into(),
4857 module: "x-old".into(),
4858 kinds: vec![
4859 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4860 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4861 ],
4862 },
4863 );
4864 }
4865
4866 #[test]
4867 fn validate_rejects_soft_purge_then_purge_for_same_module() {
4868 // Soft-then-hard footgun: the author wrote "drain, and if
4869 // drain doesn't clean up, force-discard", but the operator
4870 // runs declared instructions unconditionally — the `:purge`
4871 // fires whether the `:soft-purge` already discarded the
4872 // module or not, so the imagined fallback semantic is
4873 // missing. Fallback on cleanup failure is the operator's
4874 // job, not authored into the entry. Both kinds carry in
4875 // declaration order so the author can grep for either side
4876 // and pick one.
4877 let e = entry(
4878 "0.1.0",
4879 vec![
4880 UpgradeInstruction::LoadModule { module: "x".into() },
4881 UpgradeInstruction::SoftPurge {
4882 module: "x-old".into(),
4883 },
4884 UpgradeInstruction::Purge {
4885 module: "x-old".into(),
4886 },
4887 ],
4888 );
4889 let err = e.validate().unwrap_err();
4890 assert_eq!(
4891 err,
4892 UpgradeError::DuplicateCleanup {
4893 from: "0.1.0".into(),
4894 module: "x-old".into(),
4895 kinds: vec![
4896 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4897 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4898 ],
4899 },
4900 );
4901 }
4902
4903 #[test]
4904 fn validate_rejects_purge_then_soft_purge_for_same_module() {
4905 // Reversed-ordering arm: `:purge` discards immediately; the
4906 // trailing `:soft-purge` has no module to drain. The kinds
4907 // list reflects declaration order so the diagnostic locates
4908 // both forms in the source.
4909 let e = entry(
4910 "0.1.0",
4911 vec![
4912 UpgradeInstruction::LoadModule { module: "x".into() },
4913 UpgradeInstruction::Purge {
4914 module: "x-old".into(),
4915 },
4916 UpgradeInstruction::SoftPurge {
4917 module: "x-old".into(),
4918 },
4919 ],
4920 );
4921 let err = e.validate().unwrap_err();
4922 assert_eq!(
4923 err,
4924 UpgradeError::DuplicateCleanup {
4925 from: "0.1.0".into(),
4926 module: "x-old".into(),
4927 kinds: vec![
4928 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
4929 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
4930 ],
4931 },
4932 );
4933 }
4934
4935 #[test]
4936 fn validate_accepts_distinct_cleanup_modules() {
4937 // Positive control: `:soft-purge` and `:purge` on *different*
4938 // modules pass the gate. Mirrors
4939 // `validate_accepts_multiple_purges_after_one_load` — the
4940 // cleanup-singularity gate is keyed on (module), not on
4941 // (kind, module) pair, so distinct old-version names render
4942 // distinct cleanup targets and don't collide. Sweep both
4943 // same-class (two `:soft-purge` distinct modules) and cross-
4944 // class (`:soft-purge` then `:purge` distinct modules) so a
4945 // future tighten to a kind-only key (which would over-fire on
4946 // distinct modules) surfaces here.
4947 let two_soft = entry(
4948 "0.1.0",
4949 vec![
4950 UpgradeInstruction::LoadModule { module: "x".into() },
4951 UpgradeInstruction::SoftPurge {
4952 module: "x-old".into(),
4953 },
4954 UpgradeInstruction::SoftPurge {
4955 module: "x-older".into(),
4956 },
4957 ],
4958 );
4959 two_soft.validate().unwrap();
4960 let mixed = entry(
4961 "0.1.0",
4962 vec![
4963 UpgradeInstruction::LoadModule { module: "x".into() },
4964 UpgradeInstruction::SoftPurge {
4965 module: "x-old".into(),
4966 },
4967 UpgradeInstruction::Purge {
4968 module: "x-oldest".into(),
4969 },
4970 ],
4971 );
4972 mixed.validate().unwrap();
4973 }
4974
4975 #[test]
4976 fn validate_accepts_single_cleanup_per_module() {
4977 // Boundary control: a list with exactly one `:soft-purge` and
4978 // one `:purge` (distinct modules, the canonical "drain one,
4979 // hard-discard the other" shape) is the gate's identity
4980 // element. Pin so a future off-by-one in the duplicate-detection
4981 // scan doesn't accidentally flag a single occurrence as
4982 // duplicating itself — mirrors
4983 // `validate_upgrade_from_single_entry_never_duplicates` on
4984 // the peer cross-entry duplicate axis.
4985 let e = entry(
4986 "0.1.0",
4987 vec![
4988 UpgradeInstruction::LoadModule { module: "x".into() },
4989 UpgradeInstruction::SoftPurge {
4990 module: "x-old".into(),
4991 },
4992 UpgradeInstruction::Purge {
4993 module: "y-old".into(),
4994 },
4995 ],
4996 );
4997 e.validate().unwrap();
4998 }
4999
5000 #[test]
5001 fn validate_cleanup_singularity_fires_after_purge_ordering() {
5002 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5003 // (:soft-purge "x"))` is *both* purge-without-load and
5004 // duplicate-cleanup. The more-fundamental ordering gate must
5005 // win — the missing-load defect is load-bearing (the canonical
5006 // OTP shape requires the new code be resident before any
5007 // cleanup runs), and surfacing the duplicate diagnostic first
5008 // would mask the no-replacement-window defect the ordering
5009 // gate exists to close. Guards the call order in `validate`
5010 // against silent reordering. Same posture as
5011 // `validate_purge_ordering_fires_after_state_change_ordering`
5012 // on the sibling ordering gate.
5013 let e = entry(
5014 "0.1.0",
5015 vec![
5016 UpgradeInstruction::SoftPurge {
5017 module: "x-old".into(),
5018 },
5019 UpgradeInstruction::SoftPurge {
5020 module: "x-old".into(),
5021 },
5022 ],
5023 );
5024 let err = e.validate().unwrap_err();
5025 assert!(
5026 matches!(
5027 err,
5028 UpgradeError::PurgeWithoutPriorLoad {
5029 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5030 ..
5031 }
5032 ),
5033 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5034 );
5035 }
5036
5037 #[test]
5038 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5039 // Order pin: a malformed `:module` value on a `:soft-purge`
5040 // (an empty string) surfaces its narrower kind-tagged
5041 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5042 // singularity gate fires. The per-instruction shape pass walks
5043 // the list inline before the singularity check, so the
5044 // narrower self-locating diagnostic surfaces first — mirrors
5045 // the empty-first cascade on every peer DNS-1123 gate and the
5046 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5047 // the sibling ordering gate.
5048 //
5049 // Two empty-string `:soft-purge` would *otherwise* duplicate
5050 // (both modules are the same empty string), so this pin
5051 // double-locks the precedence: the per-instr shape gate must
5052 // win on the first malformed instruction before the duplicate
5053 // scan even reaches the second.
5054 let e = entry(
5055 "0.1.0",
5056 vec![
5057 UpgradeInstruction::LoadModule { module: "x".into() },
5058 UpgradeInstruction::SoftPurge {
5059 module: String::new(),
5060 },
5061 UpgradeInstruction::SoftPurge {
5062 module: String::new(),
5063 },
5064 ],
5065 );
5066 let err = e.validate().unwrap_err();
5067 assert_eq!(
5068 err,
5069 UpgradeError::ModuleEmpty {
5070 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5071 },
5072 "malformed instruction must surface its kind-tagged diagnostic before the \
5073 cleanup-singularity gate fires, got {err:?}"
5074 );
5075 }
5076
5077 #[test]
5078 fn validate_cleanup_singularity_reports_first_collision() {
5079 // Determinism pin: with three cleanups of the same module the
5080 // gate reports the *first* collision (the second occurrence)
5081 // and stops — the third's duplicate is masked by the first
5082 // surfaced one. Mirrors
5083 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5084 // on the peer cross-entry duplicate axis.
5085 let e = entry(
5086 "0.1.0",
5087 vec![
5088 UpgradeInstruction::LoadModule { module: "x".into() },
5089 UpgradeInstruction::SoftPurge {
5090 module: "x-old".into(),
5091 },
5092 UpgradeInstruction::SoftPurge {
5093 module: "x-old".into(),
5094 },
5095 UpgradeInstruction::Purge {
5096 module: "x-old".into(),
5097 },
5098 ],
5099 );
5100 let err = e.validate().unwrap_err();
5101 assert_eq!(
5102 err,
5103 UpgradeError::DuplicateCleanup {
5104 from: "0.1.0".into(),
5105 module: "x-old".into(),
5106 kinds: vec![
5107 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5108 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5109 ],
5110 },
5111 "the first colliding pair must surface, not the later `:purge` collision"
5112 );
5113 }
5114
5115 #[test]
5116 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
5117 // The whole-list entry-point surfaces the per-entry singularity
5118 // error (mirrors
5119 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
5120 // the gate is reachable from the LayoutInvariants call site,
5121 // not only from a direct `entry.validate()`.
5122 let entries = vec![entry(
5123 "0.1.0",
5124 vec![
5125 UpgradeInstruction::LoadModule { module: "x".into() },
5126 UpgradeInstruction::SoftPurge {
5127 module: "x-old".into(),
5128 },
5129 UpgradeInstruction::Purge {
5130 module: "x-old".into(),
5131 },
5132 ],
5133 )];
5134 let err = validate_upgrade_from(&entries).unwrap_err();
5135 assert!(
5136 matches!(err, UpgradeError::DuplicateCleanup { .. }),
5137 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
5138 );
5139 }
5140
5141 #[test]
5142 fn validate_rejects_duplicate_load_module_for_same_module() {
5143 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
5144 // §II.4): each module is loaded exactly once per upgrade entry,
5145 // the operator's dispatch table reads the module name to bind
5146 // the wasm component, and a second `(:load-module "x")` re-reads
5147 // the same module name and re-binds the same component — a
5148 // no-op the second time. systools-generated `.relup` files emit
5149 // at most one `load_module` per module per upgrade step for
5150 // this reason. Author one `(:load-module "x")` per old module.
5151 let e = entry(
5152 "0.1.0",
5153 vec![
5154 UpgradeInstruction::LoadModule { module: "x".into() },
5155 UpgradeInstruction::LoadModule { module: "x".into() },
5156 ],
5157 );
5158 let err = e.validate().unwrap_err();
5159 assert_eq!(
5160 err,
5161 UpgradeError::DuplicateLoadModule {
5162 from: "0.1.0".into(),
5163 module: "x".into(),
5164 },
5165 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
5166 the module, got {err:?}"
5167 );
5168 }
5169
5170 #[test]
5171 fn validate_accepts_distinct_load_modules() {
5172 // Positive control: `:load-module` instructions on *different*
5173 // modules pass the gate. Mirrors
5174 // `validate_accepts_distinct_cleanup_modules` on the sibling
5175 // singularity axis — the load-singularity gate is keyed on
5176 // (module), so distinct module names render distinct load
5177 // targets and don't collide. Sweep both the bare two-load shape
5178 // and the canonical load-pair-with-cleanup shape so a future
5179 // tighten that over-fires on distinct loads surfaces here.
5180 let two_loads = entry(
5181 "0.1.0",
5182 vec![
5183 UpgradeInstruction::LoadModule { module: "x".into() },
5184 UpgradeInstruction::LoadModule { module: "y".into() },
5185 ],
5186 );
5187 two_loads.validate().unwrap();
5188 let with_cleanup = entry(
5189 "0.1.0",
5190 vec![
5191 UpgradeInstruction::LoadModule { module: "x".into() },
5192 UpgradeInstruction::LoadModule { module: "y".into() },
5193 UpgradeInstruction::SoftPurge {
5194 module: "x-old".into(),
5195 },
5196 UpgradeInstruction::SoftPurge {
5197 module: "y-old".into(),
5198 },
5199 ],
5200 );
5201 with_cleanup.validate().unwrap();
5202 }
5203
5204 #[test]
5205 fn validate_accepts_single_load_per_module() {
5206 // Boundary control: a list with exactly one `:load-module`
5207 // followed by the canonical `:state-change` + `:soft-purge`
5208 // sequence (the module-doc OTP shape) is the gate's identity
5209 // element. Pin so a future off-by-one in the duplicate-
5210 // detection scan doesn't accidentally flag a single occurrence
5211 // as duplicating itself — mirrors
5212 // `validate_accepts_single_cleanup_per_module` on the sibling
5213 // singularity axis.
5214 let e = entry(
5215 "0.1.0",
5216 vec![
5217 UpgradeInstruction::LoadModule { module: "x".into() },
5218 UpgradeInstruction::StateChange {
5219 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5220 },
5221 UpgradeInstruction::SoftPurge {
5222 module: "x-old".into(),
5223 },
5224 ],
5225 );
5226 e.validate().unwrap();
5227 }
5228
5229 #[test]
5230 fn validate_load_singularity_fires_after_state_change_ordering() {
5231 // Diagnostic-precedence pin: an entry like `((:state-change
5232 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
5233 // state-change-without-load and duplicate-load. The more-
5234 // fundamental ordering gate must win — the missing-load defect
5235 // is load-bearing (the migration runs against unloaded code),
5236 // and surfacing the duplicate diagnostic first would mask the
5237 // migrate-into-unloaded-code defect the ordering gate exists
5238 // to close. Guards the call order in `validate` against silent
5239 // reordering. Same posture as
5240 // `validate_cleanup_singularity_fires_after_purge_ordering`
5241 // on the sibling singularity gate.
5242 let e = entry(
5243 "0.1.0",
5244 vec![
5245 UpgradeInstruction::StateChange {
5246 script: PathBuf::from("lib/m.lisp"),
5247 },
5248 UpgradeInstruction::LoadModule { module: "x".into() },
5249 UpgradeInstruction::LoadModule { module: "x".into() },
5250 ],
5251 );
5252 let err = e.validate().unwrap_err();
5253 assert!(
5254 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5255 "state-change-without-load must surface before duplicate-load, got {err:?}"
5256 );
5257 }
5258
5259 #[test]
5260 fn validate_load_singularity_fires_after_purge_ordering() {
5261 // Diagnostic-precedence pin: an entry like `((:soft-purge
5262 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
5263 // purge-without-load and duplicate-load. The more-fundamental
5264 // ordering gate must win — the missing-load defect is load-
5265 // bearing (the cleanup runs against no-replacement-window),
5266 // and surfacing the duplicate diagnostic first would mask the
5267 // drain-to-nothing defect the ordering gate exists to close.
5268 // Sibling of
5269 // `validate_cleanup_singularity_fires_after_purge_ordering` on
5270 // the load-singularity axis.
5271 let e = entry(
5272 "0.1.0",
5273 vec![
5274 UpgradeInstruction::SoftPurge {
5275 module: "x-old".into(),
5276 },
5277 UpgradeInstruction::LoadModule { module: "x".into() },
5278 UpgradeInstruction::LoadModule { module: "x".into() },
5279 ],
5280 );
5281 let err = e.validate().unwrap_err();
5282 assert!(
5283 matches!(
5284 err,
5285 UpgradeError::PurgeWithoutPriorLoad {
5286 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5287 ..
5288 }
5289 ),
5290 "purge-without-load must surface before duplicate-load, got {err:?}"
5291 );
5292 }
5293
5294 #[test]
5295 fn validate_load_singularity_fires_after_per_instr_shape() {
5296 // Order pin: a malformed `:module` value on a `:load-module`
5297 // (an empty string) surfaces its narrower kind-tagged
5298 // `ModuleEmpty` diagnostic *before* the within-entry load-
5299 // singularity gate fires. The per-instruction shape pass walks
5300 // the list inline before the singularity check, so the
5301 // narrower self-locating diagnostic surfaces first — mirrors
5302 // the empty-first cascade on every peer DNS-1123 gate and the
5303 // `validate_cleanup_singularity_fires_after_per_instr_shape`
5304 // pin on the sibling singularity gate.
5305 //
5306 // Two empty-string `:load-module` would *otherwise* duplicate
5307 // (both modules are the same empty string), so this pin
5308 // double-locks the precedence: the per-instr shape gate must
5309 // win on the first malformed instruction before the duplicate
5310 // scan even reaches the second.
5311 let e = entry(
5312 "0.1.0",
5313 vec![
5314 UpgradeInstruction::LoadModule {
5315 module: String::new(),
5316 },
5317 UpgradeInstruction::LoadModule {
5318 module: String::new(),
5319 },
5320 ],
5321 );
5322 let err = e.validate().unwrap_err();
5323 assert_eq!(
5324 err,
5325 UpgradeError::ModuleEmpty {
5326 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5327 },
5328 "malformed instruction must surface its kind-tagged diagnostic before the \
5329 load-singularity gate fires, got {err:?}"
5330 );
5331 }
5332
5333 #[test]
5334 fn validate_load_singularity_fires_before_cleanup_singularity() {
5335 // Diagnostic-precedence pin: an entry that violates *both*
5336 // singularities — duplicate load on "x" *and* duplicate cleanup
5337 // on "y-old" — must surface the load-side diagnostic first.
5338 // The load axis precedes the cleanup axis in the canonical OTP
5339 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
5340 // in [`UpgradeInstruction`] declaration order (LoadModule
5341 // before SoftPurge/Purge), so the load-side singularity is the
5342 // load-bearing diagnostic when both fire — the cleanup-side
5343 // duplicate is meaningless either way without a coherent load.
5344 // Guards the call order in `validate`: `validate_load_singularity`
5345 // runs before `validate_cleanup_singularity`.
5346 let e = entry(
5347 "0.1.0",
5348 vec![
5349 UpgradeInstruction::LoadModule { module: "x".into() },
5350 UpgradeInstruction::LoadModule { module: "x".into() },
5351 UpgradeInstruction::SoftPurge {
5352 module: "y-old".into(),
5353 },
5354 UpgradeInstruction::SoftPurge {
5355 module: "y-old".into(),
5356 },
5357 ],
5358 );
5359 let err = e.validate().unwrap_err();
5360 assert_eq!(
5361 err,
5362 UpgradeError::DuplicateLoadModule {
5363 from: "0.1.0".into(),
5364 module: "x".into(),
5365 },
5366 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
5367 );
5368 }
5369
5370 #[test]
5371 fn validate_load_singularity_reports_first_collision() {
5372 // Determinism pin: with three loads of the same module the gate
5373 // reports the *first* collision (the second occurrence) and
5374 // stops — the third's duplicate is masked by the first surfaced
5375 // one. Mirrors
5376 // `validate_cleanup_singularity_reports_first_collision` on the
5377 // sibling singularity axis and every peer duplicate gate's
5378 // first-collision discipline.
5379 let e = entry(
5380 "0.1.0",
5381 vec![
5382 UpgradeInstruction::LoadModule { module: "x".into() },
5383 UpgradeInstruction::LoadModule { module: "x".into() },
5384 UpgradeInstruction::LoadModule { module: "x".into() },
5385 ],
5386 );
5387 let err = e.validate().unwrap_err();
5388 assert_eq!(
5389 err,
5390 UpgradeError::DuplicateLoadModule {
5391 from: "0.1.0".into(),
5392 module: "x".into(),
5393 },
5394 "the first colliding occurrence must surface, not the later third-load collision"
5395 );
5396 }
5397
5398 #[test]
5399 fn validate_load_singularity_threads_through_validate_upgrade_from() {
5400 // The whole-list entry-point surfaces the per-entry singularity
5401 // error (mirrors
5402 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
5403 // the gate is reachable from the LayoutInvariants call site,
5404 // not only from a direct `entry.validate()`.
5405 let entries = vec![entry(
5406 "0.1.0",
5407 vec![
5408 UpgradeInstruction::LoadModule { module: "x".into() },
5409 UpgradeInstruction::LoadModule { module: "x".into() },
5410 ],
5411 )];
5412 let err = validate_upgrade_from(&entries).unwrap_err();
5413 assert!(
5414 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
5415 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
5416 );
5417 }
5418
5419 // ── within-entry state-change-singularity invariant ────────────────
5420
5421 #[test]
5422 fn validate_rejects_duplicate_state_change_for_same_script() {
5423 // `StateChange` is the `gen_server:code_change/3` analog
5424 // (INSPIRATIONS §II.4): the script folds the prior-version
5425 // state shape into the current-version shape — a one-shot
5426 // transition, not a step that composes with itself. OTP's
5427 // release_handler invokes `code_change/3` exactly once per
5428 // upgrade per gen_server; systools-generated `.relup` files
5429 // emit at most one `code_change` per gen_server per upgrade
5430 // step for this reason. A second `(:state-change "m.lisp")`
5431 // re-runs the same fold on the already-migrated state — at
5432 // best a no-op and at worst silent state corruption from
5433 // double-applied non-idempotent transforms (`add column`,
5434 // `increment counter`, `rename field`). Author one
5435 // `(:state-change "m.lisp")` per migration script per entry.
5436 let e = entry(
5437 "0.1.0",
5438 vec![
5439 UpgradeInstruction::LoadModule { module: "x".into() },
5440 UpgradeInstruction::StateChange {
5441 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5442 },
5443 UpgradeInstruction::StateChange {
5444 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5445 },
5446 ],
5447 );
5448 let err = e.validate().unwrap_err();
5449 assert_eq!(
5450 err,
5451 UpgradeError::DuplicateStateChange {
5452 from: "0.1.0".into(),
5453 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5454 },
5455 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
5456 the script, got {err:?}"
5457 );
5458 }
5459
5460 #[test]
5461 fn validate_accepts_distinct_state_change_scripts() {
5462 // Positive control: `:state-change` instructions on *different*
5463 // scripts pass the gate. Mirrors
5464 // `validate_accepts_distinct_cleanup_modules` /
5465 // `validate_accepts_distinct_load_modules` on the sibling
5466 // singularity axes — the state-change-singularity gate is keyed
5467 // on the script PathBuf, so distinct scripts render distinct
5468 // migration targets and don't collide. Sweep both the bare two-
5469 // migration shape and the canonical load-pair-with-cleanup shape
5470 // so a future tighten that over-fires on distinct scripts
5471 // surfaces here. This positive control is the gate-level peer of
5472 // `validate_accepts_multiple_state_changes_after_one_load` (the
5473 // ordering-gate positive control on distinct scripts), pinned
5474 // here independently so a future refactor that decouples the
5475 // gates can't accidentally drop coverage on either.
5476 let two_migrations = entry(
5477 "0.1.0",
5478 vec![
5479 UpgradeInstruction::LoadModule { module: "x".into() },
5480 UpgradeInstruction::StateChange {
5481 script: PathBuf::from("lib/m1.lisp"),
5482 },
5483 UpgradeInstruction::StateChange {
5484 script: PathBuf::from("lib/m2.lisp"),
5485 },
5486 ],
5487 );
5488 two_migrations.validate().unwrap();
5489 let with_cleanup = entry(
5490 "0.1.0",
5491 vec![
5492 UpgradeInstruction::LoadModule { module: "x".into() },
5493 UpgradeInstruction::StateChange {
5494 script: PathBuf::from("lib/m1.lisp"),
5495 },
5496 UpgradeInstruction::StateChange {
5497 script: PathBuf::from("lib/m2.lisp"),
5498 },
5499 UpgradeInstruction::SoftPurge {
5500 module: "x-old".into(),
5501 },
5502 ],
5503 );
5504 with_cleanup.validate().unwrap();
5505 }
5506
5507 #[test]
5508 fn validate_accepts_single_state_change_per_script() {
5509 // Boundary control: a list with exactly one `:state-change`
5510 // wrapped by the canonical `:load-module` + `:soft-purge`
5511 // sequence (the module-doc OTP shape) is the gate's identity
5512 // element. Pin so a future off-by-one in the duplicate-
5513 // detection scan doesn't accidentally flag a single occurrence
5514 // as duplicating itself — mirrors
5515 // `validate_accepts_single_load_per_module` /
5516 // `validate_accepts_single_cleanup_per_module` on the sibling
5517 // singularity axes.
5518 let e = entry(
5519 "0.1.0",
5520 vec![
5521 UpgradeInstruction::LoadModule { module: "x".into() },
5522 UpgradeInstruction::StateChange {
5523 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5524 },
5525 UpgradeInstruction::SoftPurge {
5526 module: "x-old".into(),
5527 },
5528 ],
5529 );
5530 e.validate().unwrap();
5531 }
5532
5533 #[test]
5534 fn validate_state_change_singularity_fires_after_state_change_ordering() {
5535 // Diagnostic-precedence pin: an entry like `((:state-change
5536 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
5537 // without-load and duplicate-state-change. The more-fundamental
5538 // ordering gate must win — the missing-load defect is load-
5539 // bearing (the migration runs against unloaded code), and
5540 // surfacing the duplicate diagnostic first would mask the
5541 // migrate-into-unloaded-code defect the ordering gate exists to
5542 // close. Guards the call order in `validate` against silent
5543 // reordering. Same posture as
5544 // `validate_load_singularity_fires_after_state_change_ordering`
5545 // on the sibling singularity gate.
5546 //
5547 // Two same-script `:state-change` would *otherwise* duplicate
5548 // (both scripts collide on the very first `:state-change`-
5549 // without-load encountered), so this pin double-locks the
5550 // precedence: the ordering gate must win on the first un-loaded
5551 // `:state-change` before the singularity scan even reaches the
5552 // second.
5553 let e = entry(
5554 "0.1.0",
5555 vec![
5556 UpgradeInstruction::StateChange {
5557 script: PathBuf::from("lib/m.lisp"),
5558 },
5559 UpgradeInstruction::StateChange {
5560 script: PathBuf::from("lib/m.lisp"),
5561 },
5562 ],
5563 );
5564 let err = e.validate().unwrap_err();
5565 assert!(
5566 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5567 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
5568 );
5569 }
5570
5571 #[test]
5572 fn validate_state_change_singularity_fires_after_purge_ordering() {
5573 // Diagnostic-precedence pin: an entry like `((:soft-purge
5574 // "x-old") (:load-module "x") (:state-change "m.lisp")
5575 // (:state-change "m.lisp"))` is *both* purge-without-load and
5576 // duplicate-state-change. The more-fundamental ordering gate
5577 // must win — the missing-load defect (a cleanup that drains the
5578 // only resident version to nothing) is load-bearing, and
5579 // surfacing the duplicate diagnostic first would mask the
5580 // drain-to-nothing defect the ordering gate exists to close.
5581 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
5582 // on the state-change-singularity axis.
5583 let e = entry(
5584 "0.1.0",
5585 vec![
5586 UpgradeInstruction::SoftPurge {
5587 module: "x-old".into(),
5588 },
5589 UpgradeInstruction::LoadModule { module: "x".into() },
5590 UpgradeInstruction::StateChange {
5591 script: PathBuf::from("lib/m.lisp"),
5592 },
5593 UpgradeInstruction::StateChange {
5594 script: PathBuf::from("lib/m.lisp"),
5595 },
5596 ],
5597 );
5598 let err = e.validate().unwrap_err();
5599 assert!(
5600 matches!(
5601 err,
5602 UpgradeError::PurgeWithoutPriorLoad {
5603 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5604 ..
5605 }
5606 ),
5607 "purge-without-load must surface before duplicate-state-change, got {err:?}"
5608 );
5609 }
5610
5611 #[test]
5612 fn validate_state_change_singularity_fires_after_per_instr_shape() {
5613 // Order pin: a malformed `:script` value on a `:state-change`
5614 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
5615 // *before* the within-entry state-change-singularity gate fires.
5616 // The per-instruction shape pass walks the list inline before
5617 // the singularity check, so the narrower self-locating
5618 // diagnostic surfaces first — mirrors the empty-first cascade on
5619 // every peer path-shape gate and the
5620 // `validate_load_singularity_fires_after_per_instr_shape` /
5621 // `validate_cleanup_singularity_fires_after_per_instr_shape`
5622 // pins on the sibling singularity gates.
5623 //
5624 // Two empty-path `:state-change` would *otherwise* duplicate
5625 // (both scripts are the same empty PathBuf), so this pin double-
5626 // locks the precedence: the per-instr shape gate must win on the
5627 // first malformed instruction before the duplicate scan even
5628 // reaches the second.
5629 let e = entry(
5630 "0.1.0",
5631 vec![
5632 UpgradeInstruction::LoadModule { module: "x".into() },
5633 UpgradeInstruction::StateChange {
5634 script: PathBuf::new(),
5635 },
5636 UpgradeInstruction::StateChange {
5637 script: PathBuf::new(),
5638 },
5639 ],
5640 );
5641 let err = e.validate().unwrap_err();
5642 assert_eq!(
5643 err,
5644 UpgradeError::EmptyScript,
5645 "malformed instruction must surface its narrower diagnostic before the \
5646 state-change-singularity gate fires, got {err:?}"
5647 );
5648 }
5649
5650 #[test]
5651 fn validate_state_change_singularity_fires_after_load_singularity() {
5652 // Diagnostic-precedence pin: an entry that violates *both*
5653 // singularities — duplicate load on "x" *and* duplicate
5654 // state-change on "m.lisp" — must surface the load-side
5655 // diagnostic first. The load axis precedes the migration axis
5656 // in the canonical OTP sequence (`code:load_module/1` then
5657 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
5658 // declaration order (LoadModule before StateChange), so the
5659 // load-side singularity is the load-bearing diagnostic when
5660 // both fire — the migration-side duplicate is meaningless
5661 // either way without a coherent load. Guards the call order in
5662 // `validate`: `validate_load_singularity` runs before
5663 // `validate_state_change_singularity`.
5664 let e = entry(
5665 "0.1.0",
5666 vec![
5667 UpgradeInstruction::LoadModule { module: "x".into() },
5668 UpgradeInstruction::LoadModule { module: "x".into() },
5669 UpgradeInstruction::StateChange {
5670 script: PathBuf::from("lib/m.lisp"),
5671 },
5672 UpgradeInstruction::StateChange {
5673 script: PathBuf::from("lib/m.lisp"),
5674 },
5675 ],
5676 );
5677 let err = e.validate().unwrap_err();
5678 assert_eq!(
5679 err,
5680 UpgradeError::DuplicateLoadModule {
5681 from: "0.1.0".into(),
5682 module: "x".into(),
5683 },
5684 "duplicate-load must surface before duplicate-state-change, got {err:?}"
5685 );
5686 }
5687
5688 #[test]
5689 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
5690 // Diagnostic-precedence pin: an entry that violates *both*
5691 // singularities — duplicate state-change on "m.lisp" *and*
5692 // duplicate cleanup on "y-old" — must surface the migration-
5693 // side diagnostic first. The migration axis precedes the
5694 // cleanup axis in the canonical OTP sequence
5695 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
5696 // [`UpgradeInstruction`] declaration order (StateChange before
5697 // SoftPurge/Purge), so the migration-side singularity is the
5698 // load-bearing diagnostic when both fire — the cleanup-side
5699 // duplicate is irrelevant once the migration has corrupted
5700 // state by double-applying. Guards the call order in
5701 // `validate`: `validate_state_change_singularity` runs before
5702 // `validate_cleanup_singularity`.
5703 let e = entry(
5704 "0.1.0",
5705 vec![
5706 UpgradeInstruction::LoadModule { module: "x".into() },
5707 UpgradeInstruction::StateChange {
5708 script: PathBuf::from("lib/m.lisp"),
5709 },
5710 UpgradeInstruction::StateChange {
5711 script: PathBuf::from("lib/m.lisp"),
5712 },
5713 UpgradeInstruction::SoftPurge {
5714 module: "y-old".into(),
5715 },
5716 UpgradeInstruction::SoftPurge {
5717 module: "y-old".into(),
5718 },
5719 ],
5720 );
5721 let err = e.validate().unwrap_err();
5722 assert_eq!(
5723 err,
5724 UpgradeError::DuplicateStateChange {
5725 from: "0.1.0".into(),
5726 script: PathBuf::from("lib/m.lisp"),
5727 },
5728 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
5729 );
5730 }
5731
5732 #[test]
5733 fn validate_state_change_singularity_reports_first_collision() {
5734 // Determinism pin: with three state-changes on the same script
5735 // the gate reports the *first* collision (the second
5736 // occurrence) and stops — the third's duplicate is masked by
5737 // the first surfaced one. Mirrors
5738 // `validate_load_singularity_reports_first_collision` /
5739 // `validate_cleanup_singularity_reports_first_collision` on the
5740 // sibling singularity axes and every peer duplicate gate's
5741 // first-collision discipline.
5742 let e = entry(
5743 "0.1.0",
5744 vec![
5745 UpgradeInstruction::LoadModule { module: "x".into() },
5746 UpgradeInstruction::StateChange {
5747 script: PathBuf::from("lib/m.lisp"),
5748 },
5749 UpgradeInstruction::StateChange {
5750 script: PathBuf::from("lib/m.lisp"),
5751 },
5752 UpgradeInstruction::StateChange {
5753 script: PathBuf::from("lib/m.lisp"),
5754 },
5755 ],
5756 );
5757 let err = e.validate().unwrap_err();
5758 assert_eq!(
5759 err,
5760 UpgradeError::DuplicateStateChange {
5761 from: "0.1.0".into(),
5762 script: PathBuf::from("lib/m.lisp"),
5763 },
5764 "the first colliding occurrence must surface, not the later third-migration collision"
5765 );
5766 }
5767
5768 #[test]
5769 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
5770 // The whole-list entry-point surfaces the per-entry singularity
5771 // error (mirrors
5772 // `validate_load_singularity_threads_through_validate_upgrade_from`
5773 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
5774 // the gate is reachable from the LayoutInvariants call site,
5775 // not only from a direct `entry.validate()`.
5776 let entries = vec![entry(
5777 "0.1.0",
5778 vec![
5779 UpgradeInstruction::LoadModule { module: "x".into() },
5780 UpgradeInstruction::StateChange {
5781 script: PathBuf::from("lib/m.lisp"),
5782 },
5783 UpgradeInstruction::StateChange {
5784 script: PathBuf::from("lib/m.lisp"),
5785 },
5786 ],
5787 )];
5788 let err = validate_upgrade_from(&entries).unwrap_err();
5789 assert!(
5790 matches!(err, UpgradeError::DuplicateStateChange { .. }),
5791 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
5792 );
5793 }
5794
5795 #[test]
5796 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
5797 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
5798 // per-instruction `StateChange`-arm script-path projection must
5799 // route through the sibling lifted
5800 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
5801 // accessor, not the raw
5802 // `match instr { UpgradeInstruction::StateChange { script } =>
5803 // script.as_path(), _ => continue }` open-coded pattern-match
5804 // the gate previously carried.
5805 //
5806 // Structurally: the gate's projection accept-set is the union
5807 // of every [`UpgradeInstruction`] variant for which
5808 // `declared_path().is_some()` — today exactly
5809 // [`UpgradeInstruction::StateChange`] per the sibling
5810 // `declared_path_only_for_state_change` pin, so a
5811 // duplicate-scripts input trips `DuplicateStateChange` and a
5812 // non-`StateChange` input (module-bearing / terminal) leaves
5813 // `seen` empty and the gate returns `Ok(())` byte-identical to
5814 // the pattern-match shape.
5815 //
5816 // Byte-equal today (`declared_path` returns `Some(script)` iff
5817 // `StateChange`, byte-for-byte from the variant's own storage);
5818 // the pin catches any future accessor extension that promotes
5819 // an additional variant onto the `PathBuf`-carrying axis — the
5820 // gate then fires on duplicate scripts from that variant too,
5821 // and the singularity discipline the sibling
5822 // `validate_load_singularity` / `validate_cleanup_singularity`
5823 // gates share on the `String`-carrying axis's per-variant
5824 // consumers extends to the promoted variant by construction.
5825 //
5826 // Peer of the sibling four per-`UpgradeInstruction` consumers
5827 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
5828 // sandbox-path fan-out, the layout-side per-`StateChange`
5829 // script-existence fan-out at
5830 // `caixa-core/src/layout.rs:1017`, the cross-slot
5831 // [`validate_upgrade_from_against_behavior`] gate's per-
5832 // `StateChange` detection loop, the peer
5833 // [`UpgradeInstruction::declared_module`] `String`-axis
5834 // per-variant unifier) — this gate now shares one typed
5835 // dispatch on the substrate primitive's `PathBuf`-carrying
5836 // axis with those consumers, so a future rebrand on the axis
5837 // migrates as a single caixa-core edit rather than a
5838 // coordinated rewrite of five call sites.
5839 //
5840 // Three-arm projective coverage:
5841 // (a) `StateChange` scripts project through `declared_path()`
5842 // byte-equal to the raw `script.as_path()` field access;
5843 // (b) a duplicate-`StateChange` input trips the gate on the
5844 // second occurrence with `DuplicateStateChange` carrying
5845 // the offending script verbatim;
5846 // (c) a non-`StateChange`-only input (`LoadModule` /
5847 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
5848 // vacuous with `Ok(())` — the `declared_path().is_none()`
5849 // arm's `continue` fall-through pins.
5850 //
5851 // Fail-before-pass-after verified locally: swapping the
5852 // production `let Some(script) = instr.declared_path() else {
5853 // continue };` back to `let script = match instr {
5854 // UpgradeInstruction::StateChange { script } =>
5855 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
5856 // passing but silently detaches the gate from the accessor's
5857 // typed dispatch — any future `declared_path` extension
5858 // (promotion of an additional variant onto the axis, an
5859 // operator-side pre-resolved-path cache the accessor
5860 // materializes) would then silently disagree between this
5861 // gate's raw pattern-match and the peer four sibling consumers
5862 // that route through the accessor.
5863 use std::path::PathBuf;
5864
5865 // (a) StateChange projection byte-equal via declared_path.
5866 let sc = UpgradeInstruction::StateChange {
5867 script: PathBuf::from("lib/m.lisp"),
5868 };
5869 assert_eq!(
5870 sc.declared_path().map(std::path::PathBuf::as_path),
5871 Some(PathBuf::from("lib/m.lisp").as_path()),
5872 "declared_path() must project the StateChange :script byte-equal to the raw \
5873 field access — accessor divergence would silently detach the gate from the \
5874 projection every peer per-`UpgradeInstruction` consumer routes through"
5875 );
5876
5877 // (b) Duplicate-StateChange input trips the gate.
5878 let dup = entry(
5879 "0.1.0",
5880 vec![
5881 UpgradeInstruction::LoadModule { module: "x".into() },
5882 UpgradeInstruction::StateChange {
5883 script: PathBuf::from("lib/m.lisp"),
5884 },
5885 UpgradeInstruction::StateChange {
5886 script: PathBuf::from("lib/m.lisp"),
5887 },
5888 ],
5889 );
5890 assert_eq!(
5891 dup.validate_state_change_singularity(),
5892 Err(UpgradeError::DuplicateStateChange {
5893 from: "0.1.0".into(),
5894 script: PathBuf::from("lib/m.lisp"),
5895 }),
5896 "duplicate StateChange scripts must trip the gate on the second occurrence \
5897 through the declared_path accessor's Some(script) arm"
5898 );
5899
5900 // (c) Non-StateChange-only inputs leave the gate vacuous.
5901 for instrs in [
5902 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
5903 vec![
5904 UpgradeInstruction::LoadModule { module: "x".into() },
5905 UpgradeInstruction::SoftPurge {
5906 module: "x-old".into(),
5907 },
5908 ],
5909 vec![
5910 UpgradeInstruction::LoadModule { module: "x".into() },
5911 UpgradeInstruction::Purge {
5912 module: "x-old".into(),
5913 },
5914 ],
5915 vec![UpgradeInstruction::Restart],
5916 ] {
5917 for instr in &instrs {
5918 assert!(
5919 instr.declared_path().is_none(),
5920 "non-StateChange variants must project None through declared_path — \
5921 accessor divergence would let this gate silently fire on a duplicate \
5922 module reference far from any :state-change site"
5923 );
5924 }
5925 let e = entry("0.1.0", instrs);
5926 assert_eq!(
5927 e.validate_state_change_singularity(),
5928 Ok(()),
5929 "the state-change-singularity gate must return Ok(()) on an entry whose \
5930 instructions all project None through declared_path — the accessor's \
5931 continue arm the pattern-match's `_ => continue` previously carried"
5932 );
5933 }
5934 }
5935
5936 // ── within-entry state-change-before-cleanup ordering invariant ──
5937
5938 #[test]
5939 fn validate_rejects_state_change_after_soft_purge() {
5940 // Fail-before-pass-after pin: `:state-change` is the
5941 // gen_server:code_change/3 analog and folds the prior-version
5942 // state shape into the current shape; `:soft-purge` drains the
5943 // prior code. The operator runs instructions in declared order,
5944 // so a `:soft-purge` ahead of a `:state-change` drains the
5945 // prior module before the migration callback runs against the
5946 // state it held — the canonical OTP error mode
5947 // "`code_change/3` invoked on a purged module" the
5948 // release_handler closes by always ordering the migration
5949 // before the cleanup.
5950 let e = entry(
5951 "0.1.0",
5952 vec![
5953 UpgradeInstruction::LoadModule { module: "x".into() },
5954 UpgradeInstruction::SoftPurge {
5955 module: "x-old".into(),
5956 },
5957 UpgradeInstruction::StateChange {
5958 script: PathBuf::from("lib/m.lisp"),
5959 },
5960 ],
5961 );
5962 let err = e.validate().unwrap_err();
5963 assert_eq!(
5964 err,
5965 UpgradeError::StateChangeAfterCleanup {
5966 from: "0.1.0".into(),
5967 script: PathBuf::from("lib/m.lisp"),
5968 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5969 prior_cleanup_module: "x-old".into(),
5970 },
5971 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
5972 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
5973 );
5974 }
5975
5976 #[test]
5977 fn validate_rejects_state_change_after_purge() {
5978 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5979 // the more catastrophic peer of `:soft-purge` on the cleanup
5980 // axis; same gate, same shape, the `prior_cleanup_kind` field
5981 // distinguishes the diagnostic so the author can grep their
5982 // caixa.lisp for the offending `(:purge …)` form.
5983 let e = entry(
5984 "0.1.0",
5985 vec![
5986 UpgradeInstruction::LoadModule { module: "x".into() },
5987 UpgradeInstruction::Purge {
5988 module: "x-old".into(),
5989 },
5990 UpgradeInstruction::StateChange {
5991 script: PathBuf::from("lib/m.lisp"),
5992 },
5993 ],
5994 );
5995 let err = e.validate().unwrap_err();
5996 assert_eq!(
5997 err,
5998 UpgradeError::StateChangeAfterCleanup {
5999 from: "0.1.0".into(),
6000 script: PathBuf::from("lib/m.lisp"),
6001 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6002 prior_cleanup_module: "x-old".into(),
6003 },
6004 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6005 `prior_cleanup_kind: \":purge\"`, got {err:?}"
6006 );
6007 }
6008
6009 #[test]
6010 fn validate_accepts_state_change_before_cleanup() {
6011 // Positive control: the canonical `(:load-module …)
6012 // (:state-change …) (:soft-purge …)` order validates — the
6013 // exact shape the module doc example and `validate_accepts_
6014 // well_formed` already pin, restated here on the new gate's
6015 // identity element so a future shortcut that runs the
6016 // singularity gates first doesn't silently mask a regression
6017 // here.
6018 let e = entry(
6019 "0.1.0",
6020 vec![
6021 UpgradeInstruction::LoadModule { module: "x".into() },
6022 UpgradeInstruction::StateChange {
6023 script: PathBuf::from("lib/m.lisp"),
6024 },
6025 UpgradeInstruction::SoftPurge {
6026 module: "x-old".into(),
6027 },
6028 ],
6029 );
6030 e.validate().unwrap();
6031 }
6032
6033 #[test]
6034 fn validate_accepts_cleanup_without_state_change() {
6035 // Empty-set identity: an entry that carries no `:state-change`
6036 // at all has nothing to order against the cleanup, so the gate
6037 // passes regardless of how the cleanups are placed (after the
6038 // single required `:load-module`). Mirrors the
6039 // `validate_accepts_multiple_purges_after_one_load` positive
6040 // control on the peer purge-ordering gate; metadata-only
6041 // upgrades with cleanup-but-no-migration land here.
6042 let e = entry(
6043 "0.1.0",
6044 vec![
6045 UpgradeInstruction::LoadModule { module: "x".into() },
6046 UpgradeInstruction::SoftPurge {
6047 module: "x-old".into(),
6048 },
6049 UpgradeInstruction::Purge {
6050 module: "x-oldest".into(),
6051 },
6052 ],
6053 );
6054 e.validate().unwrap();
6055 }
6056
6057 #[test]
6058 fn validate_accepts_state_change_without_cleanup() {
6059 // Empty-set identity on the dual axis: an entry that carries no
6060 // cleanup at all has nothing to order against the state-change,
6061 // so the gate passes — additive-upgrade shapes (load new code,
6062 // migrate state, leave old code resident for in-flight callers
6063 // to drain naturally) land here.
6064 let e = entry(
6065 "0.1.0",
6066 vec![
6067 UpgradeInstruction::LoadModule { module: "x".into() },
6068 UpgradeInstruction::StateChange {
6069 script: PathBuf::from("lib/m.lisp"),
6070 },
6071 ],
6072 );
6073 e.validate().unwrap();
6074 }
6075
6076 #[test]
6077 fn validate_accepts_multiple_state_changes_before_cleanup() {
6078 // Coverage: every state-change must precede every cleanup, not
6079 // just the first. A chain `(load) (sc) (sc) (sp)` is the
6080 // canonical "two distinct migration scripts on a chained
6081 // upgrade" shape (one module's schema *and* another's
6082 // projection per the DuplicateStateChange diagnostic), and
6083 // it must pass when each state-change has distinct script
6084 // paths. Pinned here so a future shortcut that only checks
6085 // the first state-change doesn't silently accept a
6086 // `(load) (sc-1) (sp) (sc-2)` regression.
6087 let e = entry(
6088 "0.1.0",
6089 vec![
6090 UpgradeInstruction::LoadModule { module: "x".into() },
6091 UpgradeInstruction::StateChange {
6092 script: PathBuf::from("lib/m1.lisp"),
6093 },
6094 UpgradeInstruction::StateChange {
6095 script: PathBuf::from("lib/m2.lisp"),
6096 },
6097 UpgradeInstruction::SoftPurge {
6098 module: "x-old".into(),
6099 },
6100 ],
6101 );
6102 e.validate().unwrap();
6103 }
6104
6105 #[test]
6106 fn validate_rejects_state_change_sandwiched_between_cleanups() {
6107 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
6108 // (sp-2)` violates the gate because the state-change runs
6109 // after the first cleanup. The reported `prior_cleanup_*`
6110 // names the *first* cleanup (the load-bearing one), not the
6111 // last — mirrors every peer first-collision diagnostic
6112 // posture on this module (`validate_state_change_ordering`,
6113 // `validate_purge_ordering`, `validate_load_singularity`,
6114 // `validate_state_change_singularity`,
6115 // `validate_cleanup_singularity` all report the first
6116 // colliding instruction, not the last).
6117 let e = entry(
6118 "0.1.0",
6119 vec![
6120 UpgradeInstruction::LoadModule { module: "x".into() },
6121 UpgradeInstruction::SoftPurge {
6122 module: "x-old".into(),
6123 },
6124 UpgradeInstruction::StateChange {
6125 script: PathBuf::from("lib/m.lisp"),
6126 },
6127 UpgradeInstruction::Purge {
6128 module: "y-old".into(),
6129 },
6130 ],
6131 );
6132 let err = e.validate().unwrap_err();
6133 assert_eq!(
6134 err,
6135 UpgradeError::StateChangeAfterCleanup {
6136 from: "0.1.0".into(),
6137 script: PathBuf::from("lib/m.lisp"),
6138 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6139 prior_cleanup_module: "x-old".into(),
6140 },
6141 "the first cleanup the state-change follows must surface (not the trailing one), \
6142 got {err:?}"
6143 );
6144 }
6145
6146 #[test]
6147 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
6148 // Diagnostic-precedence pin: an entry like `((:soft-purge
6149 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
6150 // *both* purge-without-load (the cleanup runs before the
6151 // load) and state-change-after-cleanup (the state-change
6152 // runs after the cleanup). The more-fundamental ordering
6153 // gate must win — the missing-load defect (a cleanup that
6154 // drains the only resident version to nothing) is load-
6155 // bearing, and surfacing the state-change-after-cleanup
6156 // diagnostic first would mask the drain-to-nothing defect
6157 // the peer purge-ordering gate exists to close. Guards the
6158 // call order in `validate` against silent reordering. Same
6159 // posture as `validate_purge_ordering_fires_after_state_
6160 // change_ordering` on the sibling ordering gate.
6161 //
6162 // Pin specifically uses the load-after-cleanup shape (rather
6163 // than load-less) so the state-change-ordering gate (which
6164 // would otherwise fire first on a `((:soft-purge …)
6165 // (:state-change …))` shape with no leading load) is
6166 // sidestepped: with the load present after the cleanup,
6167 // state-change-ordering passes (its `loaded` latch is set
6168 // before the state-change is encountered) but purge-ordering
6169 // still fails (the cleanup precedes the load). That isolates
6170 // the precedence between purge-ordering and this gate
6171 // cleanly.
6172 let e = entry(
6173 "0.1.0",
6174 vec![
6175 UpgradeInstruction::SoftPurge {
6176 module: "x-old".into(),
6177 },
6178 UpgradeInstruction::LoadModule { module: "x".into() },
6179 UpgradeInstruction::StateChange {
6180 script: PathBuf::from("lib/m.lisp"),
6181 },
6182 ],
6183 );
6184 let err = e.validate().unwrap_err();
6185 assert!(
6186 matches!(
6187 err,
6188 UpgradeError::PurgeWithoutPriorLoad {
6189 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6190 ..
6191 }
6192 ),
6193 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
6194 );
6195 }
6196
6197 #[test]
6198 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
6199 // Diagnostic-precedence pin: an entry like `((:state-change
6200 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
6201 // load (because no `:load-module` precedes the state-change)
6202 // but *not* state-change-after-cleanup (the state-change
6203 // precedes the cleanup textually). The state-change-ordering
6204 // gate must surface first regardless — the missing-load
6205 // defect on the migration axis is the load-bearing semantic
6206 // and surfacing a different ordering diagnostic would mask
6207 // the migration-against-stale-code defect. Guards the call
6208 // order in `validate` against silent reordering on a shape
6209 // that fires only the state-change-ordering gate (not this
6210 // one), pinning that the state-change-ordering gate wins
6211 // ahead of this gate's chance to look at the list.
6212 let e = entry(
6213 "0.1.0",
6214 vec![
6215 UpgradeInstruction::StateChange {
6216 script: PathBuf::from("lib/m.lisp"),
6217 },
6218 UpgradeInstruction::SoftPurge {
6219 module: "x-old".into(),
6220 },
6221 ],
6222 );
6223 let err = e.validate().unwrap_err();
6224 assert!(
6225 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6226 "state-change-without-load must surface before purge-without-load (the canonical \
6227 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
6228 );
6229 }
6230
6231 #[test]
6232 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
6233 // Order pin: a malformed `:script` value on a `:state-change`
6234 // (an empty path) surfaces its narrower `EmptyScript`
6235 // diagnostic *before* the within-entry state-change-before-
6236 // cleanup gate fires. The per-instruction shape pass walks
6237 // the list inline before the ordering check, so the narrower
6238 // self-locating diagnostic surfaces first — mirrors the
6239 // empty-first cascade on every peer path-shape gate and the
6240 // `validate_purge_ordering_fires_after_per_instr_shape` pin
6241 // on the sibling ordering gate.
6242 let e = entry(
6243 "0.1.0",
6244 vec![
6245 UpgradeInstruction::LoadModule { module: "x".into() },
6246 UpgradeInstruction::SoftPurge {
6247 module: "x-old".into(),
6248 },
6249 UpgradeInstruction::StateChange {
6250 script: PathBuf::new(),
6251 },
6252 ],
6253 );
6254 let err = e.validate().unwrap_err();
6255 assert_eq!(
6256 err,
6257 UpgradeError::EmptyScript,
6258 "malformed instruction must surface its narrower diagnostic before the \
6259 state-change-before-cleanup gate fires, got {err:?}"
6260 );
6261 }
6262
6263 #[test]
6264 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
6265 // Diagnostic-precedence pin: an entry like `((:load-module
6266 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
6267 // (:state-change "m.lisp"))` violates *both* this ordering
6268 // gate (the first state-change follows the cleanup) and the
6269 // state-change-singularity gate (the same script appears
6270 // twice). The ordering gate must win — the canonical
6271 // "ordering before singularity" precedence the peer
6272 // `validate_state_change_ordering` / `validate_purge_
6273 // ordering` gates already establish over their own singularity
6274 // gates, applied uniformly across the OTP canonical-sequence
6275 // ordering axis here. Guards the call order in `validate`:
6276 // `validate_state_change_before_cleanup` runs before the
6277 // per-instruction-class singularity gates.
6278 let e = entry(
6279 "0.1.0",
6280 vec![
6281 UpgradeInstruction::LoadModule { module: "x".into() },
6282 UpgradeInstruction::SoftPurge {
6283 module: "x-old".into(),
6284 },
6285 UpgradeInstruction::StateChange {
6286 script: PathBuf::from("lib/m.lisp"),
6287 },
6288 UpgradeInstruction::StateChange {
6289 script: PathBuf::from("lib/m.lisp"),
6290 },
6291 ],
6292 );
6293 let err = e.validate().unwrap_err();
6294 assert!(
6295 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6296 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
6297 );
6298 }
6299
6300 #[test]
6301 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
6302 // The whole-list entry-point surfaces the per-entry ordering
6303 // error (mirrors `validate_purge_ordering_threads_through_
6304 // validate_upgrade_from` and every peer wiring pin): the gate
6305 // is reachable from the LayoutInvariants call site, not only
6306 // from a direct `entry.validate()`.
6307 let entries = vec![entry(
6308 "0.1.0",
6309 vec![
6310 UpgradeInstruction::LoadModule { module: "x".into() },
6311 UpgradeInstruction::SoftPurge {
6312 module: "x-old".into(),
6313 },
6314 UpgradeInstruction::StateChange {
6315 script: PathBuf::from("lib/m.lisp"),
6316 },
6317 ],
6318 )];
6319 let err = validate_upgrade_from(&entries).unwrap_err();
6320 assert!(
6321 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
6322 "validate_upgrade_from must thread the state-change-before-cleanup error, \
6323 got {err:?}"
6324 );
6325 }
6326
6327 #[test]
6328 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
6329 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
6330 // per-instruction `StateChange`-arm script-path projection must
6331 // route through the sibling lifted
6332 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6333 // accessor, not the raw
6334 // `if let UpgradeInstruction::StateChange { script } = instr`
6335 // open-coded pattern-match the gate previously carried inside
6336 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
6337 //
6338 // Structurally: the gate's projection accept-set is the union
6339 // of every [`UpgradeInstruction`] variant for which
6340 // `declared_path().is_some()` — today exactly
6341 // [`UpgradeInstruction::StateChange`] per the sibling
6342 // `declared_path_only_for_state_change` pin, so a
6343 // state-change-after-cleanup input trips
6344 // `StateChangeAfterCleanup` and a non-`StateChange` input
6345 // (module-bearing / terminal) leaves the sticky-once latch
6346 // sweep quiet byte-identical to the pattern-match shape.
6347 //
6348 // Byte-equal today (`declared_path` returns `Some(script)` iff
6349 // `StateChange`, byte-for-byte from the variant's own storage);
6350 // the pin catches any future accessor extension that promotes
6351 // an additional variant onto the `PathBuf`-carrying axis — the
6352 // gate then fires on migrate-after-cleanup for that variant too,
6353 // and the migrate→cleanup ordering discipline the peer
6354 // [`validate_state_change_singularity`] /
6355 // [`validate_upgrade_from_against_behavior`] gates share on the
6356 // same axis extends to the promoted variant by construction.
6357 //
6358 // Peer of the sibling four per-`UpgradeInstruction` consumers
6359 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6360 // sandbox-path fan-out, the layout-side per-`StateChange`
6361 // script-existence fan-out at
6362 // `caixa-core/src/layout.rs:1058`, the within-entry
6363 // [`UpgradeFromEntry::validate_state_change_singularity`]
6364 // per-`StateChange` script-projection fan-out, the cross-slot
6365 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
6366 // detection loop) — the fifth (and last unlifted inside
6367 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
6368 // the `PathBuf`-carrying axis to now route through the accessor.
6369 // Same shape as the sibling
6370 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
6371 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
6372 // pins extended onto the within-entry migrate→cleanup ordering
6373 // gate.
6374 //
6375 // Three-arm projective coverage:
6376 // (a) `StateChange` scripts project through `declared_path()`
6377 // byte-equal to the raw `script.clone()` field access
6378 // the diagnostic previously carried;
6379 // (b) a `:state-change`-after-cleanup input trips the gate
6380 // with `StateChangeAfterCleanup` carrying the offending
6381 // script + the prior cleanup's kind/module verbatim;
6382 // (c) a non-`StateChange`-only input (`LoadModule` /
6383 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6384 // vacuous with `Ok(())` — the `declared_path().is_none()`
6385 // arm's fall-through pins.
6386 //
6387 // Fail-before-pass-after verified structurally: swapping the
6388 // production
6389 // `else if let Some(script) = instr.declared_path() && … { … }`
6390 // back to
6391 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
6392 // keeps arms (a)-(c) passing but silently detaches this within-
6393 // entry ordering gate from the accessor's typed dispatch — any
6394 // future `declared_path` extension (promotion of an additional
6395 // variant onto the axis, an operator-side pre-resolved-path
6396 // cache the accessor materializes) would then silently disagree
6397 // between this gate's raw pattern-match and the peer four
6398 // sibling consumers that route through the accessor.
6399
6400 // (a) StateChange projection byte-equal via declared_path.
6401 let sc = UpgradeInstruction::StateChange {
6402 script: PathBuf::from("lib/m.lisp"),
6403 };
6404 assert_eq!(
6405 sc.declared_path().cloned(),
6406 Some(PathBuf::from("lib/m.lisp")),
6407 "declared_path() must project the StateChange :script byte-equal to the raw \
6408 field access — accessor divergence would silently detach this within-entry \
6409 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
6410 consumer routes through"
6411 );
6412
6413 // (b) StateChange-after-cleanup trips the gate through the accessor.
6414 let after = entry(
6415 "0.1.0",
6416 vec![
6417 UpgradeInstruction::LoadModule { module: "x".into() },
6418 UpgradeInstruction::SoftPurge {
6419 module: "x-old".into(),
6420 },
6421 UpgradeInstruction::StateChange {
6422 script: PathBuf::from("lib/m.lisp"),
6423 },
6424 ],
6425 );
6426 assert_eq!(
6427 after.validate(),
6428 Err(UpgradeError::StateChangeAfterCleanup {
6429 from: "0.1.0".into(),
6430 script: PathBuf::from("lib/m.lisp"),
6431 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6432 prior_cleanup_module: "x-old".into(),
6433 }),
6434 "a :state-change following a cleanup must trip the gate through the declared_path \
6435 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
6436 kind/module verbatim byte-identical to the pattern-match shape"
6437 );
6438
6439 // (c) Non-StateChange-only inputs leave the gate vacuous.
6440 for instrs in [
6441 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6442 vec![
6443 UpgradeInstruction::LoadModule { module: "x".into() },
6444 UpgradeInstruction::SoftPurge {
6445 module: "x-old".into(),
6446 },
6447 ],
6448 vec![
6449 UpgradeInstruction::LoadModule { module: "x".into() },
6450 UpgradeInstruction::Purge {
6451 module: "x-old".into(),
6452 },
6453 ],
6454 vec![UpgradeInstruction::Restart],
6455 ] {
6456 for instr in &instrs {
6457 assert!(
6458 instr.declared_path().is_none(),
6459 "non-StateChange variants must project None through declared_path — \
6460 accessor divergence would let this within-entry ordering gate silently \
6461 fire on a cleanup-only sequence far from any :state-change site"
6462 );
6463 }
6464 let e = entry("0.1.0", instrs);
6465 assert_eq!(
6466 e.validate(),
6467 Ok(()),
6468 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
6469 instructions all project None through declared_path — the accessor's \
6470 None arm the pattern-match's implicit fall-through previously carried"
6471 );
6472 }
6473 }
6474
6475 #[test]
6476 fn validate_restart_order_independent() {
6477 // Position-agnostic: `(:restart)` leading or trailing the
6478 // mixed sequence surfaces the same RestartNotExclusive shape.
6479 // Mirrors OTP appup's order-insensitive
6480 // `restart_emulator | restart_new_emulator` terminal rule —
6481 // the position of the restart instruction in the script is
6482 // irrelevant; what matters is the script *contains* it
6483 // alongside other instructions at all. The gate must not
6484 // gain a false positive by depending on instruction ordering.
6485 let leading = entry(
6486 "0.1.0",
6487 vec![
6488 UpgradeInstruction::Restart,
6489 UpgradeInstruction::LoadModule { module: "x".into() },
6490 ],
6491 );
6492 let trailing = entry(
6493 "0.1.0",
6494 vec![
6495 UpgradeInstruction::LoadModule { module: "x".into() },
6496 UpgradeInstruction::Restart,
6497 ],
6498 );
6499 let middle = entry(
6500 "0.1.0",
6501 vec![
6502 UpgradeInstruction::LoadModule { module: "a".into() },
6503 UpgradeInstruction::Restart,
6504 UpgradeInstruction::SoftPurge {
6505 module: "a-old".into(),
6506 },
6507 ],
6508 );
6509 for e in [&leading, &trailing, &middle] {
6510 assert!(
6511 matches!(
6512 e.validate().unwrap_err(),
6513 UpgradeError::RestartNotExclusive {
6514 restart_count: 1,
6515 ..
6516 }
6517 ),
6518 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
6519 instruction order, got {:?}",
6520 e.validate()
6521 );
6522 }
6523 }
6524
6525 #[test]
6526 fn validate_restart_exclusive_fires_after_per_instr_shape() {
6527 // Order pin: a malformed `:module` value on a Module-bearing
6528 // instruction (an empty string) surfaces its narrower
6529 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
6530 // entry restart-exclusivity gate fires. The per-instruction
6531 // shape pass walks the list inline before the restart-
6532 // exclusive check, so the narrower self-locating diagnostic
6533 // surfaces first — mirrors the empty-first cascade on every
6534 // peer DNS-1123 gate (`validate_module`,
6535 // `validate_membro_caixa`, `validate_placement_cluster`) and
6536 // the `*_invalid_fires_before_duplicate_check` arm-ordering
6537 // pins on every typed-graph axis. Without this pin a future
6538 // shortcut that runs the restart-exclusive check ahead of
6539 // per-instruction shape would surface a less-actionable
6540 // RestartNotExclusive over an instruction list that's also
6541 // malformed at the per-instruction layer.
6542 let e = entry(
6543 "0.1.0",
6544 vec![
6545 UpgradeInstruction::LoadModule {
6546 module: String::new(),
6547 },
6548 UpgradeInstruction::Restart,
6549 ],
6550 );
6551 let err = e.validate().unwrap_err();
6552 assert_eq!(
6553 err,
6554 UpgradeError::ModuleEmpty {
6555 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
6556 },
6557 "malformed instruction must surface its kind-tagged diagnostic before the \
6558 restart-exclusivity gate fires, got {err:?}"
6559 );
6560 }
6561
6562 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
6563 // Helper for the cross-slot composition gate's pass arm: a
6564 // BehaviorSpec carrying just the `:on-state-change` callback,
6565 // the runtime hook the per-version `(:state-change "…")`
6566 // instruction is delivered through during hot upgrade. Mirrors
6567 // the canonical authoring shape pinned in the module doc.
6568 crate::BehaviorSpec {
6569 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
6570 ..Default::default()
6571 }
6572 }
6573
6574 #[test]
6575 fn behavior_gate_rejects_state_change_without_any_behavior() {
6576 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
6577 // caixa carries no `:behavior` at all surfaces the missing-
6578 // callback diagnostic naming the offending entry's `:from` +
6579 // script. The "I added the upgrade path but never declared
6580 // `:behavior`" footgun: `:behavior` is optional at the typed
6581 // root, the typed `:upgrade-from` slot validates on its own
6582 // merits, and the operator's hot-upgrade dispatch reaches for
6583 // a callback that doesn't exist.
6584 let entries = vec![entry(
6585 "0.1.0",
6586 vec![
6587 UpgradeInstruction::LoadModule { module: "x".into() },
6588 UpgradeInstruction::StateChange {
6589 script: PathBuf::from("lib/m.lisp"),
6590 },
6591 ],
6592 )];
6593 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6594 assert_eq!(
6595 err,
6596 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6597 from: "0.1.0".into(),
6598 script: PathBuf::from("lib/m.lisp"),
6599 },
6600 );
6601 }
6602
6603 #[test]
6604 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
6605 // `:behavior` declared with *other* callbacks set
6606 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
6607 // None still surfaces the missing-callback diagnostic — only
6608 // the `:on-state-change` axis matters for this gate. The
6609 // "I declared `:behavior` but missed the migration callback"
6610 // footgun: a caixa that registers its lifecycle hooks but
6611 // forgets the migration delivery path leaves the
6612 // `:state-change` instruction with no runtime hook to
6613 // dispatch through.
6614 let entries = vec![entry(
6615 "0.1.0",
6616 vec![
6617 UpgradeInstruction::LoadModule { module: "x".into() },
6618 UpgradeInstruction::StateChange {
6619 script: PathBuf::from("lib/m.lisp"),
6620 },
6621 ],
6622 )];
6623 let b = crate::BehaviorSpec {
6624 on_init: Some(PathBuf::from("lib/init.lisp")),
6625 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
6626 ..Default::default()
6627 };
6628 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
6629 assert_eq!(
6630 err,
6631 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6632 from: "0.1.0".into(),
6633 script: PathBuf::from("lib/m.lisp"),
6634 },
6635 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
6636 the missing migration hook"
6637 );
6638 }
6639
6640 #[test]
6641 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
6642 // The canonical composition shape: a per-version
6643 // `(:state-change "lib/m.lisp")` instruction paired with the
6644 // `:behavior :on-state-change "lib/migrations.lisp"` callback
6645 // it is delivered through at hot-upgrade time. Pins the gate's
6646 // pass arm — drift here = a future tighten that rejects the
6647 // canonical OTP-shape composition surfaces as a regression at
6648 // this positive-control pin.
6649 let entries = vec![entry(
6650 "0.1.0",
6651 vec![
6652 UpgradeInstruction::LoadModule { module: "x".into() },
6653 UpgradeInstruction::StateChange {
6654 script: PathBuf::from("lib/m.lisp"),
6655 },
6656 ],
6657 )];
6658 let b = behavior_with_state_change_callback();
6659 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6660 }
6661
6662 #[test]
6663 fn behavior_gate_accepts_entries_without_any_state_change() {
6664 // Empty-set identity: entries carrying no `:state-change`
6665 // instruction at all (load + cleanup only — the metadata-only
6666 // upgrade shape the module doc names, "On any failure, the
6667 // current version stays load-bearing — a typed atomic
6668 // upgrade") leave the gate vacuous. The composition only
6669 // requires a callback when the per-version script exists; a
6670 // load + cleanup pair has no migration to deliver, so the
6671 // absence of `:on-state-change` is coherent.
6672 let entries = vec![entry(
6673 "0.1.0",
6674 vec![
6675 UpgradeInstruction::LoadModule { module: "x".into() },
6676 UpgradeInstruction::SoftPurge {
6677 module: "x-old".into(),
6678 },
6679 ],
6680 )];
6681 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6682 }
6683
6684 #[test]
6685 fn behavior_gate_accepts_restart_only_entry() {
6686 // The terminal-fallback `((:restart))` shape carries no
6687 // `:state-change` — the operator restarts the pod and the
6688 // new version comes up fresh against its initial state, no
6689 // migration. Pinned alongside the metadata-only positive
6690 // control above as the second empty-state-change shape.
6691 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
6692 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6693 }
6694
6695 #[test]
6696 fn behavior_gate_accepts_empty_entries_list() {
6697 // Empty `:upgrade-from` (a caixa with no declared upgrade
6698 // paths — the v0.1.0 caixa before any upgrade entries are
6699 // added) trivially passes the gate. Pinned so the gate
6700 // doesn't accidentally fire on a caixa that hasn't yet
6701 // declared any upgrades.
6702 let entries: Vec<UpgradeFromEntry> = vec![];
6703 validate_upgrade_from_against_behavior(&entries, None).unwrap();
6704 }
6705
6706 #[test]
6707 fn behavior_gate_reports_first_state_change_in_first_entry() {
6708 // First-collision determinism: with multiple `:state-change`
6709 // instructions across multiple entries, the gate reports the
6710 // *first* one encountered in declaration order — the entry's
6711 // declaration order first, then the within-entry instruction
6712 // order. Mirrors every peer first-collision diagnostic posture
6713 // on this module (`validate_state_change_ordering`,
6714 // `validate_purge_ordering`, the singularity gates), so a
6715 // future shortcut that walks the list in reverse or returns
6716 // the last collision surfaces as a regression here.
6717 let entries = vec![
6718 entry(
6719 "0.1.0",
6720 vec![
6721 UpgradeInstruction::LoadModule { module: "x".into() },
6722 UpgradeInstruction::StateChange {
6723 script: PathBuf::from("lib/m1.lisp"),
6724 },
6725 UpgradeInstruction::StateChange {
6726 script: PathBuf::from("lib/m2.lisp"),
6727 },
6728 ],
6729 ),
6730 entry(
6731 "0.1.5",
6732 vec![
6733 UpgradeInstruction::LoadModule { module: "x".into() },
6734 UpgradeInstruction::StateChange {
6735 script: PathBuf::from("lib/m3.lisp"),
6736 },
6737 ],
6738 ),
6739 ];
6740 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6741 assert_eq!(
6742 err,
6743 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6744 from: "0.1.0".into(),
6745 script: PathBuf::from("lib/m1.lisp"),
6746 },
6747 "the first :state-change in the first entry must surface, not later collisions"
6748 );
6749 }
6750
6751 #[test]
6752 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
6753 // Cross-entry pin: a first entry with no `:state-change` (just
6754 // a load + cleanup) leaves the gate's per-entry walk continuing
6755 // to the second entry, where the offending instruction lives.
6756 // The diagnostic names the *second* entry's `:from` because
6757 // that's where the missing-callback shape is exposed — pinned
6758 // so a shortcut that bails on the first entry without a
6759 // `:state-change` (rather than continuing) doesn't mask the
6760 // defect in a later entry.
6761 let entries = vec![
6762 entry(
6763 "0.1.0",
6764 vec![
6765 UpgradeInstruction::LoadModule { module: "x".into() },
6766 UpgradeInstruction::SoftPurge {
6767 module: "x-old".into(),
6768 },
6769 ],
6770 ),
6771 entry(
6772 "0.1.5",
6773 vec![
6774 UpgradeInstruction::LoadModule { module: "x".into() },
6775 UpgradeInstruction::StateChange {
6776 script: PathBuf::from("lib/m.lisp"),
6777 },
6778 ],
6779 ),
6780 ];
6781 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
6782 assert_eq!(
6783 err,
6784 UpgradeError::StateChangeWithoutOnStateChangeCallback {
6785 from: "0.1.5".into(),
6786 script: PathBuf::from("lib/m.lisp"),
6787 },
6788 "the offending entry's `:from` must surface even when an earlier entry carries no \
6789 :state-change"
6790 );
6791 }
6792
6793 #[test]
6794 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
6795 // Positive control: a multi-entry `:upgrade-from` (chained
6796 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
6797 // a `:state-change` passes when the callback is declared once
6798 // at the caixa root. The callback is a single per-caixa
6799 // runtime hook; one declaration covers every entry's
6800 // `:state-change`, mirroring OTP's
6801 // `release_handler:install_release/1` which dispatches every
6802 // appup's `code_change` instruction through the single
6803 // `gen_server:code_change/3` callback registered on the
6804 // module.
6805 let entries = vec![
6806 entry(
6807 "0.1.0",
6808 vec![
6809 UpgradeInstruction::LoadModule { module: "x".into() },
6810 UpgradeInstruction::StateChange {
6811 script: PathBuf::from("lib/m1.lisp"),
6812 },
6813 ],
6814 ),
6815 entry(
6816 "0.1.5",
6817 vec![
6818 UpgradeInstruction::LoadModule { module: "x".into() },
6819 UpgradeInstruction::StateChange {
6820 script: PathBuf::from("lib/m2.lisp"),
6821 },
6822 ],
6823 ),
6824 ];
6825 let b = behavior_with_state_change_callback();
6826 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6827 }
6828
6829 #[test]
6830 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
6831 // Symmetry pin: the gate's pass arm doesn't depend on the
6832 // entry actually carrying a `:state-change` — if no
6833 // `:state-change` is declared, the gate is vacuous regardless
6834 // of the callback (an `:on-state-change` declared without a
6835 // matching per-version script is fine, the callback is the
6836 // runtime default for any *future* migration the author hasn't
6837 // yet added). Pins that a caixa author can declare the
6838 // callback ahead of any migration without the gate
6839 // complaining.
6840 let entries = vec![entry(
6841 "0.1.0",
6842 vec![
6843 UpgradeInstruction::LoadModule { module: "x".into() },
6844 UpgradeInstruction::SoftPurge {
6845 module: "x-old".into(),
6846 },
6847 ],
6848 )];
6849 let b = behavior_with_state_change_callback();
6850 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
6851 }
6852
6853 #[test]
6854 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
6855 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
6856 // per-instruction `StateChange`-arm script-path projection must
6857 // route through the sibling lifted
6858 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6859 // accessor, not the raw
6860 // `if let UpgradeInstruction::StateChange { script } = instr`
6861 // open-coded pattern-match the cross-slot gate previously
6862 // carried at caixa-core/src/upgrade.rs:1365.
6863 //
6864 // Structurally: the gate's projection accept-set is the union
6865 // of every [`UpgradeInstruction`] variant for which
6866 // `declared_path().is_some()` — today exactly
6867 // [`UpgradeInstruction::StateChange`] per the sibling
6868 // `declared_path_only_for_state_change` pin, so a
6869 // `:state-change`-carrying entry without an `:on-state-change`
6870 // callback trips `StateChangeWithoutOnStateChangeCallback` and
6871 // a non-`StateChange` entry (load-only / cleanup-only /
6872 // restart-only / empty-`:instructions`) leaves the per-entry
6873 // walk continuing past every non-projecting instruction
6874 // byte-identical to the pattern-match shape.
6875 //
6876 // Byte-equal today (`declared_path` returns `Some(script)` iff
6877 // `StateChange`, byte-for-byte from the variant's own storage);
6878 // the pin catches any future accessor extension that promotes
6879 // an additional variant onto the `PathBuf`-carrying axis — the
6880 // gate then fires on scripts from that variant too, and the
6881 // cross-slot composition discipline the sibling per-
6882 // `UpgradeInstruction` consumers share on the `PathBuf`-
6883 // carrying axis extends to the promoted variant by
6884 // construction.
6885 //
6886 // Peer of the sibling four per-`UpgradeInstruction` consumers
6887 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6888 // sandbox-path fan-out, the layout-side per-`StateChange`
6889 // script-existence fan-out at
6890 // `caixa-core/src/layout.rs:1058`, the within-entry
6891 // [`UpgradeFromEntry::validate_state_change_singularity`]
6892 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
6893 // peer [`UpgradeInstruction::declared_module`] `String`-axis
6894 // per-variant unifier) — the fourth (and last) per-
6895 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
6896 // to now route through the accessor. Same shape as the
6897 // sibling
6898 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
6899 // pin extended onto the cross-slot composition gate.
6900 //
6901 // Three-arm projective coverage:
6902 // (a) `StateChange` scripts project through `declared_path()`
6903 // byte-equal to the raw `script.clone()` field access
6904 // the diagnostic previously carried;
6905 // (b) a `:state-change`-carrying entry with `behavior: None`
6906 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
6907 // carrying the offending script verbatim;
6908 // (c) a non-`StateChange`-only entry (`LoadModule` /
6909 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6910 // vacuous with `Ok(())` — the `declared_path().is_none()`
6911 // arm's fall-through pins.
6912 //
6913 // Fail-before-pass-after verified structurally: swapping the
6914 // production
6915 // `if let Some(script) = instr.declared_path() { … }`
6916 // back to
6917 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
6918 // keeps arms (a)-(c) passing but silently detaches the gate
6919 // from the accessor's typed dispatch — any future
6920 // `declared_path` extension (promotion of an additional
6921 // variant onto the axis, an operator-side pre-resolved-path
6922 // cache the accessor materializes) would then silently
6923 // disagree between this cross-slot gate's raw pattern-match
6924 // and the peer four sibling consumers that route through the
6925 // accessor.
6926
6927 // (a) StateChange projection byte-equal via declared_path.
6928 let sc = UpgradeInstruction::StateChange {
6929 script: PathBuf::from("lib/m.lisp"),
6930 };
6931 assert_eq!(
6932 sc.declared_path().cloned(),
6933 Some(PathBuf::from("lib/m.lisp")),
6934 "declared_path() must project the StateChange :script byte-equal to the raw \
6935 field access — accessor divergence would silently detach this cross-slot \
6936 composition gate from the projection every peer per-`UpgradeInstruction` \
6937 consumer routes through"
6938 );
6939
6940 // (b) StateChange-carrying entry with behavior: None trips gate.
6941 let entries = vec![entry(
6942 "0.1.0",
6943 vec![
6944 UpgradeInstruction::LoadModule { module: "x".into() },
6945 UpgradeInstruction::StateChange {
6946 script: PathBuf::from("lib/m.lisp"),
6947 },
6948 ],
6949 )];
6950 assert_eq!(
6951 validate_upgrade_from_against_behavior(&entries, None),
6952 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
6953 from: "0.1.0".into(),
6954 script: PathBuf::from("lib/m.lisp"),
6955 }),
6956 "a :state-change-carrying entry with behavior: None must trip the gate through \
6957 the declared_path accessor's Some(script) arm — carrying the offending script \
6958 verbatim byte-identical to the pattern-match shape"
6959 );
6960
6961 // (c) Non-StateChange-only inputs leave the gate vacuous.
6962 for instrs in [
6963 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6964 vec![
6965 UpgradeInstruction::LoadModule { module: "x".into() },
6966 UpgradeInstruction::SoftPurge {
6967 module: "x-old".into(),
6968 },
6969 ],
6970 vec![
6971 UpgradeInstruction::LoadModule { module: "x".into() },
6972 UpgradeInstruction::Purge {
6973 module: "x-old".into(),
6974 },
6975 ],
6976 vec![UpgradeInstruction::Restart],
6977 ] {
6978 for instr in &instrs {
6979 assert!(
6980 instr.declared_path().is_none(),
6981 "non-StateChange variants must project None through declared_path — \
6982 accessor divergence would let this cross-slot composition gate silently \
6983 fire on a module reference far from any :state-change site"
6984 );
6985 }
6986 let entries = vec![entry("0.1.0", instrs)];
6987 assert_eq!(
6988 validate_upgrade_from_against_behavior(&entries, None),
6989 Ok(()),
6990 "the cross-slot composition gate must return Ok(()) on an entry whose \
6991 instructions all project None through declared_path — the accessor's \
6992 None arm the pattern-match's implicit fall-through previously carried"
6993 );
6994 }
6995 }
6996
6997 #[test]
6998 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
6999 // Wiring pin: the within-entry restart-exclusivity gate fires
7000 // through [`validate_upgrade_from`] (which delegates to
7001 // [`UpgradeFromEntry::validate`] per entry) before the cross-
7002 // entry duplicate-`:from` gate would have a chance to run on
7003 // the malformed entry. Pinned here so a future refactor that
7004 // walks the cross-entry gate first doesn't accidentally
7005 // surface a DuplicateFrom over an entry that's also malformed
7006 // at the within-entry restart-exclusivity layer.
7007 let entries = vec![
7008 entry(
7009 "0.1.0",
7010 vec![
7011 UpgradeInstruction::LoadModule { module: "x".into() },
7012 UpgradeInstruction::Restart,
7013 ],
7014 ),
7015 entry("0.1.0", vec![UpgradeInstruction::Restart]),
7016 ];
7017 let err = validate_upgrade_from(&entries).unwrap_err();
7018 assert!(
7019 matches!(
7020 err,
7021 UpgradeError::RestartNotExclusive {
7022 restart_count: 1,
7023 ..
7024 }
7025 ),
7026 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7027 duplicate-`:from` gate fires, got {err:?}"
7028 );
7029 }
7030
7031 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7032
7033 #[test]
7034 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7035 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7036 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7037 // name the exact camelCase JSON keys the `#[serde(rename_all =
7038 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7039 // test-side probe across the caixa-core / caixa-flux renderer
7040 // test fixtures navigates into each element of the rendered
7041 // `:upgrade-from` overlay sequence by consulting one of these two
7042 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7043 // and pin that each canonical byte-sequence appears verbatim in
7044 // the JSON — a future accidental `rename_all = "snake_case"` /
7045 // `"kebab-case"` / verbatim-field-name flip at the derive
7046 // attribute (any of which would silently break every test-side
7047 // probe that reaches for one of the two consts) surfaces here as
7048 // a build-time test failure at `upgrade.rs`, not as an apply-time
7049 // `.get(<stale-canonical-const>)` returning `None` far from the
7050 // derive-attr drift's commit. Same discipline the sibling
7051 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7052 // (d8b8b4f) and
7053 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7054 // (21fe462) pins established on the peer `:limits` / `:behavior`
7055 // sub-slot axes: one canonical byte-string per typed sub-key
7056 // axis, pinned to the load-bearing serde derivation at the type
7057 // itself.
7058 let e = UpgradeFromEntry {
7059 from: "0.1.0".into(),
7060 instructions: vec![UpgradeInstruction::LoadModule {
7061 module: "hello-rio".into(),
7062 }],
7063 };
7064 let json = serde_json::to_string(&e).unwrap();
7065 for key in [
7066 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7067 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7068 ] {
7069 let quoted = format!("\"{key}\"");
7070 assert!(
7071 json.contains("ed),
7072 "serialized UpgradeFromEntry must carry the lifted \
7073 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7074 the JSON emission (got: {json})",
7075 );
7076 }
7077 }
7078
7079 #[test]
7080 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7081 // Cross-axis drift-detection pin: a future collapse of the two
7082 // canonical sub-key byte-strings onto the same value (e.g. an
7083 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7084 // to also read `"from"`) would silently reroute every test-side
7085 // probe on one axis onto the sibling axis's per-entry field and
7086 // pass every propagation-probe test that expected only the stale
7087 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7088 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7089 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7090 let all = [
7091 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7092 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7093 ];
7094 for (i, a) in all.iter().enumerate() {
7095 for b in all.iter().skip(i + 1) {
7096 assert_ne!(
7097 a, b,
7098 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
7099 canonical byte-sequences — got `{a}` == `{b}`",
7100 );
7101 }
7102 }
7103 }
7104
7105 #[test]
7106 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
7107 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7108 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
7109 // tagged variant-discriminator key axis: the
7110 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
7111 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
7112 // attribute on [`UpgradeInstruction`] emits, and every downstream
7113 // consumer that navigates the serialized instruction blob to
7114 // route by variant (the caixa-core reflection-vs-serde round-trip
7115 // check in `dispatcher_registration.rs` that probes
7116 // `v.get("kind")` against every variant's expected kebab-case
7117 // tag, the future M4 admission-webhook path, any wasm-operator
7118 // dispatch step consuming the serialized instruction blob) reads
7119 // through the same `&'static str`. Serialize every variant and
7120 // pin that the const's byte-sequence appears verbatim as the
7121 // tag-slot JSON key with the expected kebab-case value — a
7122 // future accidental `tag = "type"` / `tag = "op"` /
7123 // `tag = "instruction"` rebrand at the derive attribute (any of
7124 // which would silently break every consumer probe reaching for
7125 // the stale-tag-key const) surfaces here as a build-time test
7126 // failure at `upgrade.rs`, not as an apply-time
7127 // `.get(<stale-tag-key>)` returning `None` far from the derive-
7128 // attr drift's commit.
7129 //
7130 // Same "one canonical byte-string per typed axis" discipline the
7131 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7132 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
7133 // outer-container axis — this pin extends the discipline one
7134 // altitude deeper onto the per-instruction *tag* axis inside
7135 // each element of the `:instructions` list, completing the
7136 // typed coverage of the `:upgrade-from :instructions` dual
7137 // (key = "kind" + five variant-value tags): the five
7138 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
7139 // per-variant kebab-case *values*; this pin pins the tag *key*
7140 // above them.
7141 let samples: [(UpgradeInstruction, &'static str); 5] = [
7142 (
7143 UpgradeInstruction::LoadModule {
7144 module: "hello-rio".into(),
7145 },
7146 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
7147 ),
7148 (
7149 UpgradeInstruction::StateChange {
7150 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7151 },
7152 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
7153 ),
7154 (
7155 UpgradeInstruction::SoftPurge {
7156 module: "hello-rio-old".into(),
7157 },
7158 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
7159 ),
7160 (
7161 UpgradeInstruction::Purge {
7162 module: "hello-rio-old".into(),
7163 },
7164 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
7165 ),
7166 (
7167 UpgradeInstruction::Restart,
7168 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
7169 ),
7170 ];
7171 for (sample, expected_value) in &samples {
7172 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
7173 let got = v
7174 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
7175 .and_then(|k| k.as_str());
7176 assert_eq!(
7177 got,
7178 Some(*expected_value),
7179 "serialized {sample:?} must carry the lifted \
7180 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
7181 ({:?}) verbatim as the tag-slot JSON key, holding the \
7182 expected kebab-case value {expected_value:?} (got: {v})",
7183 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
7184 );
7185 }
7186 }
7187
7188 #[test]
7189 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
7190 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
7191 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7192 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7193 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7194 // whitespace / colons / dots) — the canonical shape a serde
7195 // internally-tagged discriminator key takes across every peer
7196 // enum in this crate. A future flip to a non-camelCase byte at
7197 // the const surfaces here at build time. Peer of
7198 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
7199 // sibling per-entry outer-container axis.
7200 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7201 assert!(
7202 !key.is_empty(),
7203 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
7204 );
7205 let first = key.chars().next().unwrap();
7206 assert!(
7207 first.is_ascii_lowercase(),
7208 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
7209 byte (got {key:?}, leads with {first:?})",
7210 );
7211 assert!(
7212 key.chars().all(|c| c.is_ascii_alphanumeric()),
7213 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
7214 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7215 );
7216 }
7217
7218 #[test]
7219 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
7220 // Cross-axis drift-detection pin: the tag-slot key
7221 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
7222 // disjoint from every per-variant data-field key the
7223 // internally-tagged serialization also emits (`"module"` for
7224 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
7225 // future accidental rebrand that collapses `tag = "kind"` onto
7226 // one of the data-field names (e.g. `tag = "module"`) would
7227 // silently corrupt every serialized LoadModule blob (the
7228 // module string and the variant tag would collide on the same
7229 // JSON key) and every consumer probe would either misread the
7230 // tag or fail to distinguish variants. Pin the disjointness at
7231 // build time. Same cross-axis discipline the sibling
7232 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
7233 // (36ffe65) established on the outer container's own
7234 // `from`/`instructions` pair.
7235 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
7236 // Enumerate every per-variant data-field key across all five
7237 // variants of [`UpgradeInstruction`], routing through the two
7238 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
7239 // that name the same per-variant data-field JSON keys the
7240 // `variant_fields` reflection in
7241 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
7242 // per-variant struct-field rebrand (`module` → `component`,
7243 // `script` → `path`) lands as an edit to exactly one const and
7244 // reaches this disjointness pin by construction — the two axes
7245 // (tag-slot key on one side, per-variant data-field keys on the
7246 // other) share one source of truth per axis.
7247 for data_field in [
7248 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7249 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7250 ] {
7251 assert_ne!(
7252 key, data_field,
7253 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
7254 must be disjoint from every UpgradeInstruction per-variant \
7255 data-field key — got tag-key {key:?} colliding with \
7256 data-field {data_field:?}, which would silently corrupt \
7257 the internally-tagged serialization",
7258 );
7259 }
7260 }
7261
7262 #[test]
7263 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
7264 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
7265 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
7266 // data-field JSON key axis: the two
7267 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
7268 // `_SCRIPT`) name the exact per-variant field JSON keys the
7269 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
7270 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
7271 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
7272 // const — the `module: String` struct-field on
7273 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
7274 // struct-field on `StateChange` are promoted to sibling JSON keys
7275 // at the same nesting level as the tag by the internally-tagged
7276 // serialization, and every downstream consumer that navigates the
7277 // serialized instruction blob to reach the payload (the caixa-core
7278 // reflection round-trip in `dispatcher_registration.rs` that
7279 // consults `variant_fields`, the sibling disjointness pin below,
7280 // any future wasm-operator upgrade-dispatch step consuming the
7281 // serialized instruction blob to route the per-module load /
7282 // soft-purge / purge action or the per-script state-change action)
7283 // reads through the same `&'static str`. Serialize one Module-
7284 // bearing variant and one Script-bearing variant, then pin that
7285 // each const's byte-sequence appears verbatim in the JSON emission
7286 // — a future accidental struct-field rebrand (`module: String` →
7287 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
7288 // either variant surfaces here as a build-time test failure at
7289 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
7290 // returning `None` far from the field-name drift's commit.
7291 //
7292 // Same "one canonical byte-string per typed axis" discipline the
7293 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
7294 // pin established on the peer tag-slot key axis on the same
7295 // enum — this pin extends the discipline onto the per-variant
7296 // data-field key axis, completing the `:upgrade-from :instructions`
7297 // variant-JSON dual (tag key + tag values + per-variant field keys)
7298 // fully into caixa-core.
7299 let module_sample = UpgradeInstruction::LoadModule {
7300 module: "hello-rio".into(),
7301 };
7302 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
7303 assert_eq!(
7304 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
7305 .and_then(|k| k.as_str()),
7306 Some("hello-rio"),
7307 "serialized {module_sample:?} must carry the lifted \
7308 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
7309 ({:?}) verbatim as the data-field JSON key holding the \
7310 module string (got: {v})",
7311 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7312 );
7313
7314 let script_sample = UpgradeInstruction::StateChange {
7315 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7316 };
7317 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
7318 assert_eq!(
7319 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
7320 .and_then(|k| k.as_str()),
7321 Some("lib/migrations/v01-to-v02.lisp"),
7322 "serialized {script_sample:?} must carry the lifted \
7323 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
7324 ({:?}) verbatim as the data-field JSON key holding the \
7325 script path (got: {v})",
7326 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7327 );
7328 }
7329
7330 #[test]
7331 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
7332 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
7333 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
7334 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
7335 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
7336 // whitespace / colons / dots) — the canonical shape a Rust
7337 // struct-field name promoted to a JSON key by serde takes on this
7338 // internally-tagged variant surface, matching the sibling
7339 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
7340 // shape. A future flip to a non-camelCase byte at either const
7341 // (an accidental `rename_all` regime interleave, or a struct-
7342 // field flip like `module` → `module_name`) surfaces here at
7343 // build time. Peer of
7344 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
7345 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
7346 // the sibling wire-key axes.
7347 for key in [
7348 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7349 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7350 ] {
7351 assert!(
7352 !key.is_empty(),
7353 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
7354 );
7355 let first = key.chars().next().unwrap();
7356 assert!(
7357 first.is_ascii_lowercase(),
7358 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
7359 byte (got {key:?}, leads with {first:?})",
7360 );
7361 assert!(
7362 key.chars().all(|c| c.is_ascii_alphanumeric()),
7363 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
7364 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7365 );
7366 }
7367 }
7368
7369 #[test]
7370 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
7371 // Cross-axis drift-detection pin: a future collapse of the two
7372 // canonical per-variant data-field byte-strings onto the same
7373 // value (e.g. an accidental copy-paste flip of
7374 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
7375 // `"module"`) would silently reroute every test-side probe on one
7376 // variant's payload onto the sibling variant's payload and pass
7377 // every propagation-probe test that expected only the stale
7378 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
7379 // on the sibling per-entry outer-container axis, and of
7380 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
7381 // on the sibling tag-slot key ↔ per-variant data-field key axis.
7382 let all = [
7383 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
7384 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
7385 ];
7386 for (i, a) in all.iter().enumerate() {
7387 for b in all.iter().skip(i + 1) {
7388 assert_ne!(
7389 a, b,
7390 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
7391 canonical byte-sequences — got `{a}` == `{b}`",
7392 );
7393 }
7394 }
7395 }
7396
7397 #[test]
7398 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
7399 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
7400 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
7401 // `kebab-case` hyphens, no `PascalCase` leading capital, no
7402 // whitespace / colons / dots) — the canonical shape the
7403 // `#[serde(rename_all = "camelCase")]` derive produces on
7404 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
7405 // at the derive surfaces both here (this test fails on the
7406 // stale-constant shape) and at
7407 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
7408 // (that test fails on the mismatch between const and derive).
7409 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
7410 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
7411 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7412 for key in [
7413 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7414 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7415 ] {
7416 assert!(
7417 !key.is_empty(),
7418 "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
7419 );
7420 let first = key.chars().next().unwrap();
7421 assert!(
7422 first.is_ascii_lowercase(),
7423 "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
7424 byte (got {key:?}, leads with {first:?})",
7425 );
7426 assert!(
7427 key.chars().all(|c| c.is_ascii_alphanumeric()),
7428 "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
7429 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
7430 );
7431 }
7432 }
7433
7434 #[test]
7435 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
7436 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
7437 // OTP-appup variant-tag axis: the five canonical author-facing
7438 // kebab-case labels (`:load-module` / `:state-change` /
7439 // `:soft-purge` / `:purge` / `:restart`) the substrate's
7440 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
7441 // from and every downstream consumer probes for verbatim. Same
7442 // scalar-value discipline the peer
7443 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
7444 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7445 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7446 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
7447 // (be40492) established for the sibling M2 / M3 / Supervisor
7448 // top-level and sub-slot author-facing-label axes. Fail-before-
7449 // pass-after locally verified by mutating
7450 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
7451 // pin fires as expected; restoring passes.
7452 //
7453 // A future OTP-lineage per-variant rebrand (e.g.
7454 // `:load-module` → `:load` matching Erlang's abbreviated
7455 // `code:load_module` name, `:state-change` → `:code-change`
7456 // matching Erlang's verbatim `code_change/3` callback,
7457 // `:soft-purge` → `:drain` matching a hypothetical operator-side
7458 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
7459 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
7460 // matching a supervisor-tree vocabulary alignment) lands as an
7461 // edit to exactly one const, and every consumer that reaches for
7462 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
7463 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
7464 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
7465 // `prior_cleanup_kind:` diagnostic field, the
7466 // [`LayoutError::UpgradeViolation`] `issue:` probe in
7467 // `layout.rs`) picks it up at build time rather than at runtime
7468 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
7469 // far from the rename's commit.
7470 assert_eq!(
7471 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7472 ":load-module"
7473 );
7474 assert_eq!(
7475 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7476 ":state-change"
7477 );
7478 assert_eq!(
7479 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7480 ":soft-purge"
7481 );
7482 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
7483 assert_eq!(
7484 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7485 ":restart"
7486 );
7487 }
7488
7489 #[test]
7490 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
7491 // Cross-arm drift-detection pin on the M2
7492 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
7493 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
7494 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
7495 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
7496 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
7497 // closed-set OTP-appup variant-tag pentad: a future collapse
7498 // of two canonical variant byte-strings onto the same value
7499 // (an accidental copy-paste flip of
7500 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7501 // to also read `":purge"`, a per-arm rebrand that lands one
7502 // const without touching its paired peer) would silently
7503 // reroute every downstream OTP-appup dispatcher's per-
7504 // instruction branch onto the sibling arm's runtime
7505 // behavior and pass every propagation-probe test that
7506 // expected only the stale arm's tag — a `:soft-purge`
7507 // instruction (drain-then-swap: existing callers finish
7508 // under the old module, new callers land on the new one)
7509 // would come up under the `:purge` reconcile branch
7510 // (drop-existing: every in-flight caller terminates
7511 // immediately) on every hot-upgrade cycle, so a rolling
7512 // module swap would silently downgrade to a hard cutover
7513 // against its declared appup discipline, with no field
7514 // naming the instruction-tag drift root cause. Every
7515 // [`crate::UpgradeError`] diagnostic that surfaces the tag
7516 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
7517 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
7518 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
7519 // `prior_cleanup_kind:` field, the
7520 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
7521 // `layout.rs`) would emit the sibling arm's stale bytes at
7522 // the operator's console, far from the source rebrand
7523 // commit. Peer of the sibling
7524 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
7525 // (09ffb2d) /
7526 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
7527 // (ccdf955) /
7528 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
7529 // (d739850) distinctness pins on the sibling OTP-shape /
7530 // caixa-kind closed-set typed-enum discriminator axes —
7531 // the fifth closed-set OTP-appup / typed-enum axis to
7532 // converge on the same
7533 // "pairwise-distinct-by-construction" discipline, and the
7534 // canonical companion to the peer
7535 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
7536 // (ff980bb) distinctness pin on the sibling internally-
7537 // tagged-JSON per-variant data-field-key axis (the tag axis
7538 // this pin covers vs. the data-field-key axis its peer
7539 // covers — two paired axes on the same
7540 // [`crate::UpgradeInstruction`] typed enum surface).
7541 //
7542 // Fail-before-pass-after locally verified by mutating
7543 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
7544 // to also read `":purge"` — this pin fires as expected;
7545 // restoring passes.
7546 let all = [
7547 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7548 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7549 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7550 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7551 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7552 ];
7553 for (i, a) in all.iter().enumerate() {
7554 for (j, b) in all.iter().enumerate() {
7555 if i != j {
7556 assert_ne!(
7557 a, b,
7558 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
7559 distinct — got duplicate {a:?} at indices {i} and {j}",
7560 );
7561 }
7562 }
7563 }
7564 }
7565
7566 #[test]
7567 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
7568 // Production-through-const pin: the five per-variant labels
7569 // [`UpgradeInstruction::lisp_form`] returns route through the
7570 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
7571 // so a future rebrand that reaches the const but not the
7572 // dispatch (or vice versa) surfaces here at build time rather
7573 // than at runtime as a downstream
7574 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
7575 // diagnostic drift far from the rename's commit. Mirror of the
7576 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
7577 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
7578 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
7579 // (f49c8b0) production-through-const pins on the sibling M3 /
7580 // M2 top-level slot axes.
7581 //
7582 // Fail-before-pass-after locally verified by mutating
7583 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
7584 // `":purge-drift"` — this pin fires as expected; restoring
7585 // passes.
7586 let cases: &[(UpgradeInstruction, &'static str)] = &[
7587 (
7588 UpgradeInstruction::LoadModule { module: "x".into() },
7589 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
7590 ),
7591 (
7592 UpgradeInstruction::StateChange {
7593 script: PathBuf::from("lib/m.lisp"),
7594 },
7595 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
7596 ),
7597 (
7598 UpgradeInstruction::SoftPurge {
7599 module: "x-old".into(),
7600 },
7601 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7602 ),
7603 (
7604 UpgradeInstruction::Purge {
7605 module: "x-old".into(),
7606 },
7607 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7608 ),
7609 (
7610 UpgradeInstruction::Restart,
7611 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
7612 ),
7613 ];
7614 for (instr, expected) in cases {
7615 assert_eq!(
7616 instr.lisp_form(),
7617 *expected,
7618 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
7619 const (expected {expected:?})",
7620 );
7621 }
7622 }
7623
7624 #[test]
7625 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
7626 // The canonical per-`:upgrade-from :instructions` OTP-appup
7627 // migration-instruction-list slice-shape pin:
7628 // [`UpgradeFromEntry::instructions`] must return the
7629 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
7630 // a `&[UpgradeInstruction]` slice-view over the same backing
7631 // buffer the raw `self.instructions.as_slice()` field access
7632 // borrows from, byte-equal across every representative fixture
7633 // in the accept-set — the empty slice (the "no-op upgrade" /
7634 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
7635 // field's own docstring names), the singleton slice on every
7636 // variant of the [`UpgradeInstruction`] arm-space
7637 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
7638 // `Restart` — the five OTP-appup runtime-primitive variants),
7639 // and multi-instruction cohorts (the canonical
7640 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
7641 // load + state-migration triad the module doc names as the
7642 // "runs the instructions in order" example).
7643 //
7644 // Pins against a future silent detour that returned
7645 // `&Vec<UpgradeInstruction>` (which would type-check but leak
7646 // the storage-side `Vec`'s grow/push/reserve surface no
7647 // consumer of the typed view reaches for), a fresh-allocated
7648 // `Vec<UpgradeInstruction>` copy (which would type-check via
7649 // a coercion but silently break every downstream caller that
7650 // relied on the slice sharing the backing buffer's identity),
7651 // or an out-of-order or length-drifted projection (which
7652 // would silently split the paired within-entry cross-
7653 // instruction ordering gates' inputs from the peer per-
7654 // instruction shape-check loop's input, one seven-gate cohort
7655 // silently drifting from the peer gate's actual traversal
7656 // input).
7657 //
7658 // Peer of the sibling
7659 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
7660 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
7661 // `:contratos` edge-list axis, extended onto the M2 per-
7662 // `:upgrade-from :instructions` migration-instruction-list
7663 // axis — the fifth `&[T]`-return byte-equal pin, closing the
7664 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
7665 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
7666 Vec::new(),
7667 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7668 vec![UpgradeInstruction::StateChange {
7669 script: PathBuf::from("lib/m.lisp"),
7670 }],
7671 vec![UpgradeInstruction::SoftPurge {
7672 module: "x-old".into(),
7673 }],
7674 vec![UpgradeInstruction::Purge {
7675 module: "x-old".into(),
7676 }],
7677 vec![UpgradeInstruction::Restart],
7678 vec![
7679 UpgradeInstruction::LoadModule { module: "x".into() },
7680 UpgradeInstruction::StateChange {
7681 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7682 },
7683 UpgradeInstruction::SoftPurge {
7684 module: "x-old".into(),
7685 },
7686 ],
7687 ];
7688 for instructions in fixtures {
7689 let e = UpgradeFromEntry {
7690 from: "0.1.0".into(),
7691 instructions: instructions.clone(),
7692 };
7693 assert_eq!(
7694 e.instructions(),
7695 e.instructions.as_slice(),
7696 "UpgradeFromEntry::instructions must project the raw \
7697 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
7698 `&[UpgradeInstruction]` slice-view over the same backing buffer \
7699 (fixture: {instructions:?})",
7700 );
7701 assert_eq!(
7702 e.instructions().len(),
7703 instructions.len(),
7704 "UpgradeFromEntry::instructions length must match the raw \
7705 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
7706 );
7707 }
7708 }
7709
7710 #[test]
7711 fn validate_reads_through_lifted_instructions_accessor() {
7712 // Three-consumer coherence pin on the lifted
7713 // [`UpgradeFromEntry::instructions`] slice-return accessor:
7714 // exercises three of the nine paired production consumers of
7715 // the per-`:upgrade-from :instructions` OTP-appup migration-
7716 // instruction-list surface through end-to-end validate() paths
7717 // that require the accessor to reach each of the fixture's
7718 // instructions.
7719 //
7720 // (1) The per-instruction shape-check fan-out
7721 // ([`UpgradeFromEntry::validate`]'s `for instr in
7722 // self.instructions()` loop): pass the well-formed load →
7723 // state-change → soft-purge triad — `validate()` must accept
7724 // it, which requires the accessor to project every entry so
7725 // each `instr.validate()` fires.
7726 //
7727 // (2) The within-entry state-change-ordering gate
7728 // ([`Self::validate_state_change_ordering`]): pass a
7729 // `((:state-change …))` singleton — `validate()` must return
7730 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
7731 // requires the accessor to reach the state-change so the
7732 // no-prior-load probe fires.
7733 //
7734 // (3) The within-entry per-module cleanup-singularity gate
7735 // ([`Self::validate_cleanup_singularity`]): pass a
7736 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
7737 // "x-old"))` cohort — `validate()` must return
7738 // [`UpgradeError::DuplicateCleanup`], which requires the
7739 // accessor to iterate the whole list so the second `SoftPurge`
7740 // matches the first via the `seen` set.
7741 //
7742 // Peer of the sibling
7743 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
7744 // three-consumer coherence pin on the M3 per-`:contratos`
7745 // edge-list axis, extended onto the M2 per-`:upgrade-from
7746 // :instructions` migration-instruction-list axis.
7747
7748 // (1) accept the well-formed OTP two-phase code-load triad
7749 let well_formed = entry(
7750 "0.1.0",
7751 vec![
7752 UpgradeInstruction::LoadModule { module: "x".into() },
7753 UpgradeInstruction::StateChange {
7754 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
7755 },
7756 UpgradeInstruction::SoftPurge {
7757 module: "x-old".into(),
7758 },
7759 ],
7760 );
7761 assert!(
7762 well_formed.validate().is_ok(),
7763 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
7764 the per-instruction shape-check fan-out requires the accessor to reach every entry"
7765 );
7766
7767 // (2) refuse a `((:state-change …))` singleton — the
7768 // state-change-without-prior-load gate must fire, which
7769 // requires the accessor to reach the single instruction.
7770 let no_prior_load = entry(
7771 "0.1.0",
7772 vec![UpgradeInstruction::StateChange {
7773 script: PathBuf::from("lib/m.lisp"),
7774 }],
7775 );
7776 match no_prior_load.validate() {
7777 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
7778 other => panic!(
7779 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
7780 — the within-entry state-change-ordering gate must reach the single \
7781 instruction through the lifted accessor; got: {other:?}"
7782 ),
7783 }
7784
7785 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
7786 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
7787 // singularity gate must fire on the second `SoftPurge`, which
7788 // requires the accessor to iterate the whole list.
7789 let duplicate_cleanup = entry(
7790 "0.1.0",
7791 vec![
7792 UpgradeInstruction::LoadModule { module: "x".into() },
7793 UpgradeInstruction::SoftPurge {
7794 module: "x-old".into(),
7795 },
7796 UpgradeInstruction::SoftPurge {
7797 module: "x-old".into(),
7798 },
7799 ],
7800 );
7801 match duplicate_cleanup.validate() {
7802 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
7803 assert_eq!(
7804 module, "x-old",
7805 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
7806 cleanup-singularity gate must iterate through the lifted accessor to \
7807 match the second SoftPurge against the first via the `seen` set"
7808 );
7809 }
7810 other => panic!(
7811 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
7812 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
7813 iterate the whole list through the lifted accessor; got: {other:?}"
7814 ),
7815 }
7816
7817 // Path::new suppresses the unused-import warning if the
7818 // outer module trims `use std::path::Path;` in a future edit.
7819 let _ = Path::new("lib/m.lisp");
7820 }
7821
7822 // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
7823 // macro definition (see the paired doc-block above the macro
7824 // definition) — every generated `<ctor>(from: &str, script: &Path)
7825 // -> Self` constructor folds the uniform `Self::<Variant> { from:
7826 // from.to_string(), script: script.to_path_buf() }` two-field
7827 // struct-literal onto one substrate primitive. The three per-variant
7828 // equivalence pins below (fail-before-pass-after by construction — a
7829 // byte-mismatched macro arm would trip its equivalence pin first)
7830 // lock each generated constructor to its struct-literal peer under
7831 // `PartialEq`, so every wire-up in
7832 // [`UpgradeFromEntry::validate_state_change_ordering`],
7833 // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
7834 // [`validate_state_change_on_state_change_callback`] on that
7835 // variant produces a byte-equal `UpgradeError` to the pre-lift
7836 // open-coded struct-literal. The cross-axis pin that follows
7837 // (non-default `(from, script)` pair) routes both constructor input
7838 // axes through `.to_string()` / `.to_path_buf()`, so the fold does
7839 // not silently collapse onto a fixed `from` / `script` value.
7840 //
7841 // Peer of the sibling `empty_child_version_ctor_matches_struct_
7842 // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
7843 // literal_wrap` / `child_supervises_self_ctor_matches_struct_
7844 // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
7845 // to_string` equivalence + cross-axis pins the sibling
7846 // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
7847 // established on the peer `SupervisorError` envelope; extended
7848 // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
7849 // two-slot envelope so every substrate-primitive ctor family in
7850 // caixa-core guarantees the same-shape fold every wire-up on the
7851 // family reads through one dispatch.
7852
7853 #[test]
7854 fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
7855 let from = "0.1.0";
7856 let script = Path::new("lib/migrations/v01-to-v02.lisp");
7857 assert_eq!(
7858 UpgradeError::state_change_without_prior_load(from, script),
7859 UpgradeError::StateChangeWithoutPriorLoad {
7860 from: from.to_string(),
7861 script: script.to_path_buf(),
7862 },
7863 "generated state_change_without_prior_load ctor must produce \
7864 byte-equal UpgradeError to the open-coded struct-literal \
7865 wrap on the same (&str, &Path) fixture",
7866 );
7867 }
7868
7869 #[test]
7870 fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
7871 let from = "0.1.0";
7872 let script = Path::new("lib/migrations/v01-to-v02.lisp");
7873 assert_eq!(
7874 UpgradeError::duplicate_state_change(from, script),
7875 UpgradeError::DuplicateStateChange {
7876 from: from.to_string(),
7877 script: script.to_path_buf(),
7878 },
7879 "generated duplicate_state_change ctor must produce byte-equal \
7880 UpgradeError to the open-coded struct-literal wrap on the \
7881 same (&str, &Path) fixture",
7882 );
7883 }
7884
7885 #[test]
7886 fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
7887 let from = "0.1.0";
7888 let script = Path::new("lib/migrations/v01-to-v02.lisp");
7889 assert_eq!(
7890 UpgradeError::state_change_without_on_state_change_callback(from, script),
7891 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7892 from: from.to_string(),
7893 script: script.to_path_buf(),
7894 },
7895 "generated state_change_without_on_state_change_callback ctor \
7896 must produce byte-equal UpgradeError to the open-coded \
7897 struct-literal wrap on the same (&str, &Path) fixture",
7898 );
7899 }
7900
7901 #[test]
7902 fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
7903 // Cross-axis pin: sweep both constructor input axes (`from:
7904 // &str`, `script: &Path`) through non-default fixtures against
7905 // every generated arm in the [`upgrade_from_script_ctors!`]
7906 // macro, so any wrapper-side lowercase / trim / truncate /
7907 // re-order / fixed-path substitution on the two-field
7908 // construction surfaces here rather than at a downstream
7909 // diagnostic-shape mismatch. Also exercises the `&Path`
7910 // parameter under both `&Path` (direct `Path::new`) and
7911 // `&PathBuf` (via Deref coercion), matching the two shapes the
7912 // three wire-up sites thread through — the ordering /
7913 // callback-declaration gates hand a `&PathBuf` from
7914 // `instr.declared_path()`; the uniqueness gate hands a `&Path`
7915 // from `script.as_path()`. Peer of the sibling
7916 // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
7917 // cross-axis pin on the peer `SupervisorError` `{ caixa:
7918 // String }` envelope.
7919 let from = "1.2.3-rc.1";
7920 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
7921 let script_ref: &Path = script_owned.as_path();
7922 for script in [script_ref, &script_owned as &Path] {
7923 assert_eq!(
7924 UpgradeError::state_change_without_prior_load(from, script),
7925 UpgradeError::StateChangeWithoutPriorLoad {
7926 from: from.to_string(),
7927 script: script.to_path_buf(),
7928 },
7929 );
7930 assert_eq!(
7931 UpgradeError::duplicate_state_change(from, script),
7932 UpgradeError::DuplicateStateChange {
7933 from: from.to_string(),
7934 script: script.to_path_buf(),
7935 },
7936 );
7937 assert_eq!(
7938 UpgradeError::state_change_without_on_state_change_callback(from, script),
7939 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7940 from: from.to_string(),
7941 script: script.to_path_buf(),
7942 },
7943 );
7944 }
7945 }
7946
7947 // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
7948 // macro definition (see the paired doc-block above the macro
7949 // definition) — every generated `<ctor>(script: &Path) -> Self`
7950 // constructor folds the uniform `Self::<Variant> { script:
7951 // script.to_path_buf() }` one-field struct-literal onto one substrate
7952 // primitive. The three per-variant equivalence pins below
7953 // (fail-before-pass-after by construction — a byte-mismatched macro
7954 // arm would trip its equivalence pin first) lock each generated
7955 // constructor to its struct-literal peer under `PartialEq`, so every
7956 // closure passed to [`crate::render::require_sandboxed_lisp_path`]
7957 // at [`UpgradeInstruction::validate`] on that variant produces a
7958 // byte-equal `UpgradeError` to the pre-lift open-coded
7959 // struct-literal. The cross-axis pin that follows (non-default
7960 // `script` path, both `&Path` and `&PathBuf` shapes) routes the
7961 // constructor input axis through `.to_path_buf()`, so the fold does
7962 // not silently collapse onto a fixed `script` value or drop the
7963 // Deref-coercion arm the wire-up sites depend on.
7964 //
7965 // Peer of the sibling
7966 // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
7967 // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
7968 // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
7969 // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
7970 // equivalence + cross-axis pins the sibling
7971 // [`upgrade_from_script_ctors!`] family (8e67041) established on the
7972 // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
7973 // extended here onto the `{ script: PathBuf }` one-slot envelope
7974 // shape so every substrate-primitive ctor family on `UpgradeError`
7975 // guarantees the same-shape fold every wire-up on the family reads
7976 // through one dispatch.
7977
7978 #[test]
7979 fn absolute_script_ctor_matches_struct_literal_wrap() {
7980 let script = Path::new("/etc/nope.lisp");
7981 assert_eq!(
7982 UpgradeError::absolute_script(script),
7983 UpgradeError::AbsoluteScript {
7984 script: script.to_path_buf(),
7985 },
7986 "generated absolute_script ctor must produce byte-equal \
7987 UpgradeError to the open-coded struct-literal wrap on the \
7988 same &Path fixture",
7989 );
7990 }
7991
7992 #[test]
7993 fn parent_escape_script_ctor_matches_struct_literal_wrap() {
7994 let script = Path::new("../oops.lisp");
7995 assert_eq!(
7996 UpgradeError::parent_escape_script(script),
7997 UpgradeError::ParentEscapeScript {
7998 script: script.to_path_buf(),
7999 },
8000 "generated parent_escape_script ctor must produce byte-equal \
8001 UpgradeError to the open-coded struct-literal wrap on the \
8002 same &Path fixture",
8003 );
8004 }
8005
8006 #[test]
8007 fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8008 let script = Path::new("lib/migrations.rs");
8009 assert_eq!(
8010 UpgradeError::non_lisp_extension_script(script),
8011 UpgradeError::NonLispExtensionScript {
8012 script: script.to_path_buf(),
8013 },
8014 "generated non_lisp_extension_script ctor must produce \
8015 byte-equal UpgradeError to the open-coded struct-literal \
8016 wrap on the same &Path fixture",
8017 );
8018 }
8019
8020 #[test]
8021 fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8022 // Cross-axis pin: sweep the constructor input axis (`script:
8023 // &Path`) through a non-default fixture against every generated
8024 // arm in the [`upgrade_script_only_ctors!`] macro, so any
8025 // wrapper-side lowercase / trim / truncate / re-order /
8026 // fixed-path substitution on the one-field construction
8027 // surfaces here rather than at a downstream diagnostic-shape
8028 // mismatch. Also exercises the `&Path` parameter under both
8029 // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
8030 // coercion), matching the shape the three closures at
8031 // [`UpgradeInstruction::validate`] thread through — the
8032 // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
8033 // each closure, so the Deref-coercion arm the ctor advertises
8034 // must actually route through `.to_path_buf()` and not
8035 // silently swap in a fixed path.
8036 //
8037 // Peer of the sibling
8038 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
8039 // cross-axis pin on the sibling `{ from, script }` two-slot
8040 // envelope shape.
8041 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8042 let script_ref: &Path = script_owned.as_path();
8043 for script in [script_ref, &script_owned as &Path] {
8044 assert_eq!(
8045 UpgradeError::absolute_script(script),
8046 UpgradeError::AbsoluteScript {
8047 script: script.to_path_buf(),
8048 },
8049 );
8050 assert_eq!(
8051 UpgradeError::parent_escape_script(script),
8052 UpgradeError::ParentEscapeScript {
8053 script: script.to_path_buf(),
8054 },
8055 );
8056 assert_eq!(
8057 UpgradeError::non_lisp_extension_script(script),
8058 UpgradeError::NonLispExtensionScript {
8059 script: script.to_path_buf(),
8060 },
8061 );
8062 }
8063 }
8064}