caixa_core/upgrade.rs
1//! Erlang/OTP-style appup — declarative upgrade instructions per
2//! prior caixa version. Composes with the `:behavior :on-state-change`
3//! callback to deliver state migration during hot upgrades.
4//!
5//! See `theory/INSPIRATIONS.md` §II.4 for the prior-art frame.
6//!
7//! ```lisp
8//! (defcaixa
9//! :nome "hello-rio"
10//! :versao "0.2.0"
11//! :upgrade-from
12//! ((:from "0.1.0"
13//! :instructions ((:load-module "hello-rio")
14//! (:state-change "lib/migrations/v01-to-v02.lisp")
15//! (:soft-purge "hello-rio-old")))
16//! (:from "0.1.5"
17//! :instructions ((:load-module "hello-rio")
18//! (:soft-purge "hello-rio-old")))))
19//! ```
20//!
21//! Each `(:from <prior>)` block declares the upgrade path *from* that
22//! version *to* the current `:versao`. wasm-operator picks the
23//! matching block at upgrade time, runs the instructions in order,
24//! and only swaps traffic to the new instance after all instructions
25//! succeed (transactional upgrade). On any failure, the current
26//! version stays load-bearing — a typed atomic upgrade.
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// One upgrade instruction. The set mirrors OTP's appup low-level
34/// instructions: enough to express every common upgrade pattern,
35/// few enough that the wasm-operator can implement each
36/// deterministically.
37#[derive(
38 Serialize,
39 Deserialize,
40 Debug,
41 Clone,
42 PartialEq,
43 Eq,
44 gen_platform::TypedDispatcher,
45 gen_platform::Discriminant,
46 gen_platform::IsVariant,
47)]
48#[serde(tag = "kind", rename_all = "kebab-case")]
49pub enum UpgradeInstruction {
50 /// Load a new wasm module alongside the current one — the analog
51 /// of OTP's `code:load_module/1`. Both versions remain in memory
52 /// after this instruction; in-flight requests stay on the old
53 /// version, new requests route to the new version.
54 LoadModule { module: String },
55
56 /// Run a state-migration tatara-lisp file. Receives the old state
57 /// + the prior version string; returns the new state. Analog of
58 /// `gen_server:code_change/3`.
59 StateChange { script: PathBuf },
60
61 /// Wait for in-flight requests on a named module to drain, then
62 /// GC it — the analog of `code:soft_purge/1`. Default cooldown is
63 /// 60s; longer-running requests block the upgrade.
64 SoftPurge { module: String },
65
66 /// Discard a named module immediately, without waiting for
67 /// drain — the analog of `code:purge/1`. Used when we don't
68 /// care about in-flight callers (cron, oneShot).
69 Purge { module: String },
70
71 /// Fall back to a full restart for this entry. Used when a typed
72 /// upgrade is impossible (e.g. wasm component world incompatible).
73 Restart,
74}
75
76// Fleet-wide dispatcher-catalog registration. UpgradeInstruction is
77// the OTP-style hot-upgrade primitive (load_module/code_change/
78// soft_purge/purge/restart) — the first NON-ADAPTER consumer of
79// gen-platform's typed-dispatcher catamorphism, satisfying the ★★
80// "two classes of consumer" promotion criterion from
81// theory/QUIRK-APPLIER.md §V.1.
82//
83// Operators query via:
84// gen dispatchers --from-catalog | jq '.[] | select(.label=="caixa.upgrade-instruction")'
85//
86// The substrate's lib/build/shared/fleet-catalog-coverage-test.nix
87// adds an assertion row for this label on the next snapshot refresh.
88gen_platform::register_dispatcher!("caixa.upgrade-instruction", UpgradeInstruction);
89
90/// One upgrade entry: the *prior* version we're upgrading from, plus
91/// the instruction sequence to execute.
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct UpgradeFromEntry {
95 /// Semver of the *prior* version. Authored as a literal string;
96 /// validated lazily by [`UpgradeFromEntry::validate`].
97 pub from: String,
98
99 /// Ordered list of instructions to execute. Empty list = "no-op
100 /// upgrade" (rare; usually means only documentation changed).
101 #[serde(default)]
102 pub instructions: Vec<UpgradeInstruction>,
103}
104
105impl UpgradeFromEntry {
106 /// Prior-versao semver-2 literal this entry declares an upgrade
107 /// path *from* — the string the OTP-shape `release_handler:install_release/1`
108 /// analog matches the running caixa's `:versao` against at hot-
109 /// upgrade dispatch time to pick this entry's `:instructions`
110 /// sequence. Returned byte-for-byte from the typed slot's own
111 /// `String` storage; no cloning, no re-parsing.
112 ///
113 /// The M2 companion of the closed M3 mesh-slot scalar-accessor
114 /// family — sibling in shape to [`crate::Membro::versao_requirement`]
115 /// (a40b0e3), [`crate::Membro::nome`] (4a32abf), and the
116 /// [`crate::WitContract::{source, destination, world_ref}`]
117 /// (7f0fd43 / 0804823) / [`crate::Entrada::{hostname, destination}`]
118 /// (11f3dfe / 6db982c) `&str` accessors already routing every
119 /// per-mesh-slot-atom scalar-value axis through one typed dispatch
120 /// on the substrate primitive — extended here onto the first per-
121 /// M2-slot scalar-value axis. Every downstream consumer of the
122 /// M2 `:upgrade-from :from` axis (the [`UpgradeFromEntry::validate`]
123 /// SemVer-2 parse gate, the [`validate_upgrade_from`] cross-entry
124 /// duplicate-detection re-parse assertion, the
125 /// [`validate_upgrade_from_against_versao`] precedence gate,
126 /// the [`validate_upgrade_from_against_behavior`] state-change-
127 /// callback coherence gate, every per-arm error variant carrying
128 /// the offending `:from` verbatim for `feira lint` rendering)
129 /// now reads through this one accessor rather than open-coding
130 /// `&self.from` / `&entry.from` / `self.from.clone()` /
131 /// `entry.from.clone()`.
132 ///
133 /// A future extension of the axis (an M4 typed `:from`-range slot
134 /// composing multiple prior versions into one entry, an operator-
135 /// side pre-parsed [`semver::Version`] cache the accessor could
136 /// materialize behind the same `&str` return contract, a per-
137 /// cluster `:placement`-scoped prior-versao overlay the
138 /// `caixa-operator` reconciles ahead of dispatch) migrates as a
139 /// single caixa-core edit rather than a coordinated rewrite of
140 /// the four validate-side call sites + every downstream error-
141 /// variant carrying `:from`.
142 #[must_use]
143 pub const fn prior_versao(&self) -> &str {
144 self.from.as_str()
145 }
146
147 /// Substrate-canonical per-`:upgrade-from :instructions`
148 /// OTP-appup migration-instruction-list slice-return accessor
149 /// every per-entry instructions-list reader keys off — returns
150 /// the author-declared `:instructions` list verbatim as a
151 /// `&[UpgradeInstruction]` slice-view over the same backing
152 /// buffer the raw `self.instructions.as_slice()` field access
153 /// borrows from. Non-optional: an empty slice is the load-bearing
154 /// "author declared `:instructions ()`" sentinel — the
155 /// `Vec<UpgradeInstruction>::default()`-produced empty tail the
156 /// [`UpgradeFromEntry::instructions`] field's own docstring already
157 /// names as the "no-op upgrade" shape (a metadata-only upgrade
158 /// entry — the operator's `:from`-match dispatch matches the entry
159 /// but runs no instructions, advancing straight to the "traffic
160 /// swap" step) and every peer within-entry cross-instruction gate
161 /// no-ops against without allocating a new `Vec` per gate.
162 ///
163 /// The `:upgrade-from :instructions` slot carries the per-`:from`
164 /// OTP-appup ordered instruction list the wasm-operator's hot-
165 /// upgrade dispatch materializes one per-instruction runtime
166 /// primitive from — the Erlang/OTP appup's per-`{from, to,
167 /// UpgradeInstructions, DowngradeInstructions}` entry's
168 /// `UpgradeInstructions` list (`code:load_module/1` /
169 /// `gen_server:code_change/3` / `code:soft_purge/1` /
170 /// `code:purge/1` / `restart_new_emulator` — see INSPIRATIONS
171 /// §II.4), projected through the tatara-lisp
172 /// `:upgrade-from ((:from … :instructions …))` author surface
173 /// onto a typed `Vec<UpgradeInstruction>` whose per-element
174 /// variant is [`UpgradeInstruction::LoadModule`] /
175 /// [`UpgradeInstruction::StateChange`] /
176 /// [`UpgradeInstruction::SoftPurge`] / [`UpgradeInstruction::Purge`]
177 /// / [`UpgradeInstruction::Restart`]. Every downstream consumer
178 /// that fans on the per-entry instruction list keys off this
179 /// slice (the [`UpgradeFromEntry::validate`] per-instruction
180 /// shape-check fan-out, the seven paired within-entry cross-
181 /// instruction gates [`Self::validate_restart_exclusive`] /
182 /// [`Self::validate_state_change_ordering`] /
183 /// [`Self::validate_purge_ordering`] /
184 /// [`Self::validate_state_change_before_cleanup`] /
185 /// [`Self::validate_load_singularity`] /
186 /// [`Self::validate_state_change_singularity`] /
187 /// [`Self::validate_cleanup_singularity`], the layout-side
188 /// [`crate::layout::StandardLayout`]'s per-`:state-change`
189 /// script-existence fan-out
190 /// ([`crate::layout::LayoutError::MissingEntry`]'s
191 /// `LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT` arm), the cross-slot
192 /// [`validate_upgrade_from_against_behavior`] gate's per-entry
193 /// `:state-change`-instruction detection loop, every future
194 /// wasm-operator (M2.5) per-`:from`-match hot-upgrade dispatch's
195 /// per-instruction runtime-primitive fan-out, every future M4
196 /// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-entry
197 /// upgrade-plan admission-webhook fan-out).
198 ///
199 /// Prior to this lift the `.instructions` `Vec<UpgradeInstruction>`
200 /// was accessed inline at nine production sites across
201 /// `caixa-core/src/upgrade.rs` and `caixa-core/src/layout.rs` —
202 /// the [`UpgradeFromEntry::validate`] per-instruction shape-check
203 /// fan-out (`for instr in &self.instructions`), the paired
204 /// [`Self::validate_restart_exclusive`] restart-count / other-kind
205 /// projections + `.len()` probe (three raw-access sites in one
206 /// gate), the [`Self::validate_state_change_ordering`] /
207 /// [`Self::validate_purge_ordering`] /
208 /// [`Self::validate_state_change_before_cleanup`] /
209 /// [`Self::validate_load_singularity`] /
210 /// [`Self::validate_state_change_singularity`] /
211 /// [`Self::validate_cleanup_singularity`] within-entry cross-
212 /// instruction gate traversal heads, the peer
213 /// [`validate_upgrade_from_against_behavior`] cross-slot
214 /// composition gate's `for instr in &entry.instructions`
215 /// per-entry `:state-change` detection loop, and the
216 /// [`crate::layout::StandardLayout`]-side
217 /// `for instr in &entry.instructions` per-`:state-change`
218 /// script-existence fan-out — nine open-coded field-accesses
219 /// that expressed no compile-time link back to the typed slot.
220 /// A future extension of the `:instructions` axis to a richer
221 /// author surface (a per-cluster overlay the operator pins
222 /// through a future `:upgrade-from :instructions-overrides` slot
223 /// so a canary cluster runs a `(:state-change …)` before the
224 /// production fleet does, a per-tenant instruction-list overlay
225 /// the M4 CR materializer resolves per-CR to inject cluster-
226 /// specific `(:soft-purge …)` cooldown adjustments, a promotion
227 /// of the plain `Vec<UpgradeInstruction>` to a richer
228 /// `{static, dynamic}` partition once virtual-actor-style
229 /// dynamic-instruction composition (an operator-derived
230 /// `(:load-module …)` sequence computed from the running
231 /// module set at upgrade time) comes into typed scope, a
232 /// per-instruction pre-condition scalar the future adaptive-
233 /// upgrade engine reads to bias per-instruction retry
234 /// strategy) would have had to be threaded through all nine
235 /// open-coded copies in lockstep or one consumer would silently
236 /// disagree with the peers on which instruction sequence a
237 /// given `:upgrade-from` entry resolves to — the per-
238 /// instruction shape-check reading the raw slot while the
239 /// paired within-entry ordering gates read an operator-resolved
240 /// slot would silently split the build-time per-entry gate
241 /// cohort from the layout-side script-existence gate + the
242 /// cross-slot behavior-composition gate + the runtime hot-
243 /// upgrade dispatch, a nine-consumer split across the seven
244 /// within-entry cross-instruction gates + the layout invariant +
245 /// the cross-slot composition gate far from the source
246 /// `caixa.lisp` with no field naming the instruction-sequence-
247 /// drift root cause. Lifting the resolution rule to a typed
248 /// method on the substrate primitive means every downstream
249 /// consumer of the per-entry OTP-appup instruction-list surface
250 /// reaches for exactly one typed dispatch — the resolver's
251 /// accept-set migrates as a unit on any future axis addition.
252 ///
253 /// Fifth slice-return (`&[T]`) accessor on any M2 or M3 typed
254 /// slot — sibling to the seed M2
255 /// [`crate::SupervisorSpec::children`] (bc92bce) `&[ChildSpec]`
256 /// accessor on the peer per-`:supervisor` static-child-list
257 /// `Vec`-carry axis, the M3 [`crate::Placement::clusters`]
258 /// (a6e18d7) `&[String]` accessor on the peer per-`:placement`
259 /// distribution-target-list `Vec`-carry axis, the M3
260 /// [`crate::AplicacaoSpec::membros`] (6c77e36) `&[Membro]`
261 /// accessor on the peer per-`:membros` node-list `Vec`-carry
262 /// axis, and the M3 [`crate::AplicacaoSpec::contratos`]
263 /// (0dcc926) `&[WitContract]` accessor on the peer per-
264 /// `:contratos` edge-list `Vec`-carry axis. This lift closes the
265 /// last unlifted `Vec`-carry axis on any M2 or M3 typed slot in
266 /// the substrate — the four peer axes named in the
267 /// [`crate::SupervisorSpec::children`] seed docstring
268 /// (`Placement::clusters`, `AplicacaoSpec::membros`,
269 /// `AplicacaoSpec::contratos`, `UpgradeFromEntry::instructions`)
270 /// are now all closed. The per-`UpgradeFromEntry` type carried
271 /// two axes: the scalar `Copy`-return
272 /// [`UpgradeFromEntry::prior_versao`] (75d27a8) on the
273 /// `:from` axis, and now the slice-return
274 /// [`UpgradeFromEntry::instructions`] on the peer
275 /// `:instructions` axis. Named `instructions()` to match the
276 /// storage field's name verbatim and the tatara-lisp
277 /// author-surface term (`:instructions`) the field's own
278 /// docstring already carries; the accessor's identity maps
279 /// onto the canonical OTP-appup vocabulary the
280 /// [`crate::upgrade`] module doc already reaches for ("runs
281 /// the instructions in order"). Returns `&[UpgradeInstruction]`
282 /// (not `&Vec<UpgradeInstruction>`) because every downstream
283 /// consumer of the instruction list treats it as a read-only
284 /// sequence — the slice-view is the narrowest borrow that
285 /// supports every present + roadmapped consumer (`.iter()`,
286 /// `.len()`, `.filter(...).count()`) without leaking the
287 /// backing `Vec`'s grow/push/reserve surface that no consumer
288 /// of the typed view reaches for (the storage-side `Vec`
289 /// remains reachable through the `pub instructions` field for
290 /// the mutation-carrying `Serialize`/`Deserialize` derive
291 /// round-trip and per-test fixture-mutation paths).
292 #[must_use]
293 pub const fn instructions(&self) -> &[UpgradeInstruction] {
294 self.instructions.as_slice()
295 }
296
297 /// Verify the `:from` field is a valid semver, every instruction's
298 /// typed shape, the within-entry `(:restart)`-exclusivity invariant
299 /// (an entry containing `(:restart)` must contain exactly one
300 /// `(:restart)` and nothing else — see
301 /// [`Self::validate_restart_exclusive`]), the within-entry
302 /// state-change-ordering invariant (every `(:state-change …)` must
303 /// be preceded by a `(:load-module …)` — see
304 /// [`Self::validate_state_change_ordering`]), the within-entry
305 /// purge-ordering invariant (every `(:soft-purge …)` / `(:purge …)`
306 /// must be preceded by a `(:load-module …)` — see
307 /// [`Self::validate_purge_ordering`]), the within-entry
308 /// state-change-before-cleanup ordering invariant (no
309 /// `(:state-change …)` may appear after any `(:soft-purge …)` /
310 /// `(:purge …)` — see
311 /// [`Self::validate_state_change_before_cleanup`]), the within-
312 /// entry load-singularity invariant (no module appears as the
313 /// target of `(:load-module …)` more than once — see
314 /// [`Self::validate_load_singularity`]), the within-entry
315 /// state-change-singularity invariant (no script appears as the
316 /// target of `(:state-change …)` more than once — see
317 /// [`Self::validate_state_change_singularity`]), and the within-
318 /// entry cleanup-singularity invariant (no module appears as the
319 /// target of `(:soft-purge …)` or `(:purge …)` more than once
320 /// total — see [`Self::validate_cleanup_singularity`]).
321 pub fn validate(&self) -> Result<(), UpgradeError> {
322 use semver::Version;
323 Version::parse(self.prior_versao())
324 .map_err(|e| UpgradeError::from_invalid(self.prior_versao(), &e.to_string()))?;
325 // Per-instruction typed shape: kind-tagged `:module` /
326 // `:script` value-shape gates fire here, *before* the
327 // within-entry restart-exclusivity gate below — so a
328 // malformed-shape diagnostic on a Module/Script-bearing
329 // instruction surfaces with its narrower self-locating
330 // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
331 // `AbsoluteScript`, `ParentEscapeScript`) rather than
332 // collapsing two unrelated authoring errors into a single
333 // exclusivity diagnostic. Same empty-first cascade discipline
334 // every peer DNS-1123 / path-shape gate inside this module
335 // uses (`validate_module`'s ModuleEmpty arm precedes the
336 // DNS-1123 predicate; `validate` on `StateChange` consults
337 // the lifted `is_sandboxed_relative_path` shape gate first).
338 // Route the per-instruction shape-check fan-out through the
339 // lifted [`Self::instructions`] slice-return accessor rather
340 // than the raw `self.instructions` field access — first of
341 // nine paired production consumers of the per-`:upgrade-from
342 // :instructions` OTP-appup migration-instruction-list surface
343 // that now key off exactly one typed dispatch on the substrate
344 // primitive.
345 for instr in self.instructions() {
346 instr.validate()?;
347 }
348 self.validate_restart_exclusive()?;
349 self.validate_state_change_ordering()?;
350 self.validate_purge_ordering()?;
351 self.validate_state_change_before_cleanup()?;
352 self.validate_load_singularity()?;
353 self.validate_state_change_singularity()?;
354 self.validate_cleanup_singularity()?;
355 Ok(())
356 }
357
358 /// Reject `:upgrade-from :instructions` lists that carry
359 /// `(:restart)` alongside any other instruction, or that carry
360 /// more than one `(:restart)`. The valid Restart-bearing shape is
361 /// exactly `((:restart))` — a single `Restart` as the entry's
362 /// whole instructions list.
363 ///
364 /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
365 /// is the *fallback* for an entry whose typed upgrade is
366 /// impossible (wasm component-model world incompatibility,
367 /// irreversible state shape change). The fallback is terminal by
368 /// construction: the operator restarts the pod and the new version
369 /// comes up fresh, so any other instructions in the same entry
370 /// are dead code in both directions — either the typed sequence
371 /// would have succeeded and `(:restart)` is unreached, or it
372 /// wouldn't and the typed instructions are dead because the
373 /// operator restarts anyway. Two canonical authoring footguns
374 /// close here:
375 ///
376 /// - `((:load-module …) (:state-change …) (:restart))` — the
377 /// "I'll try the typed path *then* restart anyway" footgun.
378 /// There is no coherent OTP-shaped semantic for this: if the
379 /// typed sequence succeeds, the trailing restart discards the
380 /// work that just succeeded (defeating the whole point of
381 /// declaring it); if it fails, the restart is never reached
382 /// because the entry already failed.
383 /// - `((:restart) (:restart))` — multiple `Restart` variants in
384 /// one entry. The fallback is a single semantic; repeating it
385 /// is at best redundant, at worst suggests the author thought
386 /// the second one would re-trigger after the first.
387 ///
388 /// Same within-entry exclusivity discipline OTP's `relup` enforces
389 /// at the `restart_new_emulator | restart_emulator` instruction
390 /// boundary — those instructions are terminal in the upgrade
391 /// script (`systools(3)` rejects sequences that continue past
392 /// them); pleme-io lifts the same shape to a build-time gate,
393 /// matching the CAIXA-SDLC §III "build errors, not runtime
394 /// surprises" frame.
395 ///
396 /// Same within-entry cross-instruction discipline the
397 /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
398 /// shard-key partition (934bc58) and
399 /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
400 /// precedence partition (de7ab1a) apply on cross-slot axes — now
401 /// extended onto the first within-list cross-instruction axis on
402 /// the `:upgrade-from` typed slot.
403 fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
404 // Route the paired restart-count / instructions-len / other-
405 // kind projections through the lifted [`Self::instructions`]
406 // slice-return accessor rather than the raw `self.instructions`
407 // field access — three raw-access sites in one gate collapse
408 // onto exactly one typed dispatch on the substrate primitive.
409 //
410 // The paired positive / negated `Self::Restart` arm-discriminator
411 // predicates route through the `gen_platform::IsVariant`
412 // derive-generated [`UpgradeInstruction::is_restart`] rather than
413 // the raw `matches!(i, UpgradeInstruction::Restart)` /
414 // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
415 // matches — same closed-set-typed-enum arm-discriminator dispatch
416 // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
417 // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
418 // / `!= CaixaKind::X` production sites in the substrate's own
419 // layout invariant verifier + typed-view projection gates,
420 // extended here onto the last unlifted `matches!`-based
421 // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
422 // typed enum. A future sixth `UpgradeInstruction` arm (an
423 // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
424 // wasm-operator's hot-upgrade runtime could adopt to bracket the
425 // typed instruction sequence against a per-cluster readiness
426 // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
427 // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
428 // materializer could resolve per-CR) migrates as a single
429 // enum-declaration edit — the derive auto-generates the paired
430 // `.is_<new_arm>()` predicate; every consumer inherits the new
431 // arm on the next re-derive, rather than the two `matches!` sites
432 // here having to be threaded through in lockstep.
433 let instructions = self.instructions();
434 let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
435 if restart_count == 0 {
436 return Ok(());
437 }
438 if restart_count == 1 && instructions.len() == 1 {
439 return Ok(());
440 }
441 let other_kinds: Vec<&'static str> = instructions
442 .iter()
443 .filter(|i| !i.is_restart())
444 .map(UpgradeInstruction::lisp_form)
445 .collect();
446 Err(UpgradeError::restart_not_exclusive(
447 self.prior_versao(),
448 restart_count,
449 other_kinds,
450 ))
451 }
452
453 /// Reject an entry whose `(:state-change …)` is not preceded by a
454 /// `(:load-module …)` in the same `:instructions` list.
455 ///
456 /// `StateChange` is the `gen_server:code_change/3` analog
457 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
458 /// it runs the migration script that folds the *old* state into the
459 /// shape the *new* code expects. In OTP, `code_change/3` is invoked
460 /// in the context of the newly-loaded code — `release_handler`
461 /// always loads the new module before running the advanced update
462 /// that triggers the callback. caixa decomposes that into two
463 /// explicit instructions (`LoadModule` brings the new version up
464 /// "alongside the current one"; `StateChange` migrates the state),
465 /// and the module doc pins that the operator "runs the instructions
466 /// in order" and only swaps traffic after all succeed. So a
467 /// `:state-change` with no preceding `:load-module` migrates state
468 /// into code that was never loaded — the migration script runs while
469 /// the only resident version is still the *old* one, which expects
470 /// the *old* state. Two authoring footguns close here:
471 ///
472 /// - `((:state-change "…"))` — the "I wrote the migration but
473 /// forgot to load the new module" footgun. The new code that
474 /// defines the new state representation (and that the migration
475 /// output is destined for) never comes up; the operator runs
476 /// the script against the old code and either no-ops or corrupts
477 /// live state.
478 /// - `((:state-change "…") (:load-module "…"))` — the
479 /// right-instructions-wrong-order footgun. Because the operator
480 /// executes in declared order, the migration runs *before* the
481 /// new code is resident, then the load brings up code expecting
482 /// already-migrated state that the just-run script produced
483 /// against the old version's shape. The canonical order is
484 /// `(:load-module …) (:state-change …) (:soft-purge …)`
485 /// (module doc example).
486 ///
487 /// Same within-entry cross-instruction discipline as
488 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
489 /// exclusivity gate it runs beside): both reject an
490 /// `:instructions` list whose instructions are individually
491 /// well-shaped but jointly incoherent, at the typed build surface
492 /// rather than as a runtime surprise. Runs *after*
493 /// `validate_restart_exclusive` so a `((:state-change …)
494 /// (:restart))` shape still surfaces the more-fundamental
495 /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
496 /// alone, so no Restart-bearing entry reaches this gate carrying a
497 /// `StateChange`).
498 fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
499 // Route the per-instruction load-family arm-discriminator through
500 // the `gen_platform::IsVariant`-derive-generated
501 // [`UpgradeInstruction::is_load_module`] predicate and the
502 // per-instruction migration-family `:script` scalar projection
503 // through the sibling lifted [`UpgradeInstruction::declared_path`]
504 // `Option<&PathBuf>` accessor rather than the raw two-arm
505 // `match instr { UpgradeInstruction::LoadModule { .. } =>
506 // loaded = true, UpgradeInstruction::StateChange { script } if
507 // !loaded => …, _ => {} }` open-coded pattern-match — closes the
508 // last unlifted `match`-shaped per-arm-hand-rolled load-family
509 // arm-discriminator + migration-family script-projection pair
510 // inside `impl UpgradeFromEntry`. Sibling of the peer
511 // [`Self::validate_purge_ordering`] (580d0f1) routing already
512 // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
513 // load → cleanup ordering axis, the peer
514 // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
515 // onto the [`UpgradeInstruction::is_load_module`] +
516 // [`UpgradeInstruction::declared_module`] pair on the singularity
517 // axis, and the peer [`Self::validate_state_change_singularity`]
518 // routing already lifted onto the sibling
519 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
520 // accessor on the migration-family script-projection axis — both
521 // ordering-gate load-family sticky-latch dispatches now key off
522 // exactly one typed dispatch on the substrate primitive for
523 // their load-family arm-discriminator, and both migration-family
524 // projection sites (this ordering gate + the peer singularity
525 // gate) now key off exactly one typed dispatch on the substrate
526 // primitive for the `:script`-carrying axis. A future sixth arm
527 // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
528 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
529 // `CanaryTraffic` split-traffic variant the M4 CR materializer
530 // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
531 // enum-declaration edit through the derive rather than a
532 // coordinated rewrite of every ordering / singularity gate's
533 // per-arm hand-rolled pattern-match. Byte-identity of this
534 // dispatch against the pre-lift `match` shape is pinned by
535 // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
536 let mut loaded = false;
537 for instr in self.instructions() {
538 if instr.is_load_module() {
539 loaded = true;
540 } else if !loaded && let Some(script) = instr.declared_path() {
541 return Err(UpgradeError::state_change_without_prior_load(
542 self.prior_versao(),
543 script,
544 ));
545 }
546 }
547 Ok(())
548 }
549
550 /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
551 /// preceded by a `(:load-module …)` in the same `:instructions` list.
552 ///
553 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
554 /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
555 /// *old* module from memory after the new one is resident. OTP's
556 /// two-phase code load is `code:load_module/1` *then*
557 /// `code:soft_purge/1` — load the new version alongside the old
558 /// (both in memory, new requests route to new), then purge the old
559 /// after in-flight callers drain. caixa decomposes that into two
560 /// explicit instructions (`LoadModule` brings the new version up
561 /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
562 /// doc; `SoftPurge` "waits for in-flight requests on a named module
563 /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
564 /// and the module doc pins that the operator "runs the instructions
565 /// in order". So a `:soft-purge` / `:purge` with no preceding
566 /// `:load-module` purges old code while the only resident version is
567 /// still the *same* old code, leaving the upgrade entry asking the
568 /// operator to drain or discard the live module with no replacement
569 /// resident. Two authoring footguns close here:
570 ///
571 /// - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
572 /// cleanup but forgot to load the new module" footgun. The new
573 /// code never comes up alongside; the operator either drains the
574 /// old version to nothing (`SoftPurge`) or discards it outright
575 /// mid-request (`Purge`), with no replacement to route in-flight
576 /// or future requests to.
577 /// - `((:soft-purge "…") (:load-module "…"))` /
578 /// `((:purge "…") (:load-module "…"))` — the right-instructions-
579 /// wrong-order footgun. Because the operator executes in declared
580 /// order, the cleanup runs *before* the new code is resident,
581 /// leaving a window during which neither version is available;
582 /// the canonical order is `(:load-module …) (:state-change …)
583 /// (:soft-purge …)` (module doc example).
584 ///
585 /// Same within-entry cross-instruction discipline as
586 /// [`Self::validate_state_change_ordering`] (the `:state-change`-
587 /// ordering gate it runs beside): both close the same load-before-X
588 /// post-condition on the OTP appup ordering contract, now extending
589 /// the typed coverage from "new code resident before its state
590 /// migration runs" to "new code resident before the old code is
591 /// drained or discarded" — the second half of OTP's two-phase code
592 /// load. Runs *after* `validate_state_change_ordering` so an entry
593 /// like `((:state-change …) (:soft-purge …))` surfaces the more-
594 /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
595 /// are load-less, but state-change is the load-bearing semantic — the
596 /// purge is meaningless either way without a preceding load, so the
597 /// author should see the migration-side diagnostic first).
598 fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
599 let mut loaded = false;
600 for instr in self.instructions() {
601 // Route the per-instruction cleanup-family arm-discriminator
602 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
603 // predicate rather than the raw
604 // `UpgradeInstruction::SoftPurge { module } |
605 // UpgradeInstruction::Purge { module }` open-coded per-arm
606 // union pattern-match — the first of three within-entry cross-
607 // instruction cleanup-facing gates now keys off exactly one
608 // typed dispatch on the substrate primitive, so any future
609 // fifth cleanup-shaped variant (a `Discard` variant the
610 // `code:delete/1` peer inspires) added to
611 // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
612 // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
613 // through the accessor's one body. The paired cleanup-arm
614 // `:module` scalar is routed through the sibling
615 // [`UpgradeInstruction::declared_module`] accessor rather than
616 // the raw pattern-bound `module` binding — same substrate-
617 // primitive-owns-the-scalar discipline every peer
618 // per-`UpgradeInstruction` scalar-value axis already routes
619 // through, with the `is_cleanup`-implies-`declared_module`-is-
620 // `Some` composition pin at
621 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
622 // making the `.expect(…)` structurally infallible at build
623 // time. Peer of the sibling
624 // [`UpgradeFromEntry::validate_restart_exclusive`]
625 // paired positive / negated
626 // [`UpgradeInstruction::is_restart`] routing (915a934) on the
627 // per-arm terminal-fallback partition — same closed-set-typed-
628 // enum arm-discriminator dispatch discipline extended from
629 // the single-arm terminal-fallback family onto the two-arm
630 // cleanup family here.
631 //
632 // Route the paired load-family arm-discriminator through the
633 // `gen_platform::IsVariant`-derive-generated
634 // [`UpgradeInstruction::is_load_module`] predicate rather than
635 // the raw `matches!(instr, UpgradeInstruction::LoadModule
636 // { .. })` open-coded pattern-match — closes the last
637 // unlifted `matches!`-based per-variant arm-discriminator
638 // axis on the [`UpgradeInstruction`] closed-set typed enum,
639 // sibling of the [`UpgradeInstruction::is_restart`] terminal-
640 // fallback routing (915a934) and the
641 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
642 // routing (0bc469f) that already lifted the paired
643 // arm-discriminator sites in this method. Every arm-family
644 // partition the gate keys off — load-family (`LoadModule`),
645 // cleanup-family (`SoftPurge | Purge`), terminal-fallback
646 // (`Restart`) — now consults exactly one typed dispatch on
647 // the substrate primitive, so a future sixth arm added to
648 // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
649 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
650 // a `CanaryTraffic` split-traffic variant the M4 CR
651 // materializer could resolve per-CR — INSPIRATIONS §II.4)
652 // migrates as a single enum-declaration edit through the
653 // derive rather than a scattered per-consumer rewrite. The
654 // partition invariant is pinned by
655 // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
656 // and the byte-identity of this dispatch against the pre-lift
657 // `matches!` pattern by
658 // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
659 if instr.is_load_module() {
660 loaded = true;
661 } else if instr.is_cleanup() && !loaded {
662 return Err(UpgradeError::purge_without_prior_load(
663 self.prior_versao(),
664 instr.lisp_form(),
665 instr
666 .declared_module()
667 .expect("is_cleanup() implies declared_module() is Some"),
668 ));
669 }
670 }
671 Ok(())
672 }
673
674 /// Reject an entry whose `(:state-change …)` appears after any
675 /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
676 /// list — completing the canonical OTP appup `code:load_module/1`
677 /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
678 /// chain on the typed `:upgrade-from` slot.
679 ///
680 /// `StateChange` is the `gen_server:code_change/3` analog
681 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
682 /// verbatim: "State migration uses `gen_server:code_change/3` …
683 /// migrate state from v0.1.0 shape to current shape"). The
684 /// callback's input is the *prior* version's state shape, which
685 /// only exists while the prior code is still resident — the running
686 /// `gen_server` processes hold the v0.1.0 state, and the operator's
687 /// dispatch invokes `code_change/3` to fold that state into the
688 /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
689 /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
690 /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
691 /// *old* module after the new one is resident. The operator runs
692 /// instructions in declared order (module doc), so a cleanup ahead
693 /// of a state-change discards the prior code before the migration
694 /// fold runs against the state it held — the canonical OTP error
695 /// mode "`code_change/3` invoked on a purged module" the
696 /// `release_handler` enforces by always emitting the migration
697 /// callback before the soft-purge step.
698 ///
699 /// `systools`-generated `.relup` files always emit `code_change`
700 /// before `soft_purge` for this reason; the appup cookbook's
701 /// canonical pattern (`[{load_module, m}, {update, m, soft},
702 /// {soft_purge, m}]`) places the migration-triggering `update`
703 /// strictly between the load and the cleanup. The caixa module
704 /// doc pins the same canonical order verbatim — `(:load-module
705 /// …) (:state-change …) (:soft-purge …)` — and this gate makes
706 /// that ordering a structural property at build time. Three
707 /// authoring footguns close here:
708 ///
709 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
710 /// "lib/m.lisp"))` — the right-instructions-wrong-order
711 /// footgun on the migrate ↔ cleanup axis. Because the operator
712 /// executes in declared order, the cleanup drains the v0.1.0
713 /// module to nothing before the migration callback runs, and
714 /// the script either no-ops (no v0.1.0 state left to fold) or
715 /// crashes (`code_change/3` invoked on an unloaded version).
716 /// The canonical order is `(:load-module …) (:state-change
717 /// …) (:soft-purge …)` (module doc example).
718 /// - `((:load-module "x") (:purge "x-old") (:state-change
719 /// "lib/m.lisp"))` — same shape on the more catastrophic
720 /// `:purge` variant. The immediate-discard semantic destroys
721 /// v0.1.0 state mid-request; the trailing migration script
722 /// has nothing to fold from and the `gen_server` processes that
723 /// held v0.1.0 state were killed by the `:purge`.
724 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
725 /// "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
726 /// sandwiched between two cleanups" footgun. The first
727 /// cleanup discards v0.1.0; the migration runs against
728 /// drained state; the second cleanup is irrelevant. The first
729 /// cleanup → state-change boundary is the load-bearing defect
730 /// surfaced.
731 ///
732 /// Same within-entry cross-instruction discipline as
733 /// [`Self::validate_state_change_ordering`] (the load → state-
734 /// change ordering gate it runs after) and
735 /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
736 /// gate it runs after): all three close one boundary of the OTP
737 /// canonical sequence `code:load_module/1` →
738 /// `gen_server:code_change/3` → `code:soft_purge/1`. The
739 /// state-change-ordering gate closes the load → migrate boundary;
740 /// the purge-ordering gate closes the load → cleanup boundary;
741 /// this gate closes the migrate → cleanup boundary, completing
742 /// the typed coverage of the canonical sequence. Runs *after*
743 /// [`Self::validate_purge_ordering`] (and therefore after
744 /// [`Self::validate_state_change_ordering`]) so an entry like
745 /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
746 /// which violates *both* the purge-without-load gate and this
747 /// state-change-after-cleanup gate — surfaces the more-
748 /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
749 /// defect is load-bearing; once a coherent `(:load-module …)`
750 /// precedes both, the migrate ↔ cleanup ordering becomes the
751 /// next live defect). Runs *before* the per-instruction-class
752 /// singularity gates ([`Self::validate_load_singularity`],
753 /// [`Self::validate_state_change_singularity`],
754 /// [`Self::validate_cleanup_singularity`]) so an entry like
755 /// `((:load-module "x") (:soft-purge "x-old") (:state-change
756 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
757 /// *both* this ordering gate and the state-change-singularity
758 /// gate — surfaces the ordering defect first; the canonical
759 /// "ordering before singularity" precedence the peer
760 /// `validate_state_change_ordering` / `validate_purge_ordering`
761 /// gates already establish.
762 ///
763 /// Detection: linear scan of the instructions list with a
764 /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
765 /// recording the first cleanup encountered; on any subsequent
766 /// `StateChange` the gate fires with the script + the prior
767 /// cleanup's kind/module. Diagnostic-order pin: the first
768 /// colliding state-change-after-cleanup pair surfaces, not the
769 /// last — mirrors every peer ordering gate's first-collision
770 /// posture ([`Self::validate_state_change_ordering`] returns on
771 /// the first `StateChange` without prior load,
772 /// [`Self::validate_purge_ordering`] on the first cleanup
773 /// without prior load).
774 fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
775 let mut prior_cleanup: Option<(&str, &'static str)> = None;
776 for instr in self.instructions() {
777 // Route the per-instruction cleanup-family arm-discriminator
778 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
779 // predicate rather than the raw
780 // `UpgradeInstruction::SoftPurge { module } |
781 // UpgradeInstruction::Purge { module }` open-coded per-arm
782 // union pattern-match — the second of three within-entry
783 // cross-instruction cleanup-facing gates the peer
784 // [`Self::validate_purge_ordering`] routing already lifted;
785 // both now key off exactly one typed dispatch on the substrate
786 // primitive so the "which arms belong to the cleanup family"
787 // question resolves at exactly one caixa-core edit. The
788 // sticky-once latch's `:module` scalar is routed through the
789 // sibling [`UpgradeInstruction::declared_module`] accessor
790 // rather than the raw pattern-bound `module.as_str()`
791 // projection, with the `is_cleanup`-implies-`declared_module`-
792 // is-`Some` composition pin at
793 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
794 // making the `.expect(…)` structurally infallible at build
795 // time.
796 if instr.is_cleanup() && prior_cleanup.is_none() {
797 prior_cleanup = Some((
798 instr
799 .declared_module()
800 .expect("is_cleanup() implies declared_module() is Some"),
801 instr.lisp_form(),
802 ));
803 } else if let Some(script) = instr.declared_path()
804 && let Some((prior_module, prior_kind)) = prior_cleanup
805 {
806 // Route the per-instruction `StateChange`-arm script-path
807 // projection through the sibling lifted
808 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
809 // accessor rather than the raw
810 // `if let UpgradeInstruction::StateChange { script } = instr`
811 // open-coded pattern-match — the last unlifted per-
812 // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
813 // inside `impl UpgradeFromEntry`, sibling to the four peer
814 // per-`UpgradeInstruction` consumers already routed through
815 // the accessor: [`UpgradeInstruction::validate`]'s per-
816 // `StateChange` sandbox-path fan-out, the layout-side per-
817 // `StateChange` script-existence fan-out at
818 // [`crate::layout::StandardLayout::verify`]
819 // (caixa-core/src/layout.rs:1058), the within-entry
820 // [`UpgradeFromEntry::validate_state_change_singularity`]
821 // per-`StateChange` script-projection fan-out, and the
822 // cross-slot
823 // [`validate_upgrade_from_against_behavior`]
824 // per-`StateChange` detection loop. Byte-equal today
825 // (`declared_path` returns `Some(script)` iff the
826 // instruction is [`UpgradeInstruction::StateChange`], per
827 // the sibling `declared_path_only_for_state_change` pin),
828 // so a state-change-after-cleanup surfaces
829 // `StateChangeAfterCleanup` byte-identical to the pattern-
830 // match shape. Any future accessor extension that promotes
831 // an additional variant onto the `PathBuf`-carrying axis
832 // reaches this gate through one caixa-core edit rather
833 // than a coordinated rewrite of five call sites — the
834 // migrate→cleanup ordering discipline extends to the
835 // promoted variant by construction. Same "one typed
836 // dispatch on the substrate primitive, thin projections at
837 // each consumer" trajectory the sibling
838 // [`UpgradeInstruction::declared_module`] `String`-axis
839 // per-variant unifier already established.
840 return Err(UpgradeError::state_change_after_cleanup(
841 self.prior_versao(),
842 script,
843 prior_kind,
844 prior_module,
845 ));
846 }
847 }
848 Ok(())
849 }
850
851 /// Reject an entry whose `:instructions` list names the same module
852 /// as the target of more than one cleanup instruction (`:soft-purge`
853 /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
854 /// module) axis, narrowed to the cleanup class.
855 ///
856 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
857 /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
858 /// `code:load_module/1` — load v2 alongside v1 … 2.
859 /// `code:soft_purge/1` — wait until no process is running v1, then
860 /// discard. (`code:purge/1` kills v1 immediately if you don't
861 /// care.)"). The author picks *one* cleanup semantic per old
862 /// module — `:soft-purge` (preferred: waits for in-flight callers
863 /// to drain) or `:purge` (when the drain isn't possible) — and the
864 /// operator runs that one in declared order alongside any other
865 /// distinct-module cleanups. systools-generated `.relup` files
866 /// always emit at most one purge per module for this reason; any
867 /// retry / fallback decision is the operator's job on
868 /// instruction failure, not authored into the entry. Three
869 /// authoring footguns close here:
870 ///
871 /// - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
872 /// — the "I copy-pasted the cleanup line twice" footgun. The
873 /// second `:soft-purge` is a no-op (the module is already gone
874 /// after the first drain-and-discard) or undefined depending
875 /// on the operator's handling of a non-resident-module purge
876 /// request; either way the second instruction carries no
877 /// observable semantic, far from the source caixa.lisp.
878 /// - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
879 /// — the "soft-then-hard fallback" footgun. The author wrote
880 /// "drain, and if drain didn't clean it up, force-discard",
881 /// but the operator runs instructions unconditionally in
882 /// declared order — the `:purge` fires whether the
883 /// `:soft-purge` already discarded the module or not, so the
884 /// fallback semantic the author imagined is missing; the
885 /// pair is incoherent (drain *and* force-discard semantics
886 /// on one module is two contradictory dispositions). The
887 /// operator's failure-handling surface is its own
888 /// responsibility: if `:soft-purge` doesn't drain within its
889 /// cooldown the operator escalates, not the author's entry.
890 /// - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
891 /// — same shape on the reversed ordering. The `:purge`
892 /// discards immediately; the trailing `:soft-purge` has no
893 /// module to drain.
894 ///
895 /// Same within-entry exclusivity discipline as
896 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
897 /// exclusivity gate it joins on the per-module cleanup axis): both
898 /// reject an `:instructions` list whose instructions are
899 /// individually well-shaped but jointly incoherent on a chosen
900 /// semantic axis (restart-fallback for the whole entry there;
901 /// cleanup-semantic for one module here), at the typed build
902 /// surface rather than as a runtime surprise. Runs *after*
903 /// [`Self::validate_purge_ordering`] (the load-before-cleanup
904 /// ordering gate) so an entry like `((:soft-purge "x-old")
905 /// (:soft-purge "x-old"))` surfaces the more-fundamental
906 /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
907 /// the missing-load defect is the load-bearing one — the duplicate
908 /// is meaningless either way without the preceding load).
909 ///
910 /// Same set-not-multiset discipline applied to every peer
911 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
912 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
913 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
914 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
915 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
916 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
917 /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
918 /// Each closes the same authoring footgun: a Vec authoring surface
919 /// that silently accepts duplicate entries and renders the "second
920 /// wins" (or "operator processes both, second is a no-op or
921 /// errors") shape downstream, far from the source caixa.lisp.
922 /// This gate extends the discipline onto the within-entry
923 /// instruction-target axis — duplicate cleanup targets *within*
924 /// one `:upgrade-from` entry — the peer of the cross-entry
925 /// duplicate-`:from` axis at one level of nesting deeper.
926 ///
927 /// Detection: linear scan of the instructions list collecting
928 /// the (module, kind) pair from every `SoftPurge` / `Purge`
929 /// encountered; on the second occurrence of any module the gate
930 /// fires with the prior kind and the colliding kind in declaration
931 /// order. Diagnostic-order pin: the first colliding pair surfaces,
932 /// not the last — mirrors
933 /// [`validate_upgrade_from`]'s
934 /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
935 /// posture (the first detected collision wins) and every peer
936 /// duplicate gate's first-collision discipline.
937 fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
938 let mut seen: Vec<(&str, &'static str)> = Vec::new();
939 for instr in self.instructions() {
940 // Route the per-instruction cleanup-family arm-discriminator
941 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
942 // predicate rather than the raw two-arm
943 // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
944 // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
945 // `UpgradeInstruction::Purge { module } => (module.as_str(),
946 // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
947 // per-arm dispatch — the third of three within-entry cross-
948 // instruction cleanup-facing gates the peer
949 // [`Self::validate_purge_ordering`] +
950 // [`Self::validate_state_change_before_cleanup`] routing
951 // already lifted; all three now key off exactly one typed
952 // dispatch on the substrate primitive, structurally. The
953 // cleanup-target `(module, kind)` pair is projected through
954 // the peer [`UpgradeInstruction::declared_module`] /
955 // [`UpgradeInstruction::lisp_form`] accessors rather than
956 // the per-arm-hand-rolled scalar-value + kind-const pair,
957 // with the `is_cleanup`-implies-`declared_module`-is-`Some`
958 // composition pin at
959 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
960 // making the `.expect(…)` structurally infallible at build
961 // time. Any future fifth cleanup-shaped variant added under
962 // the `is_cleanup` predicate + registered through the peer
963 // `lisp_form` per-arm kebab-case-const dispatch reaches this
964 // dedup gate through the accessor's one body rather than a
965 // fourth per-arm-hand-rolled scalar/kind projection here.
966 if !instr.is_cleanup() {
967 continue;
968 }
969 let module = instr
970 .declared_module()
971 .expect("is_cleanup() implies declared_module() is Some");
972 let kind = instr.lisp_form();
973 if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
974 let prior_kind = seen[prior_idx].1;
975 return Err(UpgradeError::duplicate_cleanup(
976 self.prior_versao(),
977 module,
978 vec![prior_kind, kind],
979 ));
980 }
981 seen.push((module, kind));
982 }
983 Ok(())
984 }
985
986 /// Reject an entry whose `:instructions` list names the same module
987 /// as the target of more than one `(:load-module …)` instruction —
988 /// set-not-multiset on the `LoadModule` axis.
989 ///
990 /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
991 /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
992 /// new code is 'current', old code is 'old'."). The instruction
993 /// brings the new wasm component up resident alongside the old
994 /// one so the operator can route new traffic to the new code
995 /// while in-flight callers drain on the old — and the operator's
996 /// dispatch table reads the module *name* (a caixa name) to bind
997 /// the component, so two `(:load-module "x")` instructions in one
998 /// entry ask the operator to re-bind the same component twice.
999 /// `systools`-generated `.relup` files emit at most one
1000 /// `load_module` per module per upgrade step for this reason; the
1001 /// second load has no observable semantic relative to the first
1002 /// (the component is already resident). Three authoring footguns
1003 /// close here:
1004 ///
1005 /// - `((:load-module "x") (:load-module "x"))` — the "I
1006 /// copy-pasted the load line twice" footgun. The second
1007 /// `:load-module` re-reads the same module name and re-binds
1008 /// the same wasm component — a no-op in both directions
1009 /// (no new code becomes resident; no old code is purged) —
1010 /// and any cleanup / migration the author intended for a
1011 /// *distinct* module is silently absent from the entry.
1012 /// - `((:load-module "x") (:load-module "x") (:state-change …))`
1013 /// — the "I meant to load two distinct modules" typo. The
1014 /// author intended `((:load-module "x") (:load-module "y"))`
1015 /// but renamed both to "x" (or copied the first line and
1016 /// forgot to change the module). The migration runs against
1017 /// code that's resident only on one module name, and the
1018 /// second module the author imagined was being loaded never
1019 /// comes up at all — far from the source caixa.lisp.
1020 /// - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1021 /// — same shape with a trailing cleanup. The duplicate load
1022 /// is dead code; the cleanup still fires correctly, masking
1023 /// the load-side duplication as a silently-passing entry.
1024 ///
1025 /// Same within-entry exclusivity discipline as
1026 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1027 /// singularity gate this runs beside) on the sibling
1028 /// `LoadModule` axis: both reject an `:instructions` list whose
1029 /// instructions are individually well-shaped but jointly
1030 /// incoherent on a per-module-per-class basis (load-once for the
1031 /// load axis here; cleanup-once for the cleanup axis there), at
1032 /// the typed build surface rather than as a runtime surprise.
1033 /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1034 /// before-cleanup ordering gate) so an entry like
1035 /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1036 /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1037 /// first (the missing-load defect is load-bearing — the migration
1038 /// runs against unloaded code; the duplicate is meaningless either
1039 /// way without the preceding load). Runs *before*
1040 /// [`Self::validate_cleanup_singularity`] so an entry like
1041 /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1042 /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1043 /// the load axis precedes the cleanup axis in the canonical OTP
1044 /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1045 /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1046 /// before `SoftPurge`/`Purge`), so the load-side singularity is
1047 /// the load-bearing diagnostic when both fire.
1048 ///
1049 /// Same set-not-multiset discipline applied to every peer
1050 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1051 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1052 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1053 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1054 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1055 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1056 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1057 /// the per-module cleanup-target axis (9cedd8b —
1058 /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1059 /// discipline onto the within-entry `LoadModule` instruction-target
1060 /// axis — the third within-entry per-module singularity completing
1061 /// the load+cleanup pair across the OTP two-phase code-load
1062 /// contract.
1063 ///
1064 /// Detection: linear scan of the instructions list collecting the
1065 /// module name from every `LoadModule` encountered; on the second
1066 /// occurrence of any module the gate fires. Diagnostic-order pin:
1067 /// the first colliding occurrence surfaces, not the last — mirrors
1068 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1069 /// and every peer duplicate gate's first-collision discipline.
1070 fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1071 let mut seen: Vec<&str> = Vec::new();
1072 for instr in self.instructions() {
1073 // Route the per-instruction load-family arm-discriminator
1074 // through the `gen_platform::IsVariant`-derive-generated
1075 // [`UpgradeInstruction::is_load_module`] predicate rather
1076 // than the raw single-arm `match instr {
1077 // UpgradeInstruction::LoadModule { module } =>
1078 // module.as_str(), _ => continue }` open-coded pattern-
1079 // match — closes the last unlifted `matches!`-shaped
1080 // per-arm-hand-rolled scalar-value + arm-discriminator
1081 // pair inside `impl UpgradeFromEntry`, sibling of the
1082 // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1083 // routing already lifted onto the two-arm cleanup-family
1084 // axis's per-arm arm-discriminator + `:module` projection
1085 // dispatch. The load-target `:module` scalar is projected
1086 // through the sibling [`UpgradeInstruction::declared_module`]
1087 // accessor rather than the per-arm-hand-rolled scalar-
1088 // value binding, with the
1089 // `is_load_module`-implies-`declared_module`-is-`Some`
1090 // composition pin at
1091 // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1092 // making the `.expect(…)` structurally infallible at
1093 // build time. Every arm-family partition the three
1094 // within-entry per-instruction-class singularity gates
1095 // key off — load-family
1096 // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1097 // ([`UpgradeInstruction::SoftPurge`] |
1098 // [`UpgradeInstruction::Purge`]), migration-family
1099 // ([`UpgradeInstruction::StateChange`]) — now consults
1100 // exactly one typed dispatch on the substrate primitive
1101 // (`is_load_module()` here, `is_cleanup()` at
1102 // [`Self::validate_cleanup_singularity`],
1103 // `declared_path()` at
1104 // [`Self::validate_state_change_singularity`]), so a
1105 // future sixth arm added to [`UpgradeInstruction`] (an
1106 // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1107 // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1108 // split-traffic variant the M4 CR materializer could
1109 // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1110 // single enum-declaration edit through the derive rather
1111 // than a scattered per-consumer rewrite. Byte-identity of
1112 // this dispatch against the pre-lift match-pattern is
1113 // pinned by
1114 // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1115 if !instr.is_load_module() {
1116 continue;
1117 }
1118 let module = instr
1119 .declared_module()
1120 .expect("is_load_module() implies declared_module() is Some");
1121 if seen.contains(&module) {
1122 return Err(UpgradeError::duplicate_load_module(
1123 self.prior_versao(),
1124 module,
1125 ));
1126 }
1127 seen.push(module);
1128 }
1129 Ok(())
1130 }
1131
1132 /// Reject an entry whose `:instructions` list names the same script
1133 /// as the target of more than one `(:state-change …)` instruction —
1134 /// set-not-multiset on the `StateChange` axis.
1135 ///
1136 /// `StateChange` is the `gen_server:code_change/3` analog
1137 /// (INSPIRATIONS §II.4: "State migration uses
1138 /// `gen_server:code_change/3`"). The instruction folds the *old*
1139 /// state into the shape the *new* code expects — a one-shot
1140 /// transition from one declared state representation to another.
1141 /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1142 /// exactly once per upgrade per `gen_server`; `systools`-generated
1143 /// `.relup` files emit at most one `code_change` per `gen_server` per
1144 /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1145 /// instruction targeting the same script in one entry re-runs the
1146 /// migration fold — at best a no-op (idempotent script masking a
1147 /// typo where the author intended two distinct scripts) and at
1148 /// worst silent state corruption (non-idempotent fold double-
1149 /// applied: an `add column` migration that runs twice, an
1150 /// `increment counter` that double-bumps, a `rename field` that
1151 /// renames-then-fails the second time). Three authoring footguns
1152 /// close here:
1153 ///
1154 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1155 /// (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1156 /// migration line twice" footgun. The second `:state-change`
1157 /// re-runs the same fold on the already-migrated state — a
1158 /// no-op if the script is idempotent (dead code masking the
1159 /// duplication) or state corruption if not (the migration's
1160 /// pre-condition no longer holds because the post-condition is
1161 /// already in place).
1162 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1163 /// (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1164 /// "duplicate migrate masked by trailing cleanup" footgun. The
1165 /// cleanup still fires correctly, masking the migration-side
1166 /// duplication as a silently-passing entry.
1167 /// - `((:load-module "x") (:state-change "lib/m1.lisp")
1168 /// (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1169 /// two distinct modules" typo. The author intended
1170 /// `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1171 /// but renamed both to `m1.lisp` (or copy-pasted the first line
1172 /// and forgot to change the script). The migration that should
1173 /// have folded the second module's state never runs, far from
1174 /// the source caixa.lisp.
1175 ///
1176 /// Same within-entry exclusivity discipline as
1177 /// [`Self::validate_load_singularity`] (the per-module load-
1178 /// singularity gate it runs after) and
1179 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1180 /// singularity gate it runs before) on the sibling `StateChange`
1181 /// axis: each rejects an `:instructions` list whose instructions
1182 /// are individually well-shaped but jointly incoherent on a per-
1183 /// instruction-class basis (load-once per module for the load
1184 /// axis; migrate-once per script for the migration axis here;
1185 /// cleanup-once per module for the cleanup axis), at the typed
1186 /// build surface rather than as a runtime surprise. Runs *after*
1187 /// [`Self::validate_load_singularity`] so an entry like
1188 /// `((:load-module "x") (:load-module "x") (:state-change
1189 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1190 /// `DuplicateLoadModule` first — the load axis precedes the
1191 /// migration axis in the canonical OTP sequence
1192 /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1193 /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1194 /// `StateChange`), so the load-side singularity is the load-
1195 /// bearing diagnostic when both fire. Runs *before*
1196 /// [`Self::validate_cleanup_singularity`] so an entry like
1197 /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1198 /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1199 /// surfaces `DuplicateStateChange` first — the migration axis
1200 /// precedes the cleanup axis in the canonical OTP sequence
1201 /// (`code:code_change/3` then `code:soft_purge/1`) and in
1202 /// [`UpgradeInstruction`] declaration order (`StateChange` before
1203 /// `SoftPurge`/`Purge`).
1204 ///
1205 /// Same set-not-multiset discipline applied to every peer
1206 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1207 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1208 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1209 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1210 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1211 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1212 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1213 /// per-module cleanup-target axis (9cedd8b —
1214 /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1215 /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1216 /// This gate extends the discipline onto the within-entry
1217 /// `StateChange` instruction-target axis — the third within-entry
1218 /// per-instruction-class singularity, completing the OTP two-phase
1219 /// code-load + state-migration coverage triad
1220 /// (`code:load_module/1` → `gen_server:code_change/3` →
1221 /// `code:soft_purge/1`).
1222 ///
1223 /// Detection: linear scan of the instructions list collecting the
1224 /// script path from every `StateChange` encountered; on the second
1225 /// occurrence of any script the gate fires. Diagnostic-order pin:
1226 /// the first colliding occurrence surfaces, not the last — mirrors
1227 /// [`Self::validate_load_singularity`]'s and
1228 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1229 /// and every peer duplicate gate's first-collision discipline.
1230 fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1231 // Route the per-instruction `StateChange`-arm script-path
1232 // projection through the sibling lifted
1233 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1234 // accessor rather than the raw
1235 // `match instr { UpgradeInstruction::StateChange { script } =>
1236 // script.as_path(), _ => continue }` open-coded pattern-match —
1237 // the third within-entry singularity gate's per-instruction
1238 // script-projection site now keys off exactly one typed
1239 // dispatch on the substrate primitive's `PathBuf`-carrying
1240 // axis, sibling to the four peer per-`UpgradeInstruction`
1241 // consumers ([`Self::validate`]'s per-`StateChange`
1242 // sandbox-path fan-out, the layout-side per-`StateChange`
1243 // script-existence fan-out at
1244 // `caixa-core/src/layout.rs:1017`, the cross-slot
1245 // [`validate_upgrade_from_against_behavior`] gate's
1246 // per-`StateChange` detection loop, the future wasm-operator's
1247 // per-`StateChange` runtime hook-dispatch) that already route
1248 // through `declared_path` / `declared_module`. Byte-equal
1249 // today (`declared_path` returns `Some(script)` iff the
1250 // instruction is [`UpgradeInstruction::StateChange`], per the
1251 // sibling `declared_path_only_for_state_change` pin), so a
1252 // duplicate `:state-change` script surfaces
1253 // `DuplicateStateChange` byte-identical to the pattern-match
1254 // shape. Same "one typed dispatch on the substrate primitive,
1255 // thin projections at each consumer" discipline the sibling
1256 // [`UpgradeInstruction::declared_module`] accessor established
1257 // (b13c4f9) on the peer `String`-carrying axis's per-variant
1258 // consumers, extended here onto the last unlifted
1259 // pattern-match on the `PathBuf`-carrying axis inside
1260 // `impl UpgradeFromEntry`.
1261 let mut seen: Vec<&std::path::Path> = Vec::new();
1262 for instr in self.instructions() {
1263 let Some(script) = instr.declared_path() else {
1264 continue;
1265 };
1266 let script = script.as_path();
1267 if seen.contains(&script) {
1268 return Err(UpgradeError::duplicate_state_change(
1269 self.prior_versao(),
1270 script,
1271 ));
1272 }
1273 seen.push(script);
1274 }
1275 Ok(())
1276 }
1277}
1278
1279/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1280/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1281/// invariant — at most one `(:from <prior>)` block per parsed semver.
1282///
1283/// OTP's appup picks at most one matching block to apply to the running
1284/// release (`release_handler:install_release/1` matches the loaded
1285/// `:from` against the currently-running version and executes the
1286/// associated instruction sequence; the wasm-operator picks the matching
1287/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1288/// the same parsed-semver `:from` are an ambiguous edge in the typed
1289/// upgrade graph — the operator can pick either set deterministically,
1290/// but each set may carry different `LoadModule | StateChange |
1291/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1292/// non-deterministic relative to the source caixa.lisp. The author's
1293/// intent is one path per prior version; the typed graph must enforce
1294/// that shape.
1295///
1296/// Same set-not-multiset discipline already applied to every peer
1297/// typed-graph axis: `:children :caixa` (dbf50a9 —
1298/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1299/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1300/// `AplicacaoError::MembroDuplicate`), `:contratos`
1301/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1302/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1303/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1304/// Each closes the same authoring footgun: a Vec authoring surface that
1305/// silently accepts duplicate entries and renders the "second wins"
1306/// (or "operator picks arbitrarily") shape downstream, far from the
1307/// source caixa.lisp.
1308///
1309/// Duplicates are detected by [`semver::Version`] equality (the
1310/// crate's `PartialEq` compares the full identity — major.minor.patch +
1311/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1312/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1313/// The conservative choice mirrors what the wasm-operator's
1314/// `:from`-match dispatch can see; collapsing build metadata to catch
1315/// a wider net of duplicates is a future tightening that requires
1316/// coordinating with the operator's match step.
1317///
1318/// Per-entry shape errors fire before the duplicate gate so the
1319/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1320/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1321/// errors into a single duplicate diagnostic. Mirrors the
1322/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1323/// axis ([`crate::SupervisorSpec::validate`],
1324/// [`crate::AplicacaoSpec::validate_membros`],
1325/// [`crate::AplicacaoSpec::validate_placement`]).
1326pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1327 use semver::Version;
1328 let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1329 for entry in entries {
1330 entry.validate()?;
1331 // `entry.validate()` accepted this `:from`, so parse cannot
1332 // fail here — the FromInvalid arm above is the only gate
1333 // and both call `Version::parse(entry.prior_versao())`.
1334 let parsed = Version::parse(entry.prior_versao()).expect(
1335 "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1336 two gates aligned",
1337 );
1338 if seen.contains(&parsed) {
1339 return Err(UpgradeError::duplicate_from(entry));
1340 }
1341 seen.push(parsed);
1342 }
1343 Ok(())
1344}
1345
1346/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1347/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1348/// same ordering [`semver::Version::cmp`] implements, with build
1349/// metadata ignored per [SemVer §11][semver-11]).
1350///
1351/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1352/// declarative answer to "given the wasm-operator is loading a node
1353/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1354/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1355/// semantic). The operator's `:from`-match dispatch loads the
1356/// current `:versao` and matches the *running* version against each
1357/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1358/// unreachable — the operator never runs a version greater than or
1359/// equal to the current `:versao` that it could then "upgrade *to*"
1360/// the current `:versao`. Two authoring footguns close here:
1361///
1362/// - `:from > :versao` (downgrade-shaped) — the canonical
1363/// "I copy-pasted from the next minor version and forgot to bump
1364/// `:versao`" / "I bumped `:versao` then reverted but left the
1365/// `:upgrade-from` entry behind" footgun. Until this gate landed
1366/// `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1367/// silently passed `feira build` and the wasm-operator's
1368/// `:from`-match dispatch would never fire on the entry — the
1369/// instructions sat dormant in the caixa.lisp forever, the
1370/// author's intent ("upgrade users coming from 0.2.0") permanently
1371/// unreached because they actually meant to bump `:versao`.
1372///
1373/// - `:from == :versao` (precedence-equal self-upgrade) — the
1374/// "I declared an upgrade from myself to myself" no-op the
1375/// operator's dispatch would either skip silently (no semantic
1376/// transition) or attempt and trivially "succeed" with no
1377/// observable state change. Includes the build-metadata-only
1378/// difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1379/// SemVer-2 precedence ignores build metadata so they compare
1380/// equal under [`semver::Version::cmp`] — the gate rejects this
1381/// even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1382/// gate uses derived `PartialEq` which keeps them distinct;
1383/// they're distinct dispatch keys but the same "from" version
1384/// for our purposes here).
1385///
1386/// Same cross-slot value-shape discipline as
1387/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1388/// partition (934bc58 — the typed partition between two declared
1389/// slots): one slot's value constrains the valid set of another's,
1390/// and the constraint is a structural property visible at validate
1391/// time. The validated set after this gate satisfies
1392/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1393/// for every entry, so the future operator-side hot-upgrade dispatch
1394/// step can reach for `entry.from` knowing the precedence relation
1395/// holds without re-deriving it from inline checks.
1396///
1397/// Silent-pass semantics on malformed inputs:
1398///
1399/// - When `versao` itself doesn't parse as semver, this gate
1400/// returns `Ok(())` silently — the narrower
1401/// [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1402/// diagnostics are the load-bearing surfaces for those failure
1403/// modes, and surfacing a `FromNotBeforeVersao` over an
1404/// unparseable `:versao` would mask the more actionable root
1405/// cause.
1406/// - Likewise, an entry whose `:from` itself doesn't parse falls
1407/// through to its narrower diagnostic surface
1408/// ([`UpgradeError::FromInvalid`]), which is expected to fire
1409/// via [`validate_upgrade_from`] *before* this gate runs at the
1410/// [`crate::LayoutInvariants`] call site.
1411///
1412/// [semver-11]: https://semver.org/#spec-item-11
1413pub fn validate_upgrade_from_against_versao(
1414 entries: &[UpgradeFromEntry],
1415 versao: &str,
1416) -> Result<(), UpgradeError> {
1417 use semver::Version;
1418 let Ok(current) = Version::parse(versao) else {
1419 // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1420 // surfacing a precedence-relation diagnostic over an unparseable
1421 // top-level version would mask the more actionable root cause.
1422 return Ok(());
1423 };
1424 for entry in entries {
1425 // Per-entry shape — including a malformed `:from` — is gated
1426 // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1427 // upstream at the LayoutInvariants call site; an unparseable
1428 // `:from` here falls through silently to keep the
1429 // FromInvalid diagnostic load-bearing. Same fall-through
1430 // posture as the `versao` arm above.
1431 let Ok(prior) = Version::parse(entry.prior_versao()) else {
1432 continue;
1433 };
1434 if prior >= current {
1435 return Err(UpgradeError::from_not_before_versao(
1436 entry.prior_versao(),
1437 versao,
1438 ));
1439 }
1440 }
1441 Ok(())
1442}
1443
1444/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1445/// `(:state-change <script>)` instruction unless the caixa also declares
1446/// `:behavior :on-state-change` — the runtime callback the per-version
1447/// migration script is delivered through during hot upgrade.
1448///
1449/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1450/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1451/// callback to deliver state migration during hot upgrades." The peer
1452/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1453/// promise from the callback side: the slot is the
1454/// `gen_server:code_change/3` analog — "receives old state + version,
1455/// returns new state. Composes with the `:upgrade-from` slot declared at
1456/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1457/// the composition by invoking the running `gen_server`'s
1458/// `code_change/3` callback during the appup's `code_change` /
1459/// `update, m, soft` step — the appup's instruction triggers the
1460/// callback, the callback folds the prior-version state shape into the
1461/// current-version shape, and the operator advances to the next
1462/// instruction only after the callback returns successfully. caixa
1463/// decomposes the same composition into two typed slots: the per-version
1464/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1465/// instruction's `:script` (the `:upgrade-from` author surface), and the
1466/// runtime hook the operator dispatches the migration through lives in
1467/// the `:behavior :on-state-change` callback (the `:behavior` author
1468/// surface). A `:state-change` instruction declared without the callback
1469/// is half the composition: the per-version script the author wrote has
1470/// no runtime delivery path, and the operator's hot-upgrade dispatch
1471/// reaches for `caixa.behavior.on_state_change` at the migration step,
1472/// finds `None`, and either fails the upgrade mid-flight (the
1473/// transactional rollback the module doc names — "On any failure, the
1474/// current version stays load-bearing — a typed atomic upgrade") or
1475/// silently skips the migration depending on the operator's handling of
1476/// a missing callback, both far from the source caixa.lisp.
1477///
1478/// Two authoring footguns close here:
1479///
1480/// - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1481/// :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1482/// (:soft-purge "x-old")))))` — the "I declared the migration script
1483/// but forgot the callback" footgun. The author wrote the per-version
1484/// fold against the prior state shape, the typed `:upgrade-from`
1485/// slot validated every per-instruction shape + ordering + singularity
1486/// gate, and the missing callback only surfaces at upgrade time as
1487/// either a transactional rollback to the prior version (no progress
1488/// across the upgrade) or as a silently-skipped migration that leaves
1489/// v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1490/// state shape).
1491/// - `:behavior` absent entirely + `:upgrade-from` carrying any
1492/// `:state-change` — the "I added the upgrade path but never declared
1493/// `:behavior`" footgun. `:behavior` is optional at the typed root
1494/// ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1495/// `:upgrade-from` slot validates on its own merits, but a `Caixa`
1496/// with `behavior: None` and a `:state-change` instruction is the
1497/// same missing-callback shape — the operator's dispatch can't reach
1498/// a callback that doesn't exist.
1499///
1500/// Same cross-slot composition discipline as
1501/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1502/// precedence gate at the peer wire-up site): one slot's value
1503/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1504/// the valid set of another's (the entry must be dispatchable there; the
1505/// callback must be declared here), and the constraint is a structural
1506/// property visible at validate time. The validated set after this gate
1507/// satisfies the documented composition: every `:state-change`
1508/// instruction the operator iterates at hot-upgrade time has a
1509/// corresponding `:on-state-change` callback declared on the same caixa,
1510/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1511/// `release_handler` canonical-sequence loop) can reach for
1512/// `behavior.on_state_change` at the migration step knowing the
1513/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1514/// from inline checks.
1515///
1516/// Diagnostic-precedence:
1517///
1518/// - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1519/// shape + the within-entry ordering / singularity gates) and
1520/// [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1521/// gate), so a malformed `:state-change` (`EmptyScript`,
1522/// `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1523/// (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1524/// duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1525/// self-locating diagnostic first — the canonical "per-instr-shape +
1526/// within-entry ordering + cross-entry uniqueness before
1527/// cross-slot composition" precedence the peer
1528/// `validate_upgrade_from_against_versao` gate establishes at the
1529/// same wire-up site. Without this precedence pin a malformed
1530/// `:state-change` instruction would surface this gate's
1531/// missing-callback diagnostic over the narrower
1532/// `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1533/// load-bearing per-instruction defect with a cross-slot composition
1534/// diagnostic.
1535/// - Within the entries, walks the list in declaration order and
1536/// surfaces the *first* `:state-change` instruction encountered —
1537/// mirrors every peer first-collision diagnostic posture on this
1538/// module (`validate_state_change_ordering` returns on the first
1539/// `StateChange` without prior load,
1540/// `validate_load_singularity` returns on the second matching
1541/// module, etc.). A future entry's later `:state-change` doesn't
1542/// surface a different diagnostic — the missing callback is the same
1543/// defect regardless of which entry's `:state-change` exposes it.
1544///
1545/// Silent-pass semantics:
1546///
1547/// - Entries carrying no `:state-change` instruction (load-only,
1548/// cleanup-only, restart-only, or empty `:instructions`) leave the
1549/// gate vacuous — no per-version migration means no callback to
1550/// dispatch through, so the absence of `:on-state-change` is
1551/// coherent. Pins the gate's identity element on the empty-set side
1552/// of the composition.
1553/// - `behavior: None` is *not* a free pass when a `:state-change`
1554/// instruction is present — the same missing-callback shape as
1555/// `behavior: Some(_)` with `on_state_change: None`. The gate reads
1556/// `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1557/// surface the same diagnostic.
1558pub fn validate_upgrade_from_against_behavior(
1559 entries: &[UpgradeFromEntry],
1560 behavior: Option<&crate::BehaviorSpec>,
1561) -> Result<(), UpgradeError> {
1562 if behavior
1563 .and_then(crate::BehaviorSpec::on_state_change)
1564 .is_some()
1565 {
1566 return Ok(());
1567 }
1568 for entry in entries {
1569 // Route the per-instruction `StateChange`-arm script-path
1570 // projection through the sibling lifted
1571 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1572 // accessor rather than the raw
1573 // `if let UpgradeInstruction::StateChange { script } = instr`
1574 // open-coded pattern-match — the cross-slot
1575 // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1576 // script-projection site now keys off exactly one typed dispatch
1577 // on the substrate primitive's `PathBuf`-carrying axis, sibling
1578 // to the four peer per-`UpgradeInstruction` consumers
1579 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1580 // sandbox-path fan-out, the layout-side per-`StateChange`
1581 // script-existence fan-out at
1582 // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1583 // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1584 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1585 // peer [`UpgradeInstruction::declared_module`] `String`-axis
1586 // per-variant unifier) that already route through
1587 // `declared_path` / `declared_module`. Byte-equal today
1588 // (`declared_path` returns `Some(script)` iff the instruction is
1589 // [`UpgradeInstruction::StateChange`], per the sibling
1590 // `declared_path_only_for_state_change` pin), so a
1591 // `:state-change`-without-`:on-state-change`-callback
1592 // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1593 // byte-identical to the pattern-match shape. Fourth (and last)
1594 // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1595 // axis now routed through the accessor — closes the last
1596 // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1597 // site outside `impl UpgradeFromEntry`, so the peer four
1598 // consumer set named in the sibling
1599 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1600 // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1601 // closed.
1602 for instr in entry.instructions() {
1603 if let Some(script) = instr.declared_path() {
1604 return Err(UpgradeError::state_change_without_on_state_change_callback(
1605 entry.prior_versao(),
1606 script,
1607 ));
1608 }
1609 }
1610 }
1611 Ok(())
1612}
1613
1614impl UpgradeInstruction {
1615 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup kind-tag
1616 /// projection every consumer that renders / classifies / grepping-
1617 /// projects an instruction's lisp form keys off — returns the
1618 /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1619 /// straight through the paired
1620 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1621 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1622 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1623 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1624 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1625 /// roster the substrate already carries at the wire-form axis.
1626 ///
1627 /// Consumers today: [`Self::validate`] threads the label through the
1628 /// per-variant [`UpgradeError::ModuleEmpty`] /
1629 /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1630 /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1631 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1632 /// `(:purge …)` and fix it in one edit; every within-entry cross-
1633 /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1634 /// same accessor's `&'static str` return in place of hand-rolling
1635 /// the per-arm match.
1636 ///
1637 /// Promoted from `pub(self)` to `pub`: every future consumer that
1638 /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1639 /// by its OTP-appup lisp form outside caixa-core — a deferred
1640 /// wasm-operator `install_release/1` per-instruction dispatch
1641 /// logger tagging each executed instruction under its kebab-case
1642 /// kind, a `feira lint --upgrade-from` per-instruction author-time
1643 /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1644 /// webhook naming the offending instruction's kind in its rejection
1645 /// body, a future `caixa-actions` renderer that surfaces the
1646 /// declared appup instruction list in a workflow annotation, an
1647 /// LSP hover projecting the per-instruction kind onto a text-
1648 /// document diagnostic — reaches this projection through one call
1649 /// on the substrate primitive rather than open-coding the same
1650 /// five-arm match plus per-arm const imports at every consumer.
1651 /// A future variant addition (a `Discard` peer the `code:delete/1`
1652 /// analog inspires, an M4 `SoftPurge` split into
1653 /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1654 /// policy grows a two-arm shape) reaches every consumer at one edit
1655 /// — this method's match — rather than fanning out through hand-
1656 /// rolled per-arm dispatch across every downstream site.
1657 ///
1658 /// Peer of the sibling substrate-canonical arm-family accessors on
1659 /// the same closed-set enum: [`Self::declared_module`] on the
1660 /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1661 /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1662 /// [`Self::StateChange`] / [`Self::Restart`]),
1663 /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1664 /// (`Some(_)` for [`Self::StateChange`]), and
1665 /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1666 /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1667 /// generated per-variant `is_*` predicate family — every downstream
1668 /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1669 /// one typed dispatch on the substrate primitive rather than open-
1670 /// coding a per-arm match.
1671 ///
1672 /// `const fn` preserves the zero-runtime-work property of the pre-
1673 /// promotion body verbatim, and the `&'static str` return (not
1674 /// `&str` tied to `&self`'s lifetime) matches the paired
1675 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1676 /// program-lifetime discipline so callers can stash the returned
1677 /// label in `&'static`-bounded positions (a static logger's format
1678 /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1679 /// slice-of-`&'static str` accept-set) without re-borrowing through
1680 /// the instruction reference. Named `lisp_form` (not `kind_label` /
1681 /// `discriminant_label`) to name the axis the substrate already
1682 /// reaches for in the paired
1683 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1684 /// in every per-arm `UpgradeError` diagnostic that carries the
1685 /// kebab-case tag verbatim — the lisp author-surface term, not the
1686 /// Rust discriminant name.
1687 #[must_use]
1688 pub const fn lisp_form(&self) -> &'static str {
1689 match self {
1690 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1691 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1692 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1693 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1694 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1695 }
1696 }
1697
1698 /// Substrate-canonical per-`UpgradeInstruction` kebab-case wire-form
1699 /// discriminator every consumer that lands on the un-prefixed
1700 /// kebab byte-string (matching serde's
1701 /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive's
1702 /// per-variant tag output and the
1703 /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1704 /// fleet-catalog identity) reaches through — returns `"load-module"`
1705 /// / `"state-change"` / `"soft-purge"` / `"purge"` / `"restart"`,
1706 /// byte-for-byte the same five strings the JSON `"kind"` tag carries
1707 /// (per the sibling
1708 /// [`crate::tests::dispatcher_registration::reflection_round_trips_through_serde_tags`]
1709 /// pin) and the fleet-wide dispatcher-catalog registers under
1710 /// `"caixa.upgrade-instruction"` (per
1711 /// [`crate::tests::dispatcher_registration::variant_kinds_match_otp_appup_kebab`]).
1712 ///
1713 /// Distinct axis from the peer [`Self::lisp_form`] accessor, which
1714 /// returns the tatara-lisp author-surface form with the leading `:`
1715 /// prefix (`":load-module"` / `":state-change"` / `":soft-purge"` /
1716 /// `":purge"` / `":restart"`) that lands in `feira lint` per-
1717 /// instruction diagnostics and every
1718 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const's docstring.
1719 /// The two axes carry different bytes by design, not drift: the lisp
1720 /// form is the author-facing tag the caixa.lisp grep-and-fix
1721 /// workflow reaches for (`grep '(:load-module '` finds the offending
1722 /// entry verbatim), while [`Self::as_str`] is the wire-format byte-
1723 /// string every serde-serialized CR / [`std::fmt::Display`]-formatted
1724 /// diagnostic line / [`AsRef<str>`]-bound consumer / fleet-catalog
1725 /// identity converge onto — the same two-axis discipline the sibling
1726 /// [`crate::CaixaKind::as_str`] / [`crate::CaixaKind::wire_name`]
1727 /// pair (2aa6d23) documents on the top-level `:kind` closed-set
1728 /// discriminator, extended here onto the M2 OTP-appup
1729 /// per-instruction tag axis.
1730 ///
1731 /// Peer of the sibling closed-set typed enums' `as_str` /
1732 /// `as_suffix` canonical-projection accessors:
1733 /// [`crate::CaixaKind::as_str`] (6b1f4fb),
1734 /// [`crate::supervisor::RestartStrategy::as_str`] (09ffb2d),
1735 /// [`crate::supervisor::RestartPolicy::as_str`] (ccdf955),
1736 /// [`crate::aplicacao::PlacementStrategy::as_str`] (cc8f749),
1737 /// [`crate::aplicacao::RateLimitUnit::as_suffix`] (6bce03d) — the
1738 /// last closed-set typed enum on the caixa `:upgrade-from` surface
1739 /// to converge onto the substrate-canonical
1740 /// `(as_str, AsRef<str>, Display)` triple through one lifted
1741 /// `const fn` scalar accessor, so a future author-facing rebrand
1742 /// (a per-consumer disambiguation of the OTP-appup vocabulary, a
1743 /// hypothetical `:reload` collapse of `:load-module` under an
1744 /// Elixir/Phoenix hot-reload convergence, an M4-side rename of
1745 /// `:state-change` onto Erlang's own `code_change/3` verbatim) lands
1746 /// at one match arm — the paired [`std::fmt::Display`] impl and
1747 /// [`AsRef<str>`] impl route through this accessor by construction,
1748 /// so every consumer downstream of any of the three reaches the same
1749 /// per-arm byte-string in lockstep.
1750 ///
1751 /// `pub const fn` matches the peer accessors' const-context posture:
1752 /// downstream `const`-context callers (a module-scope
1753 /// `const _:() = assert!(<variant>.as_str().len() > 0)` invariant
1754 /// pin, a `const fn` per-instruction wire-shape audit table the M4
1755 /// admission webhook materializes at build time) reach the accessor
1756 /// through one dispatch on the substrate primitive without an
1757 /// intermediate non-`const` step. Returns `&'static str` (not
1758 /// `&str` bound to `&self`'s lifetime) so callers can stash the
1759 /// returned label in `&'static`-bounded positions (a static logger's
1760 /// format argument, a `HashMap<&'static str, _>` key, a `matches!`-
1761 /// style slice-of-`&'static str` accept-set) without re-borrowing
1762 /// through the instruction reference.
1763 #[must_use]
1764 pub const fn as_str(&self) -> &'static str {
1765 match self {
1766 Self::LoadModule { .. } => "load-module",
1767 Self::StateChange { .. } => "state-change",
1768 Self::SoftPurge { .. } => "soft-purge",
1769 Self::Purge { .. } => "purge",
1770 Self::Restart => "restart",
1771 }
1772 }
1773
1774 /// Validate the instruction's typed shape. Path existence is
1775 /// checked separately by [`crate::layout::StandardLayout`].
1776 ///
1777 /// The per-variant scalar the value-shape gates fire against is
1778 /// read through this method's two sibling accessors — the
1779 /// `String`-carrying axis via [`Self::declared_module`] (the
1780 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1781 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1782 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1783 /// variant's tatara-lisp `:script`) — rather than the per-arm
1784 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1785 /// Self::Purge { module }` pattern the module-axis previously
1786 /// open-coded and the per-arm `Self::StateChange { script }` the
1787 /// script-axis previously open-coded. Every scalar this enum
1788 /// carries now flows through one of the two `Option<&…>`
1789 /// accessors, so a future extension of either axis (a fifth
1790 /// module-bearing variant, an operator-side pre-parsed scalar
1791 /// cache the accessors materialize behind the same return
1792 /// contract, an M4 typed sub-slot the accessors could route
1793 /// alongside the existing scalar) migrates as a single edit on
1794 /// the accessor rather than a coordinated rewrite of every
1795 /// downstream value-shape gate. `Restart` (the only variant that
1796 /// carries neither scalar) falls through both `Option` checks and
1797 /// returns `Ok(())` — the terminal-fallback shape the
1798 /// [`Self::Restart`] variant doc pins.
1799 pub fn validate(&self) -> Result<(), UpgradeError> {
1800 if let Some(module) = self.declared_module() {
1801 return validate_module(self.lisp_form(), module);
1802 }
1803 if let Some(script) = self.declared_path() {
1804 // Delegate the four-arm cascade (empty / absolute /
1805 // parent-escape / non-`.lisp`-extension) to the lifted
1806 // [`crate::render::require_sandboxed_lisp_path`] helper —
1807 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1808 // arm-ordering this method previously inlined verbatim,
1809 // now shared with [`crate::BehaviorSpec::validate`]'s
1810 // per-`:on-*`-callback gate so every author-supplied
1811 // tatara-lisp source path on every M2 typed slot consults
1812 // one gate, not two-and-counting verbatim copies of the
1813 // same four-arm cascade. Each closure wraps the tag in
1814 // the same `*Script` variant the original inline code
1815 // raised, so the diagnostic shape every caller depends
1816 // on (the `:state-change :script` self-locating error)
1817 // is preserved by construction. See
1818 // [`crate::render::require_sandboxed_lisp_path`] for the
1819 // smallest-scope-arm-fires-last ordering rationale.
1820 crate::render::require_sandboxed_lisp_path(
1821 script,
1822 || UpgradeError::EmptyScript,
1823 || UpgradeError::absolute_script(script),
1824 || UpgradeError::parent_escape_script(script),
1825 || UpgradeError::non_lisp_extension_script(script),
1826 )?;
1827 }
1828 // `Restart` (the only variant with no `Option<&…>`-carrying
1829 // scalar) falls through both accessor gates and returns
1830 // `Ok(())` — the terminal-fallback shape.
1831 Ok(())
1832 }
1833
1834 /// The `:module` scalar carried by this instruction — the
1835 /// K8s DNS-1123-label OTP-appup caixa-name reference every
1836 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1837 /// variant declares against, and every author expects `feira lint`
1838 /// to name verbatim in per-instruction diagnostics. Returns `None`
1839 /// on [`Self::StateChange`] (which carries a `:script` — closed by
1840 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
1841 /// (which carries no data at all, the OTP terminal-fallback
1842 /// shape).
1843 ///
1844 /// Sibling in shape to [`Self::declared_path`] on the second and
1845 /// final scalar-carrying axis of [`UpgradeInstruction`]:
1846 /// `declared_path` closes the `PathBuf`-carrying arm
1847 /// (`StateChange`); `declared_module` closes the `String`-carrying
1848 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
1849 /// enum carries now routes through one of the two `Option<&…>`
1850 /// accessors — a caller that doesn't care which variant declared
1851 /// the scalar reads through one `if let Some(…)` rather than a
1852 /// per-variant pattern match. The pair is the enum-variant-
1853 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
1854 /// on the M3 side ([`crate::WitContract::source`] /
1855 /// [`crate::WitContract::destination`] /
1856 /// [`crate::WitContract::world_ref`] closing `:contratos`;
1857 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
1858 /// closing `:entrada`; [`crate::Membro::nome`] /
1859 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
1860 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
1861 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
1862 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
1863 /// closed OTP-shape supervisor family) — those peer accessors
1864 /// return a struct field verbatim; this pair unifies enum-
1865 /// variant-carried scalars into one accessor per typed axis.
1866 ///
1867 /// Byte-for-byte from the typed variant's own `String` storage;
1868 /// no cloning, no re-parsing. A future extension of the axis (an
1869 /// M4 typed sub-slot the module string is derived from, an
1870 /// operator-side pre-parsed caixa-name cache the accessor could
1871 /// materialize behind the same `&str` return contract, a fifth
1872 /// module-bearing OTP-appup variant the enum grows) migrates as
1873 /// a single caixa-core edit rather than a coordinated rewrite
1874 /// of every downstream module-axis consumer (currently
1875 /// [`Self::validate`]'s DNS-1123-label gate through
1876 /// [`validate_module`]; extensible to future consumers on the
1877 /// same axis without further per-variant match sites).
1878 #[must_use]
1879 pub const fn declared_module(&self) -> Option<&str> {
1880 match self {
1881 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
1882 Some(module.as_str())
1883 }
1884 Self::StateChange { .. } | Self::Restart => None,
1885 }
1886 }
1887
1888 /// If the instruction references an on-disk path, return it —
1889 /// used by the layout checker to verify the path resolves.
1890 ///
1891 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
1892 /// on the `String`-carrying axis: `declared_path` closes the
1893 /// `StateChange` arm's `:script`; `declared_module` closes the
1894 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
1895 /// they route every scalar this enum carries through one of two
1896 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
1897 /// gates dispatch on the accessor return rather than a per-variant
1898 /// pattern match on the enum shape itself.
1899 ///
1900 /// Four per-`UpgradeInstruction` consumers now key off this
1901 /// accessor's `PathBuf`-carrying axis:
1902 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
1903 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
1904 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
1905 /// within-entry
1906 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
1907 /// per-`StateChange` script-projection fan-out, and the cross-slot
1908 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
1909 /// :behavior` composition gate's per-`StateChange` detection loop
1910 /// — every downstream consumer of the `PathBuf`-carrying axis
1911 /// reaches through this one dispatch, so a future accessor
1912 /// extension (an M4 typed sub-slot the script path is derived from,
1913 /// an operator-side pre-resolved-path cache the accessor
1914 /// materializes behind the same `Option<&PathBuf>` return contract,
1915 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
1916 /// migrates as a single caixa-core edit rather than a coordinated
1917 /// rewrite of four call sites.
1918 #[must_use]
1919 pub const fn declared_path(&self) -> Option<&PathBuf> {
1920 match self {
1921 Self::StateChange { script } => Some(script),
1922 _ => None,
1923 }
1924 }
1925
1926 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
1927 /// family arm-discriminator predicate every within-entry cross-
1928 /// instruction cleanup-facing gate keys off — true iff `self` is
1929 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
1930 /// named module until no process is running it, then GC) or
1931 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
1932 /// module immediately, without waiting for drain), the two OTP
1933 /// two-phase-code-load cleanup arms the closed-set enum's
1934 /// non-terminal / non-migration / non-load variants exhaust.
1935 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
1936 /// two-phase-load half, [`Self::StateChange`] on the
1937 /// `gen_server:code_change/3`-analog migration axis,
1938 /// [`Self::Restart`] on the OTP terminal-fallback shape)
1939 /// returns `false`.
1940 ///
1941 /// Prior to this lift the `Self::SoftPurge { module } |
1942 /// Self::Purge { module }` two-arm cleanup-family pattern-
1943 /// match sat inline at three within-entry cross-instruction
1944 /// gate sites, each hand-rolling its own copy of the union
1945 /// with no compile-time link back to the substrate primitive's
1946 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
1947 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
1948 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
1949 /// arriving before a preceding [`Self::LoadModule`]),
1950 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
1951 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
1952 /// recording the first-encountered cleanup so a subsequent
1953 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
1954 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
1955 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
1956 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
1957 /// second cleanup targeting the same `:module`). Three open-
1958 /// coded per-arm-union pattern-matches that expressed no
1959 /// compile-time link back to the substrate primitive. A future
1960 /// fifth cleanup-shaped variant (a `Discard` variant the
1961 /// `code:delete/1` peer inspires that folds under the same
1962 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
1963 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
1964 /// cool-down policy grows a two-arm shape, an operator-side
1965 /// pre-resolved cleanup-decision cache the predicate could
1966 /// route through the same `bool` return contract) would have
1967 /// had to be threaded through every open-coded per-arm-union
1968 /// pattern-match in lockstep or one gate would silently
1969 /// classify the new arm outside the cleanup family while the
1970 /// peer gates classified it in (or vice versa) — a
1971 /// classification split across the three within-entry cross-
1972 /// instruction gates at build time that lands far from the
1973 /// source [`UpgradeInstruction`] declaration with no field
1974 /// naming which gate carries the drifted arm-set. Lifting the
1975 /// resolution to a typed predicate on the substrate primitive
1976 /// means every downstream cleanup-facing consumer of the
1977 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
1978 /// one typed dispatch — the resolver's arm-set migrates as a
1979 /// unit on any future arm addition composing under this
1980 /// predicate's `||` chain.
1981 ///
1982 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
1983 /// derive-generated [`Self::is_restart`] terminal-fallback
1984 /// arm-discriminator predicate on the same closed-set
1985 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
1986 /// family partition as one typed dispatch on the substrate
1987 /// primitive; `is_restart` on the single-arm terminal-
1988 /// fallback family, `is_cleanup` on the two-arm cleanup
1989 /// family), extended here from the single-arm case onto the
1990 /// two-arm arm-family union case. Composes through the
1991 /// [`gen_platform::IsVariant`]-derive-generated
1992 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
1993 /// predicates rather than an open-coded raw `matches!`
1994 /// pattern-match, so a future rebrand on either underlying
1995 /// per-arm classifier flows through this predicate's one
1996 /// body without a coordinated per-consumer rewrite across
1997 /// the three within-entry cross-instruction gates that route
1998 /// through it. Peer of the sibling per-`:contratos`
1999 /// shape-family union predicates [`crate::WitContract::is_http`] /
2000 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
2001 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
2002 /// per-shape WIT-prefix rule the substrate primitive's arm-
2003 /// family partition names as one typed dispatch) — the same
2004 /// "one typed dispatch on the substrate primitive, thin
2005 /// projections at each consumer" discipline extended onto the
2006 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
2007 /// cleanup-family axis.
2008 ///
2009 /// The name `is_cleanup` maps directly onto the canonical
2010 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
2011 /// `code:soft_purge/1` — wait until no process is running v1,
2012 /// then discard. (`code:purge/1` kills v1 immediately if you
2013 /// don't care.)" — the two `code:*_purge/1` operations are
2014 /// the two-phase-load contract's cleanup half, paired under
2015 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
2016 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
2017 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
2018 /// reaches for the same "cleanup" vocabulary in identifier +
2019 /// diagnostic form.
2020 #[must_use]
2021 pub const fn is_cleanup(&self) -> bool {
2022 self.is_soft_purge() || self.is_purge()
2023 }
2024}
2025
2026/// [`std::fmt::Display`] routed through [`UpgradeInstruction::as_str`],
2027/// so the pretty-printed byte-string every consumer that formats the
2028/// per-`:upgrade-from :instructions` entry's OTP-appup tag as user-
2029/// facing text lands on (the future wasm-operator's
2030/// `install_release/1` per-instruction dispatch log line, the future
2031/// `feira lint --upgrade-from` per-entry annotation, an M4
2032/// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection body
2033/// naming the offending instruction's kind, an LSP hover projecting
2034/// the instruction kind onto a text-document diagnostic) reaches for
2035/// the same wire byte-string the un-`rename`d
2036/// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive emits
2037/// under the paired [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
2038/// tag key.
2039///
2040/// Peer of the sibling closed-set typed enums' `Display` route through
2041/// their `as_str` accessor: [`crate::CaixaKind`] (2aa6d23),
2042/// [`crate::supervisor::RestartStrategy`] (supervisor.rs),
2043/// [`crate::supervisor::RestartPolicy`] (supervisor.rs), and
2044/// [`crate::aplicacao::PlacementStrategy`] (aplicacao.rs) — the last
2045/// M2 OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2046/// surface to converge onto the `Display`-through-`as_str` discipline.
2047///
2048/// Deliberately routes through the wire-aligned
2049/// [`UpgradeInstruction::as_str`] axis (kebab-case, no `:` prefix),
2050/// not the tatara-lisp author-surface [`UpgradeInstruction::lisp_form`]
2051/// axis (kebab-case, with `:` prefix): the two axes carry different
2052/// bytes by design, and Rust convention pairs [`std::fmt::Display`]
2053/// with the wire byte-string every serde-carried CR / structured-log /
2054/// catalog identity reaches. The two-axis split is preserved
2055/// structurally by the pin
2056/// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
2057/// so a future accidental collapse (routing `Display` through
2058/// [`Self::lisp_form`] via a mistaken match-arm re-inlining) trips at
2059/// caixa-core test time rather than silently merging the two axes at
2060/// some future consumer's per-instruction dispatch step.
2061///
2062/// Discards the per-variant scalar data (`module: String` on
2063/// `LoadModule` / `SoftPurge` / `Purge`; `script: PathBuf` on
2064/// `StateChange`) by design — the `Display` axis is the *tag*
2065/// projection, not a full value dump; consumers wanting the field
2066/// scalar reach for [`Self::declared_module`] /
2067/// [`Self::declared_path`] on the sibling scalar-accessor family. The
2068/// `{:?}` [`std::fmt::Debug`] derive stays untouched for callers that
2069/// want the full variant + field rendering.
2070impl std::fmt::Display for UpgradeInstruction {
2071 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2072 f.write_str(self.as_str())
2073 }
2074}
2075
2076/// Substrate-canonical [`AsRef<str>`] projection on the M2 OTP-appup
2077/// per-instruction [`UpgradeInstruction`] closed-set typed enum —
2078/// routes through the same [`UpgradeInstruction::as_str`]
2079/// `pub const fn` scalar accessor the paired [`std::fmt::Display`]
2080/// impl and the un-`rename`d [`serde::Serialize`] derive already key
2081/// off, so any future consumer that binds an [`UpgradeInstruction`]
2082/// through the standard-library `impl AsRef<str>` bound (a deferred
2083/// wasm-operator per-instruction structured-log recorder that accepts
2084/// `impl AsRef<str>` at the `tracing::field::Value` `Str`-arm, a
2085/// [`std::collections::HashMap`] lookup keyed on the instruction wire
2086/// byte through `map.get::<str>(instr.as_ref())` on a future
2087/// per-instruction dispatch table an M4 admission webhook composes,
2088/// a [`std::process::Command::arg`] shell-out threading the instruction
2089/// tag through a deferred `feira upgrade-from --dry-run <kind>` verb)
2090/// reaches the same kebab-case wire byte-string the
2091/// [`Self::as_str`] accessor returns through one substrate-primitive
2092/// dispatch rather than an open-coded `.as_str()` projection at
2093/// every wire-up.
2094///
2095/// Peer of the sibling [`std::fmt::Display`] impl on the same
2096/// primitive — both delegate to the shared
2097/// [`UpgradeInstruction::as_str`] `pub const fn` accessor, so
2098/// `format!("{v}")`, `v.as_str()`, and
2099/// `<UpgradeInstruction as AsRef<str>>::as_ref(&v)` resolve to the
2100/// same byte-string per instance by construction. A future variant
2101/// rename or `#[serde(rename_all = "…")]` attribute-drift on the enum
2102/// reaches every one of the three paths (plus the wire-format
2103/// `Serialize` derive that already routes through the same kebab
2104/// vocabulary and the [`gen_platform::Discriminant`]-derived
2105/// [`Self::discriminant`] catalog identity) through exactly one
2106/// caixa-core edit — the [`Self::as_str`] match arms.
2107///
2108/// Same "route the trait impl through the substrate-primitive
2109/// accessor" discipline the sibling
2110/// [`crate::supervisor::RestartStrategy`] [`AsRef<str>`] impl
2111/// (63eb1a4), [`crate::supervisor::RestartPolicy`] [`AsRef<str>`]
2112/// impl (419ea81), [`crate::aplicacao::PlacementStrategy`]
2113/// [`AsRef<str>`] impl (d86edd2), [`crate::CaixaKind`]
2114/// [`AsRef<str>`] impl (cd2091f), [`crate::aplicacao::RateLimitUnit`]
2115/// [`AsRef<str>`] impl (d8136db), and [`crate::CaixaVersion`]
2116/// [`AsRef<str>`] impl (16d5c7e) carry — closes the substrate
2117/// primitive's [`AsRef<str>`] projection axis onto the last M2
2118/// OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2119/// surface, so every closed-set typed enum on the caixa typed
2120/// surface now carries the paired [`AsRef<str>`] +
2121/// [`fmt::Display`] + `as_str` triple.
2122///
2123/// Pinned load-bearing by
2124/// [`tests::upgrade_instruction_as_ref_str_routes_through_as_str_accessor`]
2125/// — any future silent detour that routes the impl through a
2126/// divergent projection (a per-arm inline `match self { … }`
2127/// re-inlining that opens a compile-time link to the un-lifted arm-
2128/// literal, a swap onto the [`Self::lisp_form`] tatara-lisp axis
2129/// that would collide the wire axis with the author-surface axis)
2130/// trips at caixa-core test time under `assert_eq!` rather than at a
2131/// downstream `impl AsRef<str>`-bound consumer's silent split.
2132impl AsRef<str> for UpgradeInstruction {
2133 fn as_ref(&self) -> &str {
2134 self.as_str()
2135 }
2136}
2137
2138/// Reject upgrade instruction `:module` values that aren't K8s
2139/// DNS-1123 labels. Thin wrapper around
2140/// [`crate::render::is_dns_1123_label`] that maps the shared
2141/// parser-shaped reason into the kind-tagged
2142/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
2143/// diagnostics, so the author can grep their caixa.lisp for the
2144/// offending `(:<kind> <module>)` form and fix it in one edit.
2145///
2146/// The contract — the same DNS-1123 label rule the K8s apiserver
2147/// enforces on every `metadata.name` / Service name / label value the
2148/// module name lands in. Each upgrade instruction's `:module` is a
2149/// reference to a caixa name (the wasm-engine resolves it through the
2150/// same `ComputeUnit` registry the operator manages), so the value must
2151/// match every downstream apiserver-side schema: the per-Servico
2152/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
2153/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
2154/// against the loaded-module table at hot-upgrade dispatch, and the
2155/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
2156/// per-module reference axis. Same trajectory as `:children :caixa`
2157/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
2158/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
2159/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
2160///
2161/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
2162/// variant before this predicate is consulted, mirroring
2163/// `validate_membro_caixa`'s empty-first cascade.
2164fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
2165 // Routes through the shared
2166 // [`crate::render::require_valid_dns_1123_label`] gate the peer
2167 // name axes each land on. The `kind: &'static str` field flows
2168 // through both error variants so the diagnostic names which
2169 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
2170 // offending value came from.
2171 crate::render::require_valid_dns_1123_label(
2172 module,
2173 || UpgradeError::module_empty(kind),
2174 |reason| UpgradeError::module_invalid(kind, module, reason),
2175 )
2176}
2177
2178#[derive(Debug, Error, PartialEq, Eq)]
2179pub enum UpgradeError {
2180 #[error(
2181 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
2182 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
2183 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
2184 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
2185 the running version through `semver::Version::parse` and matches it against each entry's \
2186 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
2187 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2188 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2189 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2190 )]
2191 FromInvalid { from: String, reason: String },
2192 #[error(
2193 "upgrade instruction `{kind}` :module is empty (every appup module reference \
2194 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2195 the instruction entirely)"
2196 )]
2197 ModuleEmpty { kind: &'static str },
2198 #[error(
2199 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2200 {reason} (every appup module reference resolves to a caixa name, which lands \
2201 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2202 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2203 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2204 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2205 `\"cache-v2\"`)"
2206 )]
2207 ModuleInvalid {
2208 kind: &'static str,
2209 module: String,
2210 reason: String,
2211 },
2212 #[error("instruction's :script is empty")]
2213 EmptyScript,
2214 #[error(
2215 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2216 root (Path::join would otherwise escape the project sandbox)",
2217 script.display()
2218 )]
2219 AbsoluteScript { script: PathBuf },
2220 #[error(
2221 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2222 above the caixa root",
2223 script.display()
2224 )]
2225 ParentEscapeScript { script: PathBuf },
2226 #[error(
2227 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2228 wasm-engine instantiator reads every migration script as tatara-lisp source through \
2229 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2230 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2231 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2232 parser error far from the source caixa.lisp, with no field naming the offending \
2233 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2234 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2235 `\"lib/migrations/v01-to-v02.lisp\"`).",
2236 script.display()
2237 )]
2238 NonLispExtensionScript { script: PathBuf },
2239 #[error(
2240 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2241 one matching block per running version (`release_handler:install_release/1` dispatches \
2242 on the loaded `:from` against the currently-running release), so two entries with the \
2243 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2244 pick either set non-deterministically). Author one path per prior version; if two \
2245 distinct instruction sequences are needed, fold them into one ordered list under the \
2246 single matching `(:from {from:?} :instructions (…))` block."
2247 )]
2248 DuplicateFrom { from: String },
2249 #[error(
2250 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2251 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2252 greater than or equal to the caixa's own version is structurally unreachable \
2253 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2254 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2255 is never reached because the operator never runs a version greater than or equal to \
2256 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2257 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2258 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2259 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2260 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2261 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2262 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2263 here as a self-upgrade no-op."
2264 )]
2265 FromNotBeforeVersao { from: String, versao: String },
2266 #[error(
2267 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2268 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2269 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2270 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2271 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2272 component-model world incompatibility, irreversible state shape change), and the \
2273 fallback is terminal by construction (the operator restarts the pod and the new \
2274 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2275 in both directions: if the typed instructions would succeed, `(:restart)` is \
2276 unreached; if they wouldn't, the typed instructions are dead because the operator \
2277 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2278 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2279 never repeated. If two distinct upgrade strategies are needed for the same prior \
2280 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2281 dispatch picks exactly one block per running version) — keep the typed sequence; \
2282 the fallback restart is what the operator does on any typed-sequence failure \
2283 already."
2284 )]
2285 RestartNotExclusive {
2286 from: String,
2287 restart_count: usize,
2288 other_kinds: Vec<&'static str>,
2289 },
2290 #[error(
2291 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2292 `(:load-module …)` in its :instructions list — a state migration is the \
2293 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2294 code, but the operator executes instructions in declared order, so this migration \
2295 runs while the only resident version is still the prior one (which expects the \
2296 pre-migration state shape). Load the new module first: author the canonical \
2297 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2298 resident before its state migration runs.",
2299 script.display(),
2300 script.display()
2301 )]
2302 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2303 #[error(
2304 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2305 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2306 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2307 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2308 then `code:soft_purge/1`), but the operator executes instructions in declared \
2309 order, so this cleanup runs while the only resident version is still the same \
2310 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2311 mid-request), leaving no replacement to route in-flight or future requests \
2312 to. Load the new module first: author the canonical `(:load-module …) \
2313 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2314 before the old code is drained or discarded."
2315 )]
2316 PurgeWithoutPriorLoad {
2317 from: String,
2318 kind: &'static str,
2319 module: String,
2320 },
2321 #[error(
2322 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2323 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2324 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2325 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2326 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2327 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2328 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2329 module for this reason. A second cleanup on the same module is at best redundant (the \
2330 module is already gone after the first cleanup, so the second is a no-op or undefined \
2331 depending on the operator's handling of a non-resident-module purge request) and at \
2332 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2333 wanted a fallback, but the operator runs declared instructions unconditionally — \
2334 fallback on cleanup failure is the operator's job, not authored into the entry). \
2335 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2336 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2337 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2338 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2339 )]
2340 DuplicateCleanup {
2341 from: String,
2342 module: String,
2343 kinds: Vec<&'static str>,
2344 },
2345 #[error(
2346 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2347 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2348 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2349 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2350 dispatch table reads the module name and brings up the corresponding component \
2351 alongside the running version. systools-generated `.relup` files emit at most one \
2352 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2353 {module:?})` instruction has no observable semantic relative to the first (the \
2354 component is already resident) — either dead code (copy-pasted load line) or a typo \
2355 masking a distinct module the author intended to load alongside (renamed both to \
2356 {module:?} by mistake), leaving the second module silently absent from the entry. \
2357 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2358 versions need loading alongside the running one, name them distinctly (e.g. \
2359 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2360 )]
2361 DuplicateLoadModule { from: String, module: String },
2362 #[error(
2363 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2364 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2365 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2366 state shape into the current-version shape: a one-shot transition, not a step that \
2367 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2368 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2369 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2370 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2371 author intended two distinct migration scripts) and at worst silent state corruption \
2372 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2373 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2374 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2375 transitions are needed (e.g. one module's schema *and* another module's projection), \
2376 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2377 script.display(),
2378 script.display(),
2379 script.display(),
2380 script.display()
2381 )]
2382 DuplicateStateChange { from: String, script: PathBuf },
2383 #[error(
2384 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2385 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2386 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2387 current shape, but the prior version's state only exists while the prior code is \
2388 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2389 analogs and drain or discard that prior code. The operator executes instructions in \
2390 declared order, so a cleanup ahead of a state-change has already drained the prior \
2391 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2392 the migration script runs, leaving the script either no-op (no prior-version state \
2393 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2394 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2395 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2396 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2397 strictly between load and cleanup. Author the canonical `(:load-module …) \
2398 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2399 migration runs against the prior-version state before the cleanup drains it.",
2400 script.display(),
2401 script.display()
2402 )]
2403 StateChangeAfterCleanup {
2404 from: String,
2405 script: PathBuf,
2406 prior_cleanup_kind: &'static str,
2407 prior_cleanup_module: String,
2408 },
2409 #[error(
2410 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2411 declare `:behavior :on-state-change` — the per-version migration script is the \
2412 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2413 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2414 realizes the composition by invoking the running gen_server's code_change/3 callback \
2415 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2416 composition into two typed slots, the per-version migration logic in this \
2417 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2418 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2419 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2420 migration during hot upgrades\"). The missing callback leaves the per-version script \
2421 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2422 callback at the migration step, finds it absent, and either fails the upgrade \
2423 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2424 current version stays load-bearing\") or silently skips the migration leaving the \
2425 new code running against unmigrated prior-version state. Add the callback: \
2426 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2427 path) alongside the existing `(:state-change {})` instruction (the per-version \
2428 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2429 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2430 migration — is the canonical shape).",
2431 script.display(),
2432 script.display()
2433 )]
2434 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2435}
2436
2437// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2438// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2439// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2440// two-slot struct-variant wire-up sites at
2441// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2442// / `script` from `instr.declared_path()`),
2443// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2444// (`self.prior_versao()` / `script.as_path()` from
2445// `instr.declared_path()`), and
2446// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2447// / `script` from `instr.declared_path()`) onto one substrate primitive
2448// per typed variant — the paired `{ from: String, script: PathBuf }`
2449// two-slot sibling on [`UpgradeError`] of the peer
2450// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2451// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2452// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2453// (8580068, 4 variants on `{ de, para }`),
2454// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2455// `{ de, para, wit, expected }`),
2456// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2457// variants on `{ <field>: String, reason: String }`), and
2458// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2459// variants on `{ de, para, <field>: String, reason: String }`) on the
2460// sibling `AplicacaoError` envelopes, and the peer
2461// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2462// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2463// (0419438, 4 variants on `{ caixa, kind, slots }`),
2464// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2465// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2466// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2467// `LayoutError` envelopes, plus the peer
2468// [`crate::limits::limits_codec_value_only_ctors!`] /
2469// [`crate::limits::limits_codec_value_byte_ctors!`] /
2470// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2471// wire-ups) on the sibling `LimitsError` envelopes.
2472//
2473// Each of the three wire-up sites on this shape opens the identical
2474// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2475// script: <script>.to_path_buf() }` struct-literal against a local
2476// `prior_versao()` and `declared_path()` accessor pair — the exact
2477// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2478// names as a bug, on the same altitude the peer `SupervisorError` /
2479// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2480// closed on their sibling envelopes. The three variants share one
2481// `{ from: String, script: PathBuf }` shape, so the fold routes each
2482// wire-up site through one dispatch per typed variant.
2483//
2484// The macro below generates one `#[must_use]` inherent constructor per
2485// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2486// Self`, so every wire-up site collapses onto one dispatch:
2487// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2488// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2489// uniform two-field construction (`from.to_string()` /
2490// `script.to_path_buf()`) is spelled once — inside the macro — rather
2491// than at every wire-up site. The `&Path` parameter accepts both
2492// `&Path` (from `script.as_path()` at the uniqueness gate) and
2493// `&PathBuf` (from `instr.declared_path()` at the ordering /
2494// callback-declaration gates, via Deref coercion), so every existing
2495// wire-up threads through the ctor without a pre-conversion.
2496//
2497// Every future consumer that wants to construct one of these three
2498// variants outside the three in-crate `UpgradeFromEntry` /
2499// `validate_state_change_on_state_change_callback` gates (a deferred
2500// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2501// re-checker at hot-upgrade dispatch time, a future
2502// `feira validate --upgrade-from` per-caixa admission verb re-checking
2503// the three axes, a per-`Caixa` overlay resolver rejecting an
2504// ordering / uniqueness / callback-declaration invariant against a
2505// cluster-local snapshot) now reaches each variant through one call
2506// rather than re-inlining the three-line struct-literal in lockstep
2507// with the three in-crate wire-up sites.
2508macro_rules! upgrade_from_script_ctors {
2509 ($($ctor:ident => $variant:ident),* $(,)?) => {
2510 impl UpgradeError {
2511 $(
2512 #[doc = concat!(
2513 "Construct an [`UpgradeError::",
2514 stringify!($variant),
2515 "`] naming the offending `(:from <prior-versao>)` and ",
2516 "`(:state-change <script>)` pair. Folds the uniform ",
2517 "`Self::",
2518 stringify!($variant),
2519 " { from: from.to_string(), script: script.to_path_buf() }` ",
2520 "two-field struct-literal onto one substrate primitive so ",
2521 "every wire-up on this variant reads through one dispatch ",
2522 "rather than the pre-lift three-line open-coded block. The ",
2523 "`from` string threads verbatim from ",
2524 "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2525 "from [`UpgradeInstruction::declared_path`] at the call site."
2526 )]
2527 #[must_use]
2528 pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2529 Self::$variant {
2530 from: from.to_string(),
2531 script: script.to_path_buf(),
2532 }
2533 }
2534 )*
2535 }
2536 };
2537}
2538
2539upgrade_from_script_ctors! {
2540 state_change_without_prior_load => StateChangeWithoutPriorLoad,
2541 duplicate_state_change => DuplicateStateChange,
2542 state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2543}
2544
2545// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2546// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2547// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2548// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2549// onto one substrate primitive per typed variant — the paired
2550// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2551// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2552// `{ from: String, script: PathBuf }`) two-slot family on the same
2553// envelope, and of the peer
2554// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2555// variants on `{ caixa: String }`) and
2556// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2557// `{ nome: String }`) single-slot families on the sibling
2558// `SupervisorError` / `DepError` envelopes, and of the peer
2559// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2560// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2561// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2562// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2563// variants on `{ <field>: String, reason: String }`), and
2564// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2565// variants on `{ de, para, <field>: String, reason: String }`) on the
2566// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2567// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2568// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2569// [`crate::LayoutError::missing_entry`] 1b09f9d;
2570// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2571// `LimitsError` codec families (81c856c), and the sibling
2572// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2573// `{ nome, caminho }`) two-slot family.
2574//
2575// The three wire-up sites this fold closes are the three closures
2576// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2577// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2578// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2579// passed to [`crate::render::require_sandboxed_lisp_path`] at
2580// [`UpgradeInstruction::validate`] — each opens the identical
2581// `UpgradeError::<Variant> { script: script.clone() }` three-line
2582// struct-literal against the same `script: &PathBuf` local threaded
2583// from [`UpgradeInstruction::declared_path`], the exact "same block
2584// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2585// bug. The three variants share one `{ script: PathBuf }` shape, so
2586// the fold routes each closure through one dispatch per typed variant.
2587// The sibling `EmptyScript` unit-variant on the same envelope stays on
2588// its pre-lift open-coded shape — it carries no `script` field (the
2589// offending `:script` value *is* the empty path this variant catches),
2590// so the uniform `fn(script: &Path) -> Self` signature this macro
2591// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2592// closure is already a one-liner. This is the second fold family on
2593// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2594// two-slot family established in 8e67041, which explicitly named this
2595// `{ script: PathBuf }` single-slot family as the next fold to land
2596// on the envelope; per that commit's coverage roster, both of the two
2597// most-populated shapes on `UpgradeError` — the two-slot
2598// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2599//
2600// The macro below generates one `#[must_use]` inherent constructor per
2601// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2602// every closure collapses onto one dispatch:
2603// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2604// struct-literal on the same `&Path` fixture. The uniform one-field
2605// construction (`script.to_path_buf()`) is spelled once — inside the
2606// macro — rather than at every wire-up site. The `&Path` parameter
2607// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2608// `instr.declared_path()` at the three closures, via Deref coercion),
2609// so every existing closure threads through the ctor without a
2610// pre-conversion.
2611//
2612// Every future consumer that wants to construct one of these three
2613// variants outside the three in-crate closures (a deferred
2614// wasm-operator's `install_release/1` per-instruction script-shape
2615// re-checker at hot-upgrade dispatch time, a future
2616// `feira validate --upgrade-from` per-caixa admission verb re-checking
2617// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2618// an author-supplied `:state-change :script` against a cluster-local
2619// snapshot) now reaches each variant through one call rather than
2620// re-inlining the three-line struct-literal in lockstep with the three
2621// in-crate closure sites.
2622macro_rules! upgrade_script_only_ctors {
2623 ($($ctor:ident => $variant:ident),* $(,)?) => {
2624 impl UpgradeError {
2625 $(
2626 #[doc = concat!(
2627 "Construct an [`UpgradeError::",
2628 stringify!($variant),
2629 "`] naming the offending `(:state-change <script>)`. ",
2630 "Folds the uniform `Self::",
2631 stringify!($variant),
2632 " { script: script.to_path_buf() }` one-field ",
2633 "struct-literal onto one substrate primitive so every ",
2634 "closure passed to ",
2635 "[`crate::render::require_sandboxed_lisp_path`] at ",
2636 "[`UpgradeInstruction::validate`] on this variant reads ",
2637 "through one dispatch rather than the pre-lift three-line ",
2638 "open-coded block. The `script` path threads verbatim ",
2639 "from [`UpgradeInstruction::declared_path`] at the call ",
2640 "site."
2641 )]
2642 #[must_use]
2643 pub fn $ctor(script: &std::path::Path) -> Self {
2644 Self::$variant {
2645 script: script.to_path_buf(),
2646 }
2647 }
2648 )*
2649 }
2650 };
2651}
2652
2653upgrade_script_only_ctors! {
2654 absolute_script => AbsoluteScript,
2655 parent_escape_script => ParentEscapeScript,
2656 non_lisp_extension_script => NonLispExtensionScript,
2657}
2658
2659// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2660// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2661// <value>.to_string() }` two-slot struct-variant wire-up sites at
2662// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2663// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2664// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2665// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2666// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2667// self.prior_versao().to_string(), module: module.to_string() });`),
2668// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2669// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2670// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2671// });`) onto one substrate-primitive family per typed variant — the
2672// missing paired two-slot rung on the `UpgradeError`-side four-family
2673// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2674// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2675// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2676// script: PathBuf }`), and mirror-symmetric sibling of the peer
2677// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2678// String, <axis>: String }` fold on the `DepError` envelope — same
2679// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2680// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2681// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2682// carries the offending prior-version `:from` verbatim so the author
2683// can grep their caixa.lisp for the offending `(:from "<value>")` /
2684// `(:load-module …)` / `:versao` block in one edit). The three
2685// variants share the same `{ from: String, <axis>: String }` two-slot
2686// shape: the `from` field names the offending per-`:upgrade-from` block's
2687// prior-version tag the diagnostic points the author back at, and the
2688// middle `<axis>: String` field carries the offending per-envelope axis
2689// value verbatim (`reason` on `FromInvalid` carries the wrapped
2690// `semver::Version::parse` error message that pinpoints why the tag
2691// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2692// own current-`:versao` the entry's `:from` failed to precede; `module`
2693// on `DuplicateLoadModule` carries the caixa name the second
2694// `(:load-module …)` instruction re-loaded within the same entry).
2695// The middle axis-field name differs across variants (`reason` /
2696// `versao` / `module`) so the ctor family below takes the axis field
2697// name as a macro parameter (`$axis:ident`) alongside the ctor +
2698// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2699// -> Self` inherent constructor per typed variant that spells the
2700// uniform two-field construction (`from.to_string()` /
2701// `<axis>.to_string()`) exactly once.
2702//
2703// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2704// variants on `{ from: String, script: PathBuf }`) two-slot family on
2705// the same envelope — both key off the same `from: String` axis at the
2706// same per-`:upgrade-from :from`-owned altitude; this family carries the
2707// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2708// carrier) where the script-slot family carries the owned-`PathBuf`
2709// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2710// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2711// same envelope, of the sibling
2712// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2713// variants on `{ caixa: String }`) and
2714// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2715// `{ nome: String }`) single-slot families on the sibling
2716// `SupervisorError` / `DepError` envelopes, and of the peer
2717// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2718// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2719// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2720// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2721// variants on `{ <field>: String, reason: String }`),
2722// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2723// variants on `{ caixa: String }`),
2724// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2725// on `{ path: String }`), and
2726// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2727// variants on `{ de, para, <field>: String, reason: String }`) on the
2728// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2729// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2730// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2731// [`crate::LayoutError::missing_entry`] 1b09f9d;
2732// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2733// `LimitsError` codec families (81c856c), the sibling
2734// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2735// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2736// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2737// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2738// `{ nome, list: &'static str }`), and
2739// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2740// `{ nome, <axis>: String, reason: String }`) families.
2741//
2742// Each of the three wire-up sites on this shape opens the identical
2743// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2744// <value>.to_string() }` four-line struct-literal against a local
2745// `(prior_versao(), <axis-value>)` pair threaded from
2746// [`UpgradeFromEntry::prior_versao`] (or, at the
2747// [`validate_upgrade_from_against_versao`] site, directly from the
2748// caller-supplied `versao: &str` argument) — the exact "same block
2749// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2750// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2751// / `upgrade_script_only_ctors!` families closed on the sibling
2752// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2753// axis-field discriminators are the only things that vary between them;
2754// the rest of the struct-literal is a byte-for-byte re-inline.
2755//
2756// The macro below generates one `#[must_use]` inherent constructor per
2757// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2758// every wire-up site collapses onto one dispatch:
2759// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2760// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2761// parameters accept `&str` literals and `&String` (via Deref coercion)
2762// so every existing wire-up threads through the ctor without a
2763// pre-conversion.
2764//
2765// Every future consumer that wants to construct one of these three
2766// variants outside the three in-crate `UpgradeFromEntry::validate` /
2767// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2768// gates (a deferred wasm-operator's `install_release/1` per-entry
2769// `:from`-parse / per-`:load-module` singularity / per-entry
2770// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2771// `feira validate --upgrade-from` per-caixa admission verb re-checking
2772// the three axes, a per-`Caixa` overlay resolver rejecting a
2773// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2774// invariant against a cluster-local snapshot) now reaches each variant
2775// through one call rather than re-inlining the four-line struct-literal
2776// in lockstep with the three in-crate wire-up sites.
2777macro_rules! upgrade_from_axis_ctors {
2778 ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2779 impl UpgradeError {
2780 $(
2781 #[doc = concat!(
2782 "Construct an [`UpgradeError::",
2783 stringify!($variant),
2784 "`] naming the offending `(:from <prior-versao>)` and ",
2785 "the offending `:", stringify!($axis), "` axis value. ",
2786 "Folds the uniform `Self::",
2787 stringify!($variant),
2788 " { from: from.to_string(), ",
2789 stringify!($axis),
2790 ": ",
2791 stringify!($axis),
2792 ".to_string() }` two-field struct-literal onto one ",
2793 "substrate primitive so every in-crate wire-up on ",
2794 "this variant reads through one dispatch rather than ",
2795 "the pre-lift four-line open-coded block. Both `from: ",
2796 "&str` and `",
2797 stringify!($axis),
2798 ": &str` parameters accept `&str` literals and ",
2799 "`&String` (via Deref coercion) so every existing ",
2800 "wire-up threads through the ctor without a pre-",
2801 "conversion."
2802 )]
2803 #[must_use]
2804 pub fn $ctor(from: &str, $axis: &str) -> Self {
2805 Self::$variant {
2806 from: from.to_string(),
2807 $axis: $axis.to_string(),
2808 }
2809 }
2810 )*
2811 }
2812 };
2813}
2814
2815upgrade_from_axis_ctors! {
2816 from_invalid => FromInvalid { reason },
2817 from_not_before_versao => FromNotBeforeVersao { versao },
2818 duplicate_load_module => DuplicateLoadModule { module },
2819}
2820
2821// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2822// entry.prior_versao().to_string() }` one-slot struct-literal inside
2823// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2824// one substrate primitive on the [`UpgradeError`] envelope, projecting
2825// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2826// on the substrate primitive. The `DuplicateFrom` variant is the last
2827// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
2828// every peer envelope shape (`{ script: PathBuf }` one-slot via
2829// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
2830// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
2831// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
2832// 8e67041) already reads through one substrate-primitive dispatch, so
2833// this fold closes the last one-off single-slot on the envelope.
2834//
2835// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
2836// (b30edfe) on the paired [`WitContract`] projection — same
2837// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
2838// through the substrate primitive's own scalar accessor rather than
2839// re-inlining the `.to_string()` at the call site. Extended here onto
2840// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
2841// M2 companion of the M3 mesh-slot accessors (see
2842// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
2843// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
2844// 4a32abf, and the [`crate::WitContract::{source, destination,
2845// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
2846// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
2847//
2848// The one wire-up site this fold closes opens the identical
2849// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
2850// three-line struct-literal against the `entry: &UpgradeFromEntry` local
2851// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
2852// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2853// names as a bug, on the same altitude the peer `contrato_self_loop`
2854// closed on the sibling `{ caixa: String, wit: String }` two-slot
2855// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
2856// parameter accepts the borrowed entry verbatim so the wire-up site
2857// threads through the ctor without a pre-projection — the ctor body
2858// spells the paired `prior_versao().to_string()` projection once.
2859//
2860// Every future consumer that wants to construct this variant outside
2861// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
2862// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
2863// re-checker at hot-upgrade dispatch time rejecting a second entry
2864// with the same prior-versao tag, a future `feira validate --upgrade-
2865// from` per-caixa admission verb re-running the cross-entry duplicate
2866// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
2867// supplied duplicate `(:from "<value>")` against a cluster-local
2868// snapshot — now reaches the variant through one call rather than
2869// re-inlining the three-line struct-literal in lockstep with the one
2870// in-crate wire-up site.
2871impl UpgradeError {
2872 /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
2873 /// duplicate `(:from <prior-versao>)` entry, projecting through the
2874 /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
2875 /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
2876 /// from: entry.prior_versao().to_string() }` one-field struct-literal
2877 /// onto one substrate primitive so every wire-up on this variant
2878 /// reads through one dispatch, matching the sibling
2879 /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
2880 /// substrate-primitive-projection ctor's shape on the peer
2881 /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
2882 /// parameter accepts the borrowed entry verbatim so the paired
2883 /// `prior_versao().to_string()` projection is spelled once — inside
2884 /// the ctor body — rather than at every wire-up site.
2885 #[must_use]
2886 pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
2887 Self::DuplicateFrom {
2888 from: entry.prior_versao().to_string(),
2889 }
2890 }
2891
2892 /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
2893 /// offending `(:from <prior-versao>)` entry, the offending cleanup
2894 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
2895 /// its `:module` target. Folds the uniform
2896 /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
2897 /// module: module.to_string() }` three-field struct-literal onto one
2898 /// substrate primitive so every wire-up on this sole-variant
2899 /// cleanup-family load-before-cleanup ordering-refusal envelope reads
2900 /// through one dispatch rather than the pre-lift seven-line
2901 /// open-coded block.
2902 ///
2903 /// The `from: &str` parameter accepts `&str` literals and `&String`
2904 /// via Deref coercion so the sole in-crate wire-up site threads
2905 /// [`UpgradeFromEntry::prior_versao`] verbatim without a
2906 /// pre-conversion. The `kind: &'static str` parameter accepts the
2907 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2908 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2909 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
2910 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
2911 /// re-projection at the ctor path. The `module: &str` parameter
2912 /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
2913 /// via `.expect("is_cleanup() implies declared_module() is Some")`
2914 /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
2915 /// `Some` composition pin at
2916 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
2917 /// makes the `.expect(…)` structurally infallible at build time.
2918 ///
2919 /// Peer of the sibling one-off standalone-ctor
2920 /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
2921 /// String }` envelope on the same `UpgradeError` envelope, and of
2922 /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
2923 /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
2924 /// variant standalone ctor on the peer `AplicacaoError` envelope.
2925 /// Closes the last unlifted `{ from: String, kind: &'static str,
2926 /// module: String }` three-slot open-coded struct-literal wire-up
2927 /// on the OTP-appup load-before-cleanup ordering axis, sibling of
2928 /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
2929 /// (41d08db, three variants on `{ from: String, <axis>: String }`)
2930 /// on the paired ordering / uniqueness / callback-declaration axes,
2931 /// and of the peer standalone [`UpgradeError::duplicate_from`]
2932 /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
2933 /// `:from` gate. Every future consumer that raises this refusal
2934 /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
2935 /// wasm-operator's `install_release/1` per-entry load-before-cleanup
2936 /// re-checker at hot-upgrade dispatch time, a future
2937 /// `feira validate --upgrade-from` per-caixa admission verb
2938 /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
2939 /// overlay resolver rejecting a cluster-local `:soft-purge` /
2940 /// `:purge` overlay lacking a preceding `:load-module` — reaches
2941 /// the variant through one call rather than re-inlining the
2942 /// seven-line struct-literal in lockstep with the sole in-crate
2943 /// wire-up site.
2944 #[must_use]
2945 pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
2946 Self::PurgeWithoutPriorLoad {
2947 from: from.to_string(),
2948 kind,
2949 module: module.to_string(),
2950 }
2951 }
2952
2953 /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
2954 /// offending `(:from <prior-versao>)` entry, the offending
2955 /// `(:state-change …)` `:script` path, and the prior cleanup
2956 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
2957 /// `:module` target. Folds the uniform
2958 /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
2959 /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
2960 /// prior_cleanup_module.to_string() }` four-field struct-literal
2961 /// onto one substrate primitive so every wire-up on this sole-
2962 /// variant migrate-after-cleanup ordering-refusal envelope reads
2963 /// through one dispatch rather than the pre-lift seven-line open-
2964 /// coded block. Closes the last unlifted `{ from: String, script:
2965 /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
2966 /// String }` four-slot open-coded struct-literal wire-up on the
2967 /// OTP-appup migrate-before-cleanup ordering axis, filling the
2968 /// missing four-slot rung on the `UpgradeError`-side ctor-family
2969 /// ladder alongside the sibling one-slot
2970 /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
2971 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
2972 /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
2973 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
2974 /// families, and the one-slot [`upgrade_script_only_ctors!`]
2975 /// (7468ca9) family. Sole in-crate wire-up site is inside
2976 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
2977 /// migrate-family sticky-latch dispatch — the third of three
2978 /// within-entry cross-instruction OTP-appup ordering gates the
2979 /// module doc pins (`validate_state_change_ordering` on the load →
2980 /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
2981 /// `state_change_without_prior_load`; `validate_purge_ordering` on
2982 /// the load → cleanup boundary via `purge_without_prior_load`;
2983 /// `validate_state_change_before_cleanup` on the migrate → cleanup
2984 /// boundary via this ctor — now).
2985 ///
2986 /// The `from: &str` parameter accepts `&str` literals and `&String`
2987 /// via Deref coercion so the sole in-crate wire-up site threads
2988 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
2989 /// without a pre-conversion. The `script: &std::path::Path`
2990 /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
2991 /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
2992 /// via Deref coercion) so the wire-up threads the sticky-latch
2993 /// script projection through the ctor without a pre-conversion; the
2994 /// uniform `script.to_path_buf()` one-field construction is spelled
2995 /// once — inside the ctor body — rather than at every wire-up site.
2996 /// The `prior_cleanup_kind: &'static str` parameter accepts the
2997 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
2998 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
2999 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3000 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3001 /// re-projection at the ctor path. The `prior_cleanup_module: &str`
3002 /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
3003 /// returns via `.expect("is_cleanup() implies declared_module() is
3004 /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
3005 /// is-`Some` composition pin at
3006 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3007 /// makes the `.expect(…)` structurally infallible at build time.
3008 ///
3009 /// Every future consumer that raises this refusal outside
3010 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
3011 /// deferred wasm-operator's `install_release/1` per-entry
3012 /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
3013 /// a future `feira validate --upgrade-from` per-caixa admission verb
3014 /// re-running the migrate-before-cleanup gate on demand, a
3015 /// per-`Caixa` overlay resolver rejecting a cluster-local
3016 /// `:state-change` overlay authored after a `:soft-purge` /
3017 /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
3018 /// webhook re-checking a per-`:upgrade-from`-patched candidate
3019 /// before the migrate-before-cleanup gate re-fires — reaches the
3020 /// variant through one call rather than re-inlining the seven-line
3021 /// struct-literal in lockstep with the sole in-crate wire-up site.
3022 #[must_use]
3023 pub fn state_change_after_cleanup(
3024 from: &str,
3025 script: &std::path::Path,
3026 prior_cleanup_kind: &'static str,
3027 prior_cleanup_module: &str,
3028 ) -> Self {
3029 Self::StateChangeAfterCleanup {
3030 from: from.to_string(),
3031 script: script.to_path_buf(),
3032 prior_cleanup_kind,
3033 prior_cleanup_module: prior_cleanup_module.to_string(),
3034 }
3035 }
3036
3037 /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
3038 /// offending `(:from <prior-versao>)` entry, the colliding `:module`
3039 /// target, and the ordered pair of colliding cleanup `:kind` lisp-
3040 /// forms (`:soft-purge` / `:purge`). Folds the uniform
3041 /// `Self::DuplicateCleanup { from: from.to_string(), module:
3042 /// module.to_string(), kinds }` three-field struct-literal onto one
3043 /// substrate primitive so every wire-up on this sole-variant within-
3044 /// entry per-module cleanup-singularity refusal envelope reads
3045 /// through one dispatch rather than the pre-lift five-line open-coded
3046 /// block. Closes the last unlifted `{ from: String, module: String,
3047 /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
3048 /// wire-up on the OTP-appup per-module cleanup-singularity axis,
3049 /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
3050 /// family ladder alongside the sibling three-slot
3051 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3052 /// ctor on the paired within-entry load → cleanup ordering axis, the
3053 /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
3054 /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
3055 /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
3056 /// standalone ctor on the migrate → cleanup boundary, the two-slot
3057 /// [`upgrade_from_axis_ctors!`] (41d08db) /
3058 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3059 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3060 /// Sole in-crate wire-up site is inside
3061 /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
3062 /// cleanup-family dedup arm.
3063 ///
3064 /// The `from: &str` parameter accepts `&str` literals and `&String`
3065 /// via Deref coercion so the sole in-crate wire-up threads
3066 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3067 /// without a pre-conversion. The `module: &str` parameter takes the
3068 /// `&str` [`UpgradeInstruction::declared_module`] returns via
3069 /// `.expect("is_cleanup() implies declared_module() is Some")` at the
3070 /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
3071 /// composition pin at
3072 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3073 /// makes the `.expect(…)` structurally infallible at build time. The
3074 /// `kinds: Vec<&'static str>` parameter takes the ordered pair
3075 /// `vec![prior_kind, kind]` built at the caller from the two
3076 /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
3077 /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3078 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
3079 /// primitive `&'static str` projection the paired three-slot
3080 /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
3081 /// sibling load → cleanup ordering axis.
3082 ///
3083 /// Every future consumer that raises this refusal outside
3084 /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
3085 /// wasm-operator's `install_release/1` per-entry per-module
3086 /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
3087 /// future `feira validate --upgrade-from` per-caixa admission verb
3088 /// re-running the singularity pass on demand, a per-`Caixa` overlay
3089 /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
3090 /// overlay that collides with a base-entry cleanup on the same
3091 /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3092 /// re-checking a per-`:upgrade-from`-patched candidate before the
3093 /// singularity gate re-fires — reaches the variant through one call
3094 /// rather than re-inlining the five-line struct-literal in lockstep
3095 /// with the sole in-crate wire-up site.
3096 #[must_use]
3097 pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
3098 Self::DuplicateCleanup {
3099 from: from.to_string(),
3100 module: module.to_string(),
3101 kinds,
3102 }
3103 }
3104
3105 /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
3106 /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
3107 /// instruction count, and the ordered list of non-`:restart`
3108 /// instruction lisp-forms the entry mixed with the terminal fallback.
3109 /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
3110 /// restart_count, other_kinds }` three-field struct-literal onto one
3111 /// substrate primitive so every wire-up on this sole-variant within-
3112 /// entry `(:restart)`-exclusivity refusal envelope reads through one
3113 /// dispatch rather than the pre-lift five-line open-coded block. Closes
3114 /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
3115 /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
3116 /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
3117 /// the last-remaining open-coded emission site the sibling
3118 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
3119 /// the natural next lift on the `UpgradeError` envelope. Fills a peer
3120 /// three-slot rung on the `UpgradeError`-side ctor-family ladder
3121 /// alongside the sibling three-slot
3122 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3123 /// ctor on the paired within-entry load → cleanup ordering axis and
3124 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
3125 /// the per-module cleanup-singularity axis, the one-slot
3126 /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
3127 /// cross-entry duplicate-`:from` gate, the four-slot
3128 /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
3129 /// ctor on the migrate → cleanup boundary, the two-slot
3130 /// [`upgrade_from_axis_ctors!`] (41d08db) /
3131 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3132 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3133 /// Sole in-crate wire-up site is inside
3134 /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
3135 /// arm.
3136 ///
3137 /// The `from: &str` parameter accepts `&str` literals and `&String`
3138 /// via Deref coercion so the sole in-crate wire-up threads
3139 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3140 /// without a pre-conversion. The `restart_count: usize` parameter
3141 /// takes the observed `(:restart)` occurrence count built at the
3142 /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
3143 /// — the same `IsVariant`-derived arm-discriminator dispatch the
3144 /// paired `other_kinds` projection routes through — so the diagnostic
3145 /// surfaces the duplication mode unambiguously even when `other_kinds`
3146 /// is empty (the `((:restart) (:restart))` shape the sibling
3147 /// `validate_rejects_restart_duplicated` test pins with
3148 /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
3149 /// Vec<&'static str>` parameter takes the ordered list of non-
3150 /// `:restart` instruction lisp-forms built at the caller from
3151 /// `instructions.iter().filter(|i| !i.is_restart()).map(
3152 /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
3153 /// primitive `&'static str` projection the peer three-slot
3154 /// [`UpgradeError::purge_without_prior_load`] /
3155 /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
3156 /// within-entry cleanup axes.
3157 ///
3158 /// Every future consumer that raises this refusal outside
3159 /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
3160 /// wasm-operator's `install_release/1` per-entry `(:restart)`-
3161 /// exclusivity re-checker at hot-upgrade dispatch time, a future
3162 /// `feira validate --upgrade-from` per-caixa admission verb re-running
3163 /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
3164 /// rejecting a cluster-local `(:restart)` overlay that mixes with a
3165 /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
3166 /// CR admission webhook re-checking a per-`:upgrade-from`-patched
3167 /// candidate before the exclusivity gate re-fires — reaches the
3168 /// variant through one call rather than re-inlining the five-line
3169 /// struct-literal in lockstep with the sole in-crate wire-up site.
3170 #[must_use]
3171 pub fn restart_not_exclusive(
3172 from: &str,
3173 restart_count: usize,
3174 other_kinds: Vec<&'static str>,
3175 ) -> Self {
3176 Self::RestartNotExclusive {
3177 from: from.to_string(),
3178 restart_count,
3179 other_kinds,
3180 }
3181 }
3182
3183 /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
3184 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3185 /// `:purge`), the malformed `:module` value, and the parser-shaped
3186 /// `reason` from
3187 /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3188 /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3189 /// three-field struct-literal onto one substrate primitive so every
3190 /// wire-up on this variant reads through one dispatch rather than the
3191 /// pre-lift open-coded closure block inside [`validate_module`]'s
3192 /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3193 ///
3194 /// The `kind: &'static str` parameter accepts the lisp-form
3195 /// [`UpgradeInstruction::lisp_form`] returns for the three
3196 /// [`UpgradeInstruction::declared_module`]-bearing arms —
3197 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3198 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3199 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3200 /// without a per-arm re-projection at the ctor path. The `module: &str`
3201 /// parameter threads the offending author-supplied `:module` value
3202 /// verbatim from [`UpgradeInstruction::declared_module`]. The
3203 /// `reason: impl Into<String>` bound accepts both `&str` literals and
3204 /// the `String` [`crate::render::is_dns_1123_label`] returns via
3205 /// `.into()`, matching the peer
3206 /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3207 /// [`crate::SupervisorError::child_caixa_invalid`] /
3208 /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3209 /// three-slot invalid-arm ctor discipline on the sibling
3210 /// DNS-1123-label per-envelope shape.
3211 ///
3212 /// Peer of the sibling standalone-ctor
3213 /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3214 /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3215 /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3216 /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3217 /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3218 /// [`validate_module`]'s cascade now reads through one substrate
3219 /// primitive on the invalid-arm rather than an open-coded four-line
3220 /// struct-literal in lockstep with the sole in-crate wire-up site.
3221 ///
3222 /// Every future consumer that raises this refusal outside
3223 /// [`validate_module`] — a deferred wasm-operator's
3224 /// `install_release/1` per-instruction `:module` re-validator at
3225 /// hot-upgrade dispatch time re-running the same DNS-1123-label
3226 /// floor against a candidate module reference, a future
3227 /// `feira validate --upgrade-from` per-caixa admission verb
3228 /// re-running the module-shape gate on demand, an M4
3229 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3230 /// per-`:upgrade-from`-patched candidate before the module-shape
3231 /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3232 /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3233 /// overlay against a cluster-local snapshot — now reaches this
3234 /// variant through one call rather than re-inlining the four-line
3235 /// struct-literal in lockstep with the [`validate_module`]
3236 /// closure-form wire-up.
3237 #[must_use]
3238 pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3239 Self::ModuleInvalid {
3240 kind,
3241 module: module.to_string(),
3242 reason: reason.into(),
3243 }
3244 }
3245
3246 /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3247 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3248 /// `:purge`) at which the appup module reference is the empty
3249 /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3250 /// struct-literal onto one substrate primitive so the sole in-crate
3251 /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3252 /// at [`validate_module`] on this variant reads through one dispatch
3253 /// rather than the pre-lift open-coded block. The `kind` label
3254 /// threads verbatim from the caller-side
3255 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3256 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3257 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3258 /// roster the wire-up feeds through [`validate_module`]'s
3259 /// `kind: &'static str` parameter.
3260 ///
3261 /// Sibling of the paired three-slot [`Self::module_invalid`]
3262 /// (3d0d64a) substrate primitive on the same
3263 /// [`crate::render::require_valid_dns_1123_label`] two-closure
3264 /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3265 /// now both reach the `UpgradeError` envelope through one substrate
3266 /// primitive per typed variant, closing the pair on the OTP-appup
3267 /// per-instruction `:module` caixa-reference axis. Same shape
3268 /// discipline as the peer
3269 /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3270 /// one-slot `{ slot: &'static str }` sibling that closed the peer
3271 /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3272 /// cascade at the `:contratos <slot>` per-edge axis
3273 /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3274 /// "one substrate primitive per typed arm on both sides of a
3275 /// `require_valid_dns_1123_label` two-closure cascade, projecting
3276 /// through the caller-supplied axis-tag" discipline now extended
3277 /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3278 /// of the pair the M3 (`:contratos <slot>`) side already carries.
3279 ///
3280 /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3281 /// :instructions <kind>` tag comes from the
3282 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3283 /// carrying program-lifetime storage, matching the enum-field type
3284 /// and the [`validate_module`] wire-up's per-arm dispatch. A
3285 /// runtime-borrowed `&str` would silently downgrade the label
3286 /// lifetime and let a caller stash a non-`'static` borrow into the
3287 /// returned error. `#[must_use]` fires a compile warning at any
3288 /// wire-up that mistakenly discards the constructed error rather
3289 /// than routing it through `return Err(…)` / `.map_err(…)` / a
3290 /// closure return. `pub const fn` matches the peer per-envelope
3291 /// one-slot `Copy`-scalar ctor family discipline
3292 /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3293 /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3294 /// ctor is usable in `const` position at every wire-up site.
3295 ///
3296 /// Every future consumer that constructs `ModuleEmpty` outside
3297 /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3298 /// closure — a deferred wasm-operator's `install_release/1`
3299 /// per-instruction `:module` re-validator at hot-upgrade dispatch
3300 /// time re-running the same empty-arm floor against a candidate
3301 /// module reference, a future `feira validate --upgrade-from`
3302 /// per-caixa admission verb re-running the empty-module gate on
3303 /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3304 /// re-checking a per-`:upgrade-from`-patched candidate before the
3305 /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3306 /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3307 /// overlay against a cluster-local snapshot — now reaches this
3308 /// variant through one call rather than re-inlining the one-line
3309 /// struct-literal in lockstep with the sole in-crate wire-up site.
3310 #[must_use]
3311 pub const fn module_empty(kind: &'static str) -> Self {
3312 Self::ModuleEmpty { kind }
3313 }
3314}
3315
3316#[cfg(test)]
3317mod tests {
3318 use std::path::Path;
3319
3320 use super::*;
3321
3322 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3323 UpgradeFromEntry {
3324 from: from.into(),
3325 instructions: instrs,
3326 }
3327 }
3328
3329 #[test]
3330 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3331 // Fail-before-pass-after pin on
3332 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3333 // posture. The accessor projects the per-`:upgrade-from :from`
3334 // [`String`] storage through the `pub const fn`
3335 // [`String::as_str`] (const-stable since Rust 1.87, well within
3336 // the workspace MSRV) — any future accidental downgrade to
3337 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3338 // build time with E0015 (`cannot call non-const method`),
3339 // strictly stronger than a runtime `assert!`. Sibling of the
3340 // peer M2/M3 slot family pins on the sibling `const`-eval-
3341 // surface passes ([`crate::Caixa::nome`] /
3342 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3343 // [`crate::aplicacao::Membro::nome`] /
3344 // [`crate::aplicacao::Membro::versao_requirement`],
3345 // [`crate::aplicacao::Entrada::hostname`] /
3346 // [`crate::aplicacao::Entrada::destination`],
3347 // [`crate::supervisor::ChildSpec::nome`] /
3348 // [`crate::supervisor::ChildSpec::versao_requirement`],
3349 // [`crate::dep::Dep::nome`] /
3350 // [`crate::dep::Dep::versao_requirement`], and the
3351 // per-`:contratos`
3352 // [`crate::aplicacao::WitContract::source`] /
3353 // [`crate::aplicacao::WitContract::destination`] /
3354 // [`crate::aplicacao::WitContract::world_ref`] trio the
3355 // sibling pin at 279823b already anchors).
3356 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3357 e.prior_versao()
3358 }
3359 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3360 let e = entry(from, vec![]);
3361 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3362 assert_eq!(e.prior_versao(), from);
3363 }
3364 }
3365
3366 #[test]
3367 fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3368 // Fail-before-pass-after pin on
3369 // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3370 // posture. The accessor destructures the per-`:upgrade-from
3371 // :instructions` `Vec<UpgradeInstruction>` storage through the
3372 // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3373 // 1.66, well within the workspace MSRV) — any future
3374 // accidental downgrade to non-`const` fails
3375 // `instructions_via_const_fn` at caixa-core build time with
3376 // E0015 (`cannot call non-const method`), strictly stronger
3377 // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3378 // slot `Vec → &[T]` slice-return accessor family pin
3379 // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3380 // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3381 // per-`:membros` / per-`:contratos` slice-return axes, and of
3382 // the peer M2 supervisor-tree axis pin
3383 // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3384 // on the per-`:children` slice-return axis.
3385 const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3386 e.instructions()
3387 }
3388 // Sweep both the empty-instructions arm (author-declared
3389 // per-`:from` entry with no migration steps — the degenerate
3390 // shape the appup `restart`-only path folds through) and the
3391 // populated-instructions arm (the canonical OTP-appup shape
3392 // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3393 // chain) so the accessor carries a const-dispatch pin on
3394 // both arms.
3395 let e_empty = entry("0.1.0", vec![]);
3396 assert!(instructions_via_const_fn(&e_empty).is_empty());
3397 assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3398 let e_full = entry(
3399 "0.1.0",
3400 vec![
3401 UpgradeInstruction::LoadModule {
3402 module: "hello-rio".into(),
3403 },
3404 UpgradeInstruction::StateChange {
3405 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3406 },
3407 UpgradeInstruction::SoftPurge {
3408 module: "hello-rio-old".into(),
3409 },
3410 ],
3411 );
3412 assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3413 assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3414 }
3415
3416 #[test]
3417 fn round_trip_load_module() {
3418 let i = UpgradeInstruction::LoadModule {
3419 module: "hello-rio".into(),
3420 };
3421 let json = serde_json::to_string(&i).unwrap();
3422 assert!(json.contains("\"kind\":\"load-module\""));
3423 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3424 assert_eq!(i, back);
3425 }
3426
3427 #[test]
3428 fn round_trip_all_variants() {
3429 let cases = vec![
3430 UpgradeInstruction::LoadModule { module: "x".into() },
3431 UpgradeInstruction::StateChange {
3432 script: PathBuf::from("lib/migrations.lisp"),
3433 },
3434 UpgradeInstruction::SoftPurge {
3435 module: "x-old".into(),
3436 },
3437 UpgradeInstruction::Purge {
3438 module: "x-old".into(),
3439 },
3440 UpgradeInstruction::Restart,
3441 ];
3442 for c in cases {
3443 let json = serde_json::to_string(&c).unwrap();
3444 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3445 assert_eq!(c, back);
3446 }
3447 }
3448
3449 #[test]
3450 fn validate_accepts_well_formed() {
3451 let e = entry(
3452 "0.1.0",
3453 vec![
3454 UpgradeInstruction::LoadModule {
3455 module: "hello-rio".into(),
3456 },
3457 UpgradeInstruction::StateChange {
3458 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3459 },
3460 UpgradeInstruction::SoftPurge {
3461 module: "hello-rio-old".into(),
3462 },
3463 ],
3464 );
3465 e.validate().unwrap();
3466 }
3467
3468 #[test]
3469 fn validate_rejects_non_semver_from() {
3470 let e = entry("not-a-semver", vec![]);
3471 let err = e.validate().unwrap_err();
3472 assert!(
3473 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3474 );
3475 }
3476
3477 #[test]
3478 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3479 // Diagnostic-shape pin: the error names the offending
3480 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3481 // reason, so a `feira lint` run can render the diagnostic
3482 // without re-parsing — the author can grep their caixa.lisp for
3483 // `:from "<value>"` and fix it in one edit. Mirrors the peer
3484 // `versao_invalid_diagnostic_carries_offending_versao` pin on
3485 // the sibling SemVer-2 axis (the top-level `:versao`), the
3486 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3487 // pin on `:membros :versao`, and the peer
3488 // `deps_invalid_diagnostic_carries_offending_value` pin on
3489 // `:deps :versao` — every SemVer-2-parsing slot's invalid
3490 // diagnostic is now structurally equivalent.
3491 let e = entry("v0.1.0", vec![]);
3492 let err = e.validate().unwrap_err();
3493 let UpgradeError::FromInvalid { from, reason } = err else {
3494 panic!("expected FromInvalid variant, got {err:?}");
3495 };
3496 assert_eq!(from, "v0.1.0");
3497 assert!(
3498 !reason.is_empty(),
3499 "FromInvalid `reason` must carry the parser's wording verbatim"
3500 );
3501 }
3502
3503 #[test]
3504 fn prior_versao_returns_from_byte_equal_across_permutations() {
3505 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3506 // accessor across the SemVer-2 shape lattice every consumer
3507 // reaches through it — the numeric-triad canonical shape, a
3508 // pre-release build with a dotted identifier chain, a full-
3509 // metadata build, a large-magnitude triad, and the empty
3510 // string (which reaches this accessor unchanged before any
3511 // validate gate rejects it). Sibling to the peer
3512 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3513 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3514 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3515 // family — extended here onto the first M2 slot scalar-value
3516 // axis. Any silent detour on the accessor (a `.to_string()`
3517 // + retained ownership shape, a canonicalization pass, a
3518 // trim-whitespace on the return path) surfaces as a byte-
3519 // inequality failure here rather than as a downstream error-
3520 // diagnostic drift.
3521 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3522 for from in cases {
3523 let e = entry(from, vec![]);
3524 assert_eq!(
3525 e.prior_versao(),
3526 from,
3527 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3528 );
3529 assert_eq!(
3530 e.prior_versao().len(),
3531 from.len(),
3532 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3533 );
3534 }
3535 }
3536
3537 #[test]
3538 fn prior_versao_borrows_from_from_storage() {
3539 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3540 // a borrow into `self.from`'s heap allocation, never a fresh
3541 // owned copy. Guards against a future silent detour where
3542 // the accessor materializes a `Cow<'_, str>` / `String` /
3543 // `Rc<str>` intermediate — the return path stays zero-cost
3544 // even under a refactor that reshapes the storage. Sibling
3545 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3546 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3547 // (4a32abf) pins — extended onto the M2 slot's first
3548 // scalar-value axis.
3549 let e = entry("0.1.0", vec![]);
3550 assert!(
3551 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3552 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3553 );
3554 }
3555
3556 #[test]
3557 fn validate_parses_prior_versao_through_lifted_accessor() {
3558 // Coherence pin between the accessor and the SemVer-2 parse
3559 // gate: every `:upgrade-from :from` value the validator
3560 // accepts (resp. rejects) must be identical to what
3561 // `Version::parse(entry.prior_versao())` accepts (resp.
3562 // rejects) — the two must remain in lockstep across the
3563 // shape lattice so `validate_upgrade_from`'s
3564 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3565 // assertion holds by construction. If a future extension of
3566 // `prior_versao` reshapes the return (a canonicalization
3567 // pass, a leading/trailing whitespace trim, an empty-to-
3568 // "0.0.0" fallback) it would either loosen the validator
3569 // (silently accepting shapes the parser rejects) or
3570 // tighten the parser's re-parse (silently panicking on
3571 // shapes the validator accepts) — this pin catches either
3572 // shift at caixa-core build time.
3573 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3574 for from in accepted {
3575 let e = entry(from, vec![]);
3576 e.validate().unwrap_or_else(|err| {
3577 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3578 });
3579 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3580 panic!(
3581 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3582 got {err:?}",
3583 );
3584 });
3585 }
3586 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3587 for from in rejected {
3588 let e = entry(from, vec![]);
3589 assert!(
3590 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3591 "validate() must reject {from:?} that Version::parse rejects",
3592 );
3593 assert!(
3594 semver::Version::parse(e.prior_versao()).is_err(),
3595 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3596 );
3597 }
3598 }
3599
3600 #[test]
3601 fn validate_rejects_empty_module() {
3602 // Per-arm coverage: every Module-bearing variant surfaces the
3603 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3604 // so the author can grep their caixa.lisp for `(:load-module
3605 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3606 // edit — same self-locating shape `BehaviorError::EmptyPath`
3607 // (b0c8389) carries on the peer M2 typed slot.
3608 let cases: &[(UpgradeInstruction, &'static str)] = &[
3609 (
3610 UpgradeInstruction::LoadModule {
3611 module: String::new(),
3612 },
3613 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3614 ),
3615 (
3616 UpgradeInstruction::SoftPurge {
3617 module: String::new(),
3618 },
3619 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3620 ),
3621 (
3622 UpgradeInstruction::Purge {
3623 module: String::new(),
3624 },
3625 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3626 ),
3627 ];
3628 for (instr, expected_kind) in cases {
3629 assert_eq!(
3630 instr.validate().unwrap_err(),
3631 UpgradeError::ModuleEmpty {
3632 kind: expected_kind
3633 },
3634 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3635 );
3636 }
3637 }
3638
3639 #[test]
3640 fn validate_rejects_non_dns_1123_module() {
3641 // Every appup `:module` reference is a caixa name (the
3642 // wasm-engine resolves it through the same ComputeUnit
3643 // registry the operator manages), so the value-shape gate
3644 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3645 // the canonical authoring footguns — uppercase letters, `_`
3646 // separator, embedded `.`, leading/trailing `-`, an embedded
3647 // whitespace byte, the >63-byte UUID-shaped slug — across
3648 // every Module-bearing variant; each must surface as
3649 // `ModuleInvalid { kind, module, reason }` carrying the
3650 // offending value verbatim and the parser-shaped reason.
3651 type Build = fn(String) -> UpgradeInstruction;
3652 let footguns: &[&str] = &[
3653 "Hello-Rio",
3654 "hello_rio",
3655 "hello.rio",
3656 "-hello",
3657 "hello-",
3658 "hello rio",
3659 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3660 ];
3661 let variants: &[(Build, &'static str)] = &[
3662 (
3663 |m| UpgradeInstruction::LoadModule { module: m },
3664 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3665 ),
3666 (
3667 |m| UpgradeInstruction::SoftPurge { module: m },
3668 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3669 ),
3670 (
3671 |m| UpgradeInstruction::Purge { module: m },
3672 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3673 ),
3674 ];
3675 for (build, expected_kind) in variants {
3676 for module in footguns {
3677 let instr = build((*module).to_string());
3678 let err = instr.validate().unwrap_err();
3679 match err {
3680 UpgradeError::ModuleInvalid {
3681 kind,
3682 module: m,
3683 reason,
3684 } => {
3685 assert_eq!(
3686 kind, *expected_kind,
3687 ":module footgun on {instr:?} must tag the lisp-form"
3688 );
3689 assert_eq!(
3690 m, *module,
3691 "ModuleInvalid must carry the offending value verbatim"
3692 );
3693 assert!(
3694 !reason.is_empty(),
3695 "ModuleInvalid reason must name the specific violation \
3696 (the predicate's parser-shaped wording from \
3697 `is_dns_1123_label`), got empty"
3698 );
3699 }
3700 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3701 }
3702 }
3703 }
3704 }
3705
3706 #[test]
3707 fn validate_accepts_canonical_module_names() {
3708 // Positive control: every documented authoring shape — bare
3709 // identifier, with hyphens, with digits, the
3710 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3711 // references — passes the gate. Drift here = a future
3712 // tighten that rejects any of these surfaces as a
3713 // test-failure at the predicate boundary, not piecemeal
3714 // across per-instruction call sites.
3715 let canonical: &[&str] = &[
3716 "hello-rio",
3717 "hello-rio-old",
3718 "cache",
3719 "cache-v2",
3720 "x",
3721 "a1",
3722 "0a",
3723 "abc-123-def",
3724 ];
3725 for module in canonical {
3726 UpgradeInstruction::LoadModule {
3727 module: (*module).to_string(),
3728 }
3729 .validate()
3730 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3731 UpgradeInstruction::SoftPurge {
3732 module: (*module).to_string(),
3733 }
3734 .validate()
3735 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3736 UpgradeInstruction::Purge {
3737 module: (*module).to_string(),
3738 }
3739 .validate()
3740 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3741 }
3742 }
3743
3744 #[test]
3745 fn validate_empty_takes_precedence_over_invalid() {
3746 // Empty input is rejected via the narrower `ModuleEmpty`
3747 // diagnostic before the DNS-1123 predicate is consulted, so
3748 // a future tighten that adds another stage between the two
3749 // doesn't accidentally reorder the diagnostic precedence.
3750 // Mirrors the empty-first cascade on every peer DNS-1123
3751 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3752 // `SupervisorSpec::validate`'s child-name arm).
3753 let err = UpgradeInstruction::LoadModule {
3754 module: String::new(),
3755 }
3756 .validate()
3757 .unwrap_err();
3758 assert_eq!(
3759 err,
3760 UpgradeError::ModuleEmpty {
3761 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3762 }
3763 );
3764 }
3765
3766 #[test]
3767 fn validate_rejects_empty_script() {
3768 let i = UpgradeInstruction::StateChange {
3769 script: PathBuf::new(),
3770 };
3771 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3772 }
3773
3774 #[test]
3775 fn validate_rejects_absolute_script() {
3776 let i = UpgradeInstruction::StateChange {
3777 script: PathBuf::from("/etc/migrations.lisp"),
3778 };
3779 assert!(matches!(
3780 i.validate().unwrap_err(),
3781 UpgradeError::AbsoluteScript { .. }
3782 ));
3783 }
3784
3785 #[test]
3786 fn validate_rejects_parent_escape_script() {
3787 let i = UpgradeInstruction::StateChange {
3788 script: PathBuf::from("../sibling/migrations.lisp"),
3789 };
3790 assert!(matches!(
3791 i.validate().unwrap_err(),
3792 UpgradeError::ParentEscapeScript { .. }
3793 ));
3794 // mid-path `..` is also caught
3795 let i2 = UpgradeInstruction::StateChange {
3796 script: PathBuf::from("lib/../../escaped.lisp"),
3797 };
3798 assert!(matches!(
3799 i2.validate().unwrap_err(),
3800 UpgradeError::ParentEscapeScript { .. }
3801 ));
3802 }
3803
3804 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3805 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3806 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3807 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3808 // consumer; the file-type contract is identical, so the per-axis
3809 // test grid is mirrored leg-for-leg.
3810
3811 #[test]
3812 fn validate_rejects_no_extension_script() {
3813 // Fail-before-pass-after: the canonical "I declared the
3814 // migration script but forgot the `.lisp` extension"
3815 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3816 // The wasm-engine's `tatara_lisp::read` consumer needs a
3817 // file-type contract beyond the structural-shape gate; a
3818 // no-extension path past `is_sandboxed_relative_path` would
3819 // surface a parser-shaped diagnostic at hot-upgrade migration
3820 // time far from the source caixa.lisp.
3821 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3822 let i = UpgradeInstruction::StateChange {
3823 script: PathBuf::from(relpath),
3824 };
3825 let err = i.validate().unwrap_err();
3826 assert!(
3827 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3828 if s == Path::new(relpath)),
3829 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
3830 carrying the offending path verbatim, got {err:?}"
3831 );
3832 }
3833 }
3834
3835 #[test]
3836 fn validate_rejects_non_lisp_extension_script() {
3837 // Wrong-extension sweep across common authoring footguns: the
3838 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
3839 // drag in from the workspace tree, the `.rs` shape that an
3840 // IDE auto-complete might propose, the `.lisp.bak` shape an
3841 // editor might leave behind, and the `.lispx` near-miss that
3842 // a typo would produce. Each must surface as
3843 // `NonLispExtensionScript` carrying the offending path
3844 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
3845 // rejects all of these at hot-upgrade migration time, and
3846 // the gate lifts that contract to validate time. Mirrors the
3847 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
3848 // the `:behavior :on-*` axis leg-for-leg — same downstream
3849 // consumer, same accepted set, same per-axis test grid.
3850 let footguns: &[&str] = &[
3851 "lib/migrations.rs",
3852 "lib/migrations.txt",
3853 "lib/migrations.md",
3854 "lib/migrations.json",
3855 "lib/migrations.yaml",
3856 "lib/migrations.toml",
3857 "lib/migrations.lisp.bak",
3858 "lib/migrations.lispx",
3859 "lib/migrations.lis",
3860 ];
3861 for relpath in footguns {
3862 let i = UpgradeInstruction::StateChange {
3863 script: PathBuf::from(relpath),
3864 };
3865 let err = i.validate().unwrap_err();
3866 assert!(
3867 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3868 if s == Path::new(relpath)),
3869 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
3870 carrying the offending path verbatim, got {err:?}"
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn validate_rejects_uppercase_lisp_extension_script() {
3877 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
3878 // case-folded shapes a case-insensitive volume's existence
3879 // check would match the on-disk file — but the
3880 // canonical-form codec emits lowercase `.lisp` verbatim, so
3881 // a case-folded shape mismatches the round-trip-stable
3882 // canonical form (THEORY.md §V.2.7 render-determinism).
3883 // Same case-sensitive discipline the byte-size / duration
3884 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
3885 // and every other shape-gate predicate in `render.rs` (label
3886 // / scheme / unit boundaries). Mirrors the peer
3887 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
3888 for relpath in [
3889 "lib/migrations.LISP",
3890 "lib/migrations.Lisp",
3891 "lib/migrations.LiSp",
3892 "lib/migrations.lISP",
3893 ] {
3894 let i = UpgradeInstruction::StateChange {
3895 script: PathBuf::from(relpath),
3896 };
3897 let err = i.validate().unwrap_err();
3898 assert!(
3899 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
3900 if s == Path::new(relpath)),
3901 "case-folded `.lisp` extension {relpath:?} must surface as \
3902 NonLispExtensionScript (strict lowercase, canonical-form \
3903 round-trip pin), got {err:?}"
3904 );
3905 }
3906 }
3907
3908 #[test]
3909 fn validate_accepts_canonical_lisp_extension_scripts() {
3910 // Positive-control sweep across every canonical in-tree
3911 // authoring shape: bare filename, standard `lib/`
3912 // subdirectory, deeply-nested migrations subdirectory,
3913 // explicit current-dir-relative prefix, mid-path `./`
3914 // segment, multi-dot stem (the version-suffix shape
3915 // `lib/migrations/v.0.1.lisp` an author might use to encode
3916 // the migration's `:from` version into the filename). Drift
3917 // here = a future tightening that rejects any of these
3918 // surfaces as a test-failure at the per-axis validator
3919 // boundary, not piecemeal across renderer / layout-checker
3920 // call sites. Mirrors the peer `BehaviorSpec` positive-set
3921 // sweep (c97815a).
3922 let canonical: &[&str] = &[
3923 "lib/migrations.lisp",
3924 "lib/migrations/v01-to-v02.lisp",
3925 "migrations.lisp",
3926 "a.lisp",
3927 "./lib/migrations.lisp",
3928 "lib/./migrations.lisp",
3929 "lib/migrations/v.0.1.lisp",
3930 ];
3931 for relpath in canonical {
3932 UpgradeInstruction::StateChange {
3933 script: PathBuf::from(relpath),
3934 }
3935 .validate()
3936 .unwrap_or_else(|e| {
3937 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
3938 });
3939 }
3940 }
3941
3942 #[test]
3943 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
3944 // Cross-arm precedence pin: a script that is *both*
3945 // sandbox-escaping (Empty / Absolute / ParentEscape) and
3946 // non-`.lisp` must surface the more-fundamental
3947 // sandbox-shape diagnostic first — the canonical fix
3948 // collapses both into "pin a relative `.lisp` path under the
3949 // caixa root", and the `.lisp` remediation would be
3950 // misleading when the offending path can never resolve under
3951 // the caixa root anyway. Mirrors the peer
3952 // `BehaviorError` cross-arm precedence (c97815a) and the
3953 // sibling `LimitsError`
3954 // (`MemoryZero` → `MemoryBelowWasm32Page` →
3955 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
3956 // smallest-scope-arm-fires-last posture.
3957 let i_empty = UpgradeInstruction::StateChange {
3958 script: PathBuf::new(),
3959 };
3960 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
3961 let i_abs = UpgradeInstruction::StateChange {
3962 script: PathBuf::from("/etc/migrations.txt"),
3963 };
3964 assert!(
3965 matches!(
3966 i_abs.validate().unwrap_err(),
3967 UpgradeError::AbsoluteScript { .. }
3968 ),
3969 "absolute + non-`.lisp` must surface AbsoluteScript first"
3970 );
3971 let i_esc = UpgradeInstruction::StateChange {
3972 script: PathBuf::from("../sibling/migrations.rs"),
3973 };
3974 assert!(
3975 matches!(
3976 i_esc.validate().unwrap_err(),
3977 UpgradeError::ParentEscapeScript { .. }
3978 ),
3979 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
3980 );
3981 }
3982
3983 #[test]
3984 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
3985 // Diagnostic-shape pin: the surfaced error message names the
3986 // offending path verbatim (so the author can grep their
3987 // caixa.lisp for the literal value), the `.lisp` extension
3988 // is named in the remediation, and the downstream consumer
3989 // (`tatara_lisp::read` at hot-upgrade migration time) is
3990 // named so the author can trace the contract back to its
3991 // source. Same self-locating shape every per-axis variant
3992 // carries (`BehaviorError::NonLispExtension`, c97815a;
3993 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
3994 let bad = PathBuf::from("lib/migrations.txt");
3995 let err = UpgradeInstruction::StateChange {
3996 script: bad.clone(),
3997 }
3998 .validate()
3999 .unwrap_err();
4000 let msg = err.to_string();
4001 assert!(
4002 msg.contains("lib/migrations.txt"),
4003 "diagnostic must name the offending path verbatim, got {msg:?}"
4004 );
4005 assert!(
4006 msg.contains(".lisp"),
4007 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
4008 );
4009 assert!(
4010 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
4011 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
4012 );
4013 match err {
4014 UpgradeError::NonLispExtensionScript { script } => {
4015 assert_eq!(
4016 script, bad,
4017 "variant must carry the offending path verbatim"
4018 );
4019 }
4020 other => panic!("expected NonLispExtensionScript, got {other:?}"),
4021 }
4022 }
4023
4024 #[test]
4025 fn declared_path_only_for_state_change() {
4026 let load = UpgradeInstruction::LoadModule { module: "x".into() };
4027 assert!(load.declared_path().is_none());
4028 let mig = UpgradeInstruction::StateChange {
4029 script: PathBuf::from("lib/m.lisp"),
4030 };
4031 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
4032 }
4033
4034 #[test]
4035 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
4036 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4037 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
4038 // predicate: [`UpgradeInstruction::Restart`] is the only variant
4039 // that satisfies `.is_restart()`; every module-bearing arm
4040 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
4041 // `StateChange` arm all return `false`. This pin makes the
4042 // partition invariant load-bearing at caixa-core test time so a
4043 // future derive regression (a hole that returns `false` for
4044 // `Restart` too, or a byte-collision that flips a second variant
4045 // to `true`) trips here rather than laundering the arm at
4046 // [`Self::validate_restart_exclusive`]'s paired positive /
4047 // negated filter sites (a hole flips restart-count to 0 →
4048 // vacuous OK; a collision flips restart-count > 1 → false
4049 // `RestartNotExclusive` on an entry the author declared without
4050 // any `(:restart)`). Peer of the sibling
4051 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
4052 // pin on the M0 `CaixaKind` axis.
4053 let cases: &[(UpgradeInstruction, bool)] = &[
4054 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4055 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4056 (UpgradeInstruction::Purge { module: "c".into() }, false),
4057 (
4058 UpgradeInstruction::StateChange {
4059 script: PathBuf::from("lib/m.lisp"),
4060 },
4061 false,
4062 ),
4063 (UpgradeInstruction::Restart, true),
4064 ];
4065 for (variant, expected) in cases {
4066 assert_eq!(
4067 variant.is_restart(),
4068 *expected,
4069 "UpgradeInstruction::{variant:?}.is_restart() must \
4070 return {expected} (partition invariant on the \
4071 IsVariant-derived arm-discriminator predicate)"
4072 );
4073 }
4074 }
4075
4076 #[test]
4077 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
4078 // Byte-identity pin on the paired positive / negated
4079 // `.is_restart()` filters at
4080 // [`Self::validate_restart_exclusive`] against the pre-lift
4081 // `matches!(i, UpgradeInstruction::Restart)` /
4082 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
4083 // consumer of the gate previously coupled to inline. Asserts
4084 // the two projections agree byte-for-byte on every arm of the
4085 // enum, so a future derive regression that flipped either
4086 // predicate's arm-set would surface here at caixa-core test
4087 // time rather than at
4088 // [`Self::validate_restart_exclusive`]'s per-entry restart-
4089 // count / other-kinds tabulation far from the derive site.
4090 // Same peer-shape pin every sibling
4091 // `IsVariant`-derive-routed gate carries on the substrate's
4092 // closed-set typed-enum surface.
4093 let cases: Vec<UpgradeInstruction> = vec![
4094 UpgradeInstruction::LoadModule { module: "a".into() },
4095 UpgradeInstruction::SoftPurge { module: "b".into() },
4096 UpgradeInstruction::Purge { module: "c".into() },
4097 UpgradeInstruction::StateChange {
4098 script: PathBuf::from("lib/m.lisp"),
4099 },
4100 UpgradeInstruction::Restart,
4101 ];
4102 for instr in &cases {
4103 let via_predicate = instr.is_restart();
4104 let via_matches = matches!(instr, UpgradeInstruction::Restart);
4105 assert_eq!(
4106 via_predicate, via_matches,
4107 "UpgradeInstruction::{instr:?}: is_restart() must \
4108 byte-equal matches!(_, UpgradeInstruction::Restart) — \
4109 the pre-lift open-coded pattern and the \
4110 IsVariant-derived predicate are the same axis, \
4111 one typed dispatch"
4112 );
4113 }
4114 }
4115
4116 #[test]
4117 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
4118 // The fail-before-pass-after pin on the lifted
4119 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
4120 // arm-discriminator predicate:
4121 // [`UpgradeInstruction::SoftPurge`] and
4122 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
4123 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
4124 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
4125 // on the paired two-phase-load half,
4126 // [`UpgradeInstruction::StateChange`] on the
4127 // `gen_server:code_change/3`-analog migration axis,
4128 // [`UpgradeInstruction::Restart`] on the OTP terminal-
4129 // fallback shape) returns `false`. This pin makes the
4130 // partition invariant load-bearing at caixa-core test time
4131 // so a future accessor regression (a hole that returns
4132 // `false` for `SoftPurge` or `Purge`, or a byte-collision
4133 // that flips `LoadModule` / `StateChange` / `Restart` to
4134 // `true`) trips here rather than laundering the arm at the
4135 // three within-entry cross-instruction cleanup-facing gates
4136 // ([`UpgradeFromEntry::validate_purge_ordering`],
4137 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
4138 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
4139 // hole would silently accept a cleanup-shaped entry the
4140 // three gates should refuse; a collision would fire a
4141 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
4142 // `DuplicateCleanup` refusal on a well-shaped
4143 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
4144 // `Restart` arm the three gates should pass through. Peer
4145 // of the sibling
4146 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4147 // pin on the single-arm terminal-fallback partition —
4148 // extended here from the single-arm case onto the two-arm
4149 // cleanup-family union case.
4150 let cases: &[(UpgradeInstruction, bool)] = &[
4151 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4152 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
4153 (UpgradeInstruction::Purge { module: "c".into() }, true),
4154 (
4155 UpgradeInstruction::StateChange {
4156 script: PathBuf::from("lib/m.lisp"),
4157 },
4158 false,
4159 ),
4160 (UpgradeInstruction::Restart, false),
4161 ];
4162 for (variant, expected) in cases {
4163 assert_eq!(
4164 variant.is_cleanup(),
4165 *expected,
4166 "UpgradeInstruction::{variant:?}.is_cleanup() must \
4167 return {expected} (partition invariant on the \
4168 lifted OTP-appup two-arm cleanup-family arm-\
4169 discriminator predicate)"
4170 );
4171 }
4172 }
4173
4174 #[test]
4175 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
4176 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
4177 // composition against the two [`gen_platform::IsVariant`]-
4178 // derive-generated per-variant classifiers it routes through
4179 // — the accessor's one body must byte-equal
4180 // `self.is_soft_purge() || self.is_purge()` across every arm
4181 // of the closed-set enum, so a future silent detour that
4182 // reintroduced a raw `matches!` pattern or that stopped
4183 // composing through the derive-generated per-variant
4184 // predicates (an accidental `self.is_soft_purge()` on its
4185 // own — silently dropping the `Purge` arm; an accidental
4186 // `self.is_purge() || self.is_state_change()` — silently
4187 // folding the migration arm into the cleanup family; a
4188 // typo `&&` for the union `||` — silently classifying no
4189 // arm as cleanup) trips here at caixa-core test time
4190 // rather than laundering the arm at the three within-entry
4191 // cross-instruction cleanup-facing gates. Same peer-shape
4192 // pin the sibling
4193 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4194 // carries on the paired terminal-fallback axis.
4195 let cases: Vec<UpgradeInstruction> = vec![
4196 UpgradeInstruction::LoadModule { module: "a".into() },
4197 UpgradeInstruction::SoftPurge { module: "b".into() },
4198 UpgradeInstruction::Purge { module: "c".into() },
4199 UpgradeInstruction::StateChange {
4200 script: PathBuf::from("lib/m.lisp"),
4201 },
4202 UpgradeInstruction::Restart,
4203 ];
4204 for instr in &cases {
4205 let via_predicate = instr.is_cleanup();
4206 let via_composition = instr.is_soft_purge() || instr.is_purge();
4207 assert_eq!(
4208 via_predicate, via_composition,
4209 "UpgradeInstruction::{instr:?}: is_cleanup() must \
4210 byte-equal is_soft_purge() || is_purge() — the \
4211 lifted union predicate and its per-variant \
4212 composition are the same axis, one typed dispatch"
4213 );
4214 }
4215 }
4216
4217 #[test]
4218 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4219 // Composition-pin the load-bearing invariant every consumer
4220 // that routes through `is_cleanup()` + `declared_module()`
4221 // relies on: any [`UpgradeInstruction`] value whose
4222 // `.is_cleanup()` returns `true` must have a `Some(_)`
4223 // `.declared_module()`. This makes the three within-entry
4224 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4225 // implies declared_module() is Some")` structurally
4226 // infallible at build time — a future refactor that added
4227 // a cleanup-shaped variant carrying no `:module` would trip
4228 // here rather than panic at
4229 // [`UpgradeFromEntry::validate_purge_ordering`] /
4230 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4231 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4232 // runtime on the offending author's caixa.lisp.
4233 let cases: Vec<UpgradeInstruction> = vec![
4234 UpgradeInstruction::LoadModule { module: "a".into() },
4235 UpgradeInstruction::SoftPurge { module: "b".into() },
4236 UpgradeInstruction::Purge { module: "c".into() },
4237 UpgradeInstruction::StateChange {
4238 script: PathBuf::from("lib/m.lisp"),
4239 },
4240 UpgradeInstruction::Restart,
4241 ];
4242 for instr in &cases {
4243 if instr.is_cleanup() {
4244 assert!(
4245 instr.declared_module().is_some(),
4246 "UpgradeInstruction::{instr:?}: is_cleanup() \
4247 must imply declared_module().is_some() — the \
4248 three within-entry cross-instruction cleanup-\
4249 facing gates rely on this invariant to route \
4250 the cleanup-target :module scalar through the \
4251 sibling declared_module accessor without a \
4252 pattern-bound `module` binding"
4253 );
4254 }
4255 }
4256 }
4257
4258 #[test]
4259 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4260 // Composition-pin the load-bearing invariant
4261 // [`UpgradeFromEntry::validate_load_singularity`] relies on
4262 // when routing the per-instruction load-family arm-discriminator
4263 // through the sibling
4264 // [`UpgradeInstruction::is_load_module`] +
4265 // [`UpgradeInstruction::declared_module`] accessor pair: any
4266 // [`UpgradeInstruction`] value whose `.is_load_module()`
4267 // returns `true` must have a `Some(_)` `.declared_module()`.
4268 // This makes the gate's `.expect("is_load_module() implies
4269 // declared_module() is Some")` structurally infallible at
4270 // build time — a future refactor that added a load-shaped
4271 // variant carrying no `:module` would trip here rather than
4272 // panic at [`UpgradeFromEntry::validate_load_singularity`]
4273 // at runtime on the offending author's caixa.lisp. Sibling
4274 // of the peer
4275 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4276 // composition pin on the two-arm cleanup-family axis — same
4277 // "predicate implies accessor" discipline extended onto the
4278 // single-arm load-family axis, closes the load-vs-cleanup
4279 // pair on the substrate primitive's typed dispatch discipline.
4280 let cases: Vec<UpgradeInstruction> = vec![
4281 UpgradeInstruction::LoadModule { module: "a".into() },
4282 UpgradeInstruction::SoftPurge { module: "b".into() },
4283 UpgradeInstruction::Purge { module: "c".into() },
4284 UpgradeInstruction::StateChange {
4285 script: PathBuf::from("lib/m.lisp"),
4286 },
4287 UpgradeInstruction::Restart,
4288 ];
4289 for instr in &cases {
4290 if instr.is_load_module() {
4291 assert!(
4292 instr.declared_module().is_some(),
4293 "UpgradeInstruction::{instr:?}: is_load_module() \
4294 must imply declared_module().is_some() — the \
4295 within-entry load-singularity gate relies on this \
4296 invariant to route the load-target :module scalar \
4297 through the sibling declared_module accessor \
4298 without a pattern-bound `module` binding"
4299 );
4300 }
4301 }
4302 }
4303
4304 #[test]
4305 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4306 {
4307 // Byte-identity pin on the
4308 // [`UpgradeFromEntry::validate_load_singularity`] load-family
4309 // dispatch against the pre-lift
4310 // `match instr { UpgradeInstruction::LoadModule { module } =>
4311 // module.as_str(), _ => continue }` open-coded pattern-match
4312 // the site previously carried. Asserts the two projections
4313 // agree byte-for-byte on every arm of the enum — the
4314 // arm-discriminator via `is_load_module()` and the `:module`
4315 // scalar via `declared_module()` — so a future derive
4316 // regression that flipped the predicate's arm-set (a hole
4317 // returning `false` for [`UpgradeInstruction::LoadModule`], a
4318 // byte-collision flipping a second variant to `true`) or an
4319 // accessor extension that promoted an additional variant onto
4320 // the `String`-carrying axis would trip here at caixa-core
4321 // test time rather than laundering the arm at the gate's
4322 // per-entry load-singularity scan far from the derive site.
4323 // Peer of the sibling
4324 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4325 // byte-identity pin on the paired ordering-side load-family
4326 // sticky-latch dispatch (both consumers now agree on one
4327 // typed dispatch for the load-family axis) and the peer
4328 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4329 // pin on the migration-family script-projection axis — the
4330 // three within-entry per-instruction-class singularity gates
4331 // now share one byte-identity pin apiece against their
4332 // respective substrate-primitive typed dispatches.
4333 //
4334 // Three-arm projective coverage:
4335 // (a) `LoadModule` modules project through
4336 // `declared_module()` byte-equal to the raw
4337 // `module.as_str()` field access;
4338 // (b) a duplicate-`LoadModule` input trips the gate on the
4339 // second occurrence with `DuplicateLoadModule` carrying
4340 // the offending module verbatim;
4341 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4342 // `StateChange` / `Restart`) leaves the gate vacuous
4343 // with `Ok(())` — the `!instr.is_load_module()`
4344 // `continue` fall-through pins.
4345 //
4346 // Fail-before-pass-after verified locally: swapping the
4347 // production `if !instr.is_load_module() { continue; } let
4348 // module = instr.declared_module().expect(…);` back to `let
4349 // module = match instr { UpgradeInstruction::LoadModule
4350 // { module } => module.as_str(), _ => continue, };` keeps
4351 // arms (a)-(c) passing but silently detaches the gate from
4352 // the accessor's typed dispatch — any future
4353 // `is_load_module` / `declared_module` extension (a hole in
4354 // either predicate, a promotion of an additional variant
4355 // onto the `String`-carrying axis, an operator-side
4356 // pre-parsed caixa-name cache the accessor materializes)
4357 // would then silently disagree between this gate's raw
4358 // pattern-match and the peer per-`UpgradeInstruction`
4359 // consumers that route through the accessor pair.
4360
4361 // (a) LoadModule projection byte-equal via
4362 // is_load_module() + declared_module().
4363 let lm = UpgradeInstruction::LoadModule {
4364 module: "hello-rio".into(),
4365 };
4366 assert!(
4367 lm.is_load_module(),
4368 "LoadModule must satisfy is_load_module() — the gate's \
4369 load-family arm-discriminator relies on this partition"
4370 );
4371 assert_eq!(
4372 lm.declared_module(),
4373 Some("hello-rio"),
4374 "declared_module() must project the LoadModule :module \
4375 byte-equal to the raw field access — accessor divergence \
4376 would silently detach the gate from the projection every \
4377 peer per-`UpgradeInstruction` consumer routes through"
4378 );
4379
4380 // (b) Duplicate-LoadModule input trips the gate.
4381 let dup = entry(
4382 "0.1.0",
4383 vec![
4384 UpgradeInstruction::LoadModule { module: "x".into() },
4385 UpgradeInstruction::LoadModule { module: "x".into() },
4386 ],
4387 );
4388 assert_eq!(
4389 dup.validate_load_singularity(),
4390 Err(UpgradeError::DuplicateLoadModule {
4391 from: "0.1.0".into(),
4392 module: "x".into(),
4393 }),
4394 "duplicate LoadModule modules within one entry must fire \
4395 DuplicateLoadModule byte-identical to the pre-lift \
4396 pattern-match shape"
4397 );
4398
4399 // (c) Non-LoadModule-only input leaves the gate vacuous.
4400 let no_load = entry(
4401 "0.1.0",
4402 vec![
4403 UpgradeInstruction::StateChange {
4404 script: PathBuf::from("lib/m.lisp"),
4405 },
4406 UpgradeInstruction::Restart,
4407 ],
4408 );
4409 assert_eq!(
4410 no_load.validate_load_singularity(),
4411 Ok(()),
4412 "non-LoadModule-only entries must leave the load-\
4413 singularity gate vacuous — the `!is_load_module()` \
4414 continue fall-through pins"
4415 );
4416 }
4417
4418 #[test]
4419 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4420 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4421 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4422 // predicate: [`UpgradeInstruction::LoadModule`] is the only
4423 // variant that satisfies `.is_load_module()`; every cleanup arm
4424 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4425 // and the terminal-fallback arm (`Restart`) all return `false`.
4426 // This pin makes the partition invariant load-bearing at
4427 // caixa-core test time so a future derive regression (a hole
4428 // that returns `false` for `LoadModule` too, or a byte-collision
4429 // that flips a second variant to `true`) trips here rather than
4430 // laundering the arm at
4431 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4432 // dispatch — a hole would silently keep `loaded = false` through
4433 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4434 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4435 // a collision would flip `loaded = true` on a well-shaped
4436 // cleanup-only entry and silently swallow the load-less
4437 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4438 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4439 // and
4440 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4441 // pins on the paired terminal-fallback and cleanup-family
4442 // arm-discriminator axes — closes the last unlifted `matches!`-
4443 // based arm-discriminator axis on the OTP-appup closed-set
4444 // typed enum.
4445 let cases: &[(UpgradeInstruction, bool)] = &[
4446 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4447 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4448 (UpgradeInstruction::Purge { module: "c".into() }, false),
4449 (
4450 UpgradeInstruction::StateChange {
4451 script: PathBuf::from("lib/m.lisp"),
4452 },
4453 false,
4454 ),
4455 (UpgradeInstruction::Restart, false),
4456 ];
4457 for (variant, expected) in cases {
4458 assert_eq!(
4459 variant.is_load_module(),
4460 *expected,
4461 "UpgradeInstruction::{variant:?}.is_load_module() must \
4462 return {expected} (partition invariant on the \
4463 IsVariant-derived arm-discriminator predicate)"
4464 );
4465 }
4466 }
4467
4468 #[test]
4469 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4470 // Byte-identity pin on the [`Self::validate_purge_ordering`]
4471 // load-family sticky-latch dispatch against the pre-lift
4472 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4473 // predicate the site previously open-coded. Asserts the two
4474 // projections agree byte-for-byte on every arm of the enum, so
4475 // a future derive regression that flipped the predicate's
4476 // arm-set would surface here at caixa-core test time rather
4477 // than at [`Self::validate_purge_ordering`]'s per-entry
4478 // load-before-cleanup ordering scan far from the derive site.
4479 // Same peer-shape pin the sibling
4480 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4481 // carries on the paired terminal-fallback axis and the
4482 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4483 // carries on the two-arm cleanup-family axis — the third and
4484 // final byte-identity pin closes the substrate primitive's
4485 // arm-discriminator dispatch discipline on the OTP-appup
4486 // closed-set typed enum.
4487 let cases: Vec<UpgradeInstruction> = vec![
4488 UpgradeInstruction::LoadModule { module: "a".into() },
4489 UpgradeInstruction::SoftPurge { module: "b".into() },
4490 UpgradeInstruction::Purge { module: "c".into() },
4491 UpgradeInstruction::StateChange {
4492 script: PathBuf::from("lib/m.lisp"),
4493 },
4494 UpgradeInstruction::Restart,
4495 ];
4496 for instr in &cases {
4497 let via_predicate = instr.is_load_module();
4498 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4499 assert_eq!(
4500 via_predicate, via_matches,
4501 "UpgradeInstruction::{instr:?}: is_load_module() must \
4502 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4503 {{ .. }}) — the pre-lift open-coded pattern and the \
4504 IsVariant-derived predicate are the same axis, one \
4505 typed dispatch"
4506 );
4507 }
4508 }
4509
4510 #[test]
4511 fn declared_module_only_for_module_bearing_variants() {
4512 // Pinned partition of the `UpgradeInstruction` closed-set
4513 // variant space against the sibling of the peer
4514 // `declared_path` accessor: every OTP-appup module-bearing
4515 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4516 // `:module` string byte-for-byte through the lifted
4517 // `declared_module` accessor; every non-module-bearing variant
4518 // (`StateChange` on the peer `:script`-carrying axis;
4519 // `Restart` on the OTP terminal-fallback data-less axis)
4520 // returns `None`. Mirrors the peer
4521 // `declared_path_only_for_state_change` pin — the pair now
4522 // closes both scalar-carrying axes on the enum on one lifted
4523 // `Option<&…>` accessor apiece.
4524 let load = UpgradeInstruction::LoadModule {
4525 module: "hello-rio".into(),
4526 };
4527 assert_eq!(load.declared_module(), Some("hello-rio"));
4528 let soft = UpgradeInstruction::SoftPurge {
4529 module: "hello-rio-old".into(),
4530 };
4531 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4532 let hard = UpgradeInstruction::Purge {
4533 module: "hello-rio-ancient".into(),
4534 };
4535 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4536 let mig = UpgradeInstruction::StateChange {
4537 script: PathBuf::from("lib/m.lisp"),
4538 };
4539 assert!(mig.declared_module().is_none());
4540 assert!(UpgradeInstruction::Restart.declared_module().is_none());
4541 }
4542
4543 #[test]
4544 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4545 // Byte-identity pin on the two-accessor partition: every
4546 // `UpgradeInstruction` variant returns `Some` from *exactly
4547 // one* of {`declared_module`, `declared_path`} (the two
4548 // module-bearing / script-carrying axes) or from *neither*
4549 // (the OTP terminal-fallback `Restart` shape). No variant
4550 // returns `Some` from both — the two axes are disjoint by
4551 // construction, and this pin closes the disjointness at the
4552 // test surface so a future variant that leaks a scalar across
4553 // both axes fails at build time. Mirrors the peer
4554 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4555 // discipline on the `BehaviorSpec` per-slot family.
4556 let cases: Vec<UpgradeInstruction> = vec![
4557 UpgradeInstruction::LoadModule { module: "a".into() },
4558 UpgradeInstruction::SoftPurge { module: "b".into() },
4559 UpgradeInstruction::Purge { module: "c".into() },
4560 UpgradeInstruction::StateChange {
4561 script: PathBuf::from("lib/m.lisp"),
4562 },
4563 UpgradeInstruction::Restart,
4564 ];
4565 for instr in &cases {
4566 let has_module = instr.declared_module().is_some();
4567 let has_path = instr.declared_path().is_some();
4568 assert!(
4569 !(has_module && has_path),
4570 "no variant may declare both a module and a path — offending: {instr:?}"
4571 );
4572 match instr {
4573 UpgradeInstruction::LoadModule { .. }
4574 | UpgradeInstruction::SoftPurge { .. }
4575 | UpgradeInstruction::Purge { .. } => {
4576 assert!(has_module && !has_path, "module axis: {instr:?}");
4577 }
4578 UpgradeInstruction::StateChange { .. } => {
4579 assert!(!has_module && has_path, "script axis: {instr:?}");
4580 }
4581 UpgradeInstruction::Restart => {
4582 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4583 }
4584 }
4585 }
4586 }
4587
4588 #[test]
4589 fn entry_with_chain_of_versions() {
4590 // Middle entry pairs a `:load-module` with the trailing
4591 // `:soft-purge` so it satisfies the within-entry purge-ordering
4592 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4593 // preceding `:load-module`, mirroring the state-change-ordering
4594 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4595 // test is *cross-entry* `:from` values; the within-entry shape
4596 // is incidental — keeping it canonical (`:load-module` before
4597 // `:soft-purge`) leaves the chain assertion load-bearing.
4598 let entries = vec![
4599 entry(
4600 "0.1.0",
4601 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4602 ),
4603 entry(
4604 "0.1.5",
4605 vec![
4606 UpgradeInstruction::LoadModule { module: "x".into() },
4607 UpgradeInstruction::SoftPurge {
4608 module: "x-old".into(),
4609 },
4610 ],
4611 ),
4612 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4613 ];
4614 for e in &entries {
4615 e.validate().unwrap();
4616 }
4617 let json = serde_json::to_string(&entries).unwrap();
4618 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4619 assert_eq!(entries, back);
4620 }
4621
4622 #[test]
4623 fn empty_instructions_list_is_valid() {
4624 let e = entry("0.1.0", vec![]);
4625 e.validate().unwrap();
4626 }
4627
4628 #[test]
4629 fn json_uses_kebab_case_kind_tags() {
4630 let i = UpgradeInstruction::SoftPurge {
4631 module: "x-old".into(),
4632 };
4633 let json = serde_json::to_string(&i).unwrap();
4634 assert!(json.contains("\"kind\":\"soft-purge\""));
4635 let i2 = UpgradeInstruction::StateChange {
4636 script: PathBuf::from("m.lisp"),
4637 };
4638 let json2 = serde_json::to_string(&i2).unwrap();
4639 assert!(json2.contains("\"kind\":\"state-change\""));
4640 }
4641
4642 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4643
4644 #[test]
4645 fn validate_upgrade_from_accepts_disjoint_versions() {
4646 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4647 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4648 // (and `entry_with_chain_of_versions` above) passes the cross-
4649 // entry gate. Different `:from` per entry is the intended
4650 // shape; the gate must not regress this baseline. Middle entry
4651 // pairs `:load-module` with `:soft-purge` to satisfy the
4652 // within-entry purge-ordering gate (see
4653 // `entry_with_chain_of_versions` for the same shape).
4654 let entries = vec![
4655 entry(
4656 "0.1.0",
4657 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4658 ),
4659 entry(
4660 "0.1.5",
4661 vec![
4662 UpgradeInstruction::LoadModule { module: "x".into() },
4663 UpgradeInstruction::SoftPurge {
4664 module: "x-old".into(),
4665 },
4666 ],
4667 ),
4668 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4669 ];
4670 validate_upgrade_from(&entries).unwrap();
4671 }
4672
4673 #[test]
4674 fn validate_upgrade_from_accepts_empty_list() {
4675 // Absent `:upgrade-from` (the bare `feira init` shape) — the
4676 // gate must trivially pass an empty list. Mirrors the per-axis
4677 // "empty list passes" positive control on every peer typed-
4678 // graph gate (`validate_membros` empty list, `validate_placement`
4679 // requires non-empty clusters but only after a `Placement`
4680 // exists, etc.).
4681 validate_upgrade_from(&[]).unwrap();
4682 }
4683
4684 #[test]
4685 fn validate_upgrade_from_rejects_duplicate_from() {
4686 // Fail-before-pass-after pin: two entries with the same parsed-
4687 // semver `:from` are an ambiguous edge in the typed upgrade
4688 // graph (OTP appup picks at most one matching block per running
4689 // version; with two matching blocks the operator picks either
4690 // set non-deterministically — author intent is one path per
4691 // prior version). Same set-not-multiset discipline as
4692 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4693 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4694 // `:entrada :paths` (eb3456d) — now extended onto the fifth
4695 // typed-graph axis.
4696 let entries = vec![
4697 entry(
4698 "0.1.0",
4699 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4700 ),
4701 entry(
4702 "0.1.0",
4703 vec![
4704 UpgradeInstruction::LoadModule { module: "x".into() },
4705 UpgradeInstruction::SoftPurge {
4706 module: "x-old".into(),
4707 },
4708 ],
4709 ),
4710 ];
4711 let err = validate_upgrade_from(&entries).unwrap_err();
4712 assert_eq!(
4713 err,
4714 UpgradeError::DuplicateFrom {
4715 from: "0.1.0".into()
4716 },
4717 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4718 offending value verbatim"
4719 );
4720 }
4721
4722 #[test]
4723 fn validate_upgrade_from_treats_pre_release_as_distinct() {
4724 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4725 // equal under semver (pre-release version is part of the
4726 // identity), so they're distinct upgrade paths and must not
4727 // collide. A future tightening that collapses pre-release into
4728 // the release version surfaces here.
4729 let entries = vec![
4730 entry("1.0.0", vec![UpgradeInstruction::Restart]),
4731 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4732 ];
4733 validate_upgrade_from(&entries).unwrap();
4734 }
4735
4736 #[test]
4737 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4738 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4739 // compares build metadata (it derives equality across all
4740 // fields including `pre` + `build`), so `1.0.0+build1` and
4741 // `1.0.0+build2` are *not* duplicates from the gate's
4742 // perspective — the operator may treat the build-metadata
4743 // suffix as a tiebreaker even though the semver spec says
4744 // build metadata is ignored for precedence
4745 // (https://semver.org/#spec-item-10). Pin the conservative
4746 // behavior here so a future switch to a build-metadata-
4747 // stripping comparator surfaces as a test failure first; that
4748 // change would require coordinating with the wasm-operator's
4749 // `:from`-match dispatch step, which is the load-bearing
4750 // semantic we'd be mirroring.
4751 let entries = vec![
4752 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4753 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4754 ];
4755 validate_upgrade_from(&entries).unwrap();
4756 }
4757
4758 #[test]
4759 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4760 // Order pin: a malformed `:from` on the second entry surfaces
4761 // its `FromInvalid` diagnostic, not a (less-useful)
4762 // `DuplicateFrom`. The per-entry shape pass runs *inline*
4763 // before the duplicate-key insert — parallel to
4764 // `child_versao_invalid_fires_before_duplicate_check`
4765 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4766 // (9888b13). Without this pin a future shortcut that runs the
4767 // cross-entry gate first would surface a duplicate diagnostic
4768 // on a string that isn't even parsable as a version.
4769 let entries = vec![
4770 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4771 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4772 ];
4773 let err = validate_upgrade_from(&entries).unwrap_err();
4774 assert!(
4775 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4776 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4777 );
4778 }
4779
4780 #[test]
4781 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4782 // Symmetric arm: a malformed shape on the *first* entry of a
4783 // duplicate pair surfaces its per-entry diagnostic too (not
4784 // the duplicate diagnostic that would otherwise fire on the
4785 // second entry). Pinned separately so a future shortcut that
4786 // walks the duplicate-check ahead of the per-entry pass for the
4787 // first entry only — easy regression to introduce — surfaces
4788 // here.
4789 let entries = vec![
4790 entry(
4791 "0.1.0",
4792 vec![UpgradeInstruction::LoadModule {
4793 module: String::new(),
4794 }],
4795 ),
4796 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4797 ];
4798 let err = validate_upgrade_from(&entries).unwrap_err();
4799 assert_eq!(
4800 err,
4801 UpgradeError::ModuleEmpty {
4802 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4803 },
4804 "malformed instruction on the first entry of a duplicate pair must surface its \
4805 per-entry diagnostic before the duplicate gate fires, got {err:?}"
4806 );
4807 }
4808
4809 #[test]
4810 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4811 // Diagnostic-shape pin: when three entries carry the same
4812 // `:from`, the gate reports the *first* collision (the second
4813 // entry) and stops — the third entry's duplicate is masked by
4814 // the first surfaced one. Mirrors
4815 // `validate_duplicate_child_diagnostic_names_first_collision`
4816 // (dbf50a9) on the supervisor axis.
4817 let entries = vec![
4818 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4819 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4820 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4821 ];
4822 let err = validate_upgrade_from(&entries).unwrap_err();
4823 assert_eq!(
4824 err,
4825 UpgradeError::DuplicateFrom {
4826 from: "0.1.0".into()
4827 }
4828 );
4829 }
4830
4831 #[test]
4832 fn validate_upgrade_from_single_entry_never_duplicates() {
4833 // Boundary control: a list of one entry can never produce a
4834 // duplicate, regardless of `:from` value (any single-element
4835 // set is trivially without duplicates). Pin this so a future
4836 // off-by-one in the seen-set insert doesn't accidentally flag
4837 // a single entry as duplicating itself.
4838 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4839 validate_upgrade_from(&entries).unwrap();
4840 }
4841
4842 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
4843
4844 #[test]
4845 fn versao_gate_accepts_strict_upgrade() {
4846 // Positive control: the canonical "chain prior versions →
4847 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
4848 // `:from` strictly less than the current `:versao` under
4849 // SemVer-2 precedence. The gate must not regress this baseline.
4850 let entries = vec![
4851 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4852 entry("0.1.5", vec![UpgradeInstruction::Restart]),
4853 entry("0.1.9", vec![UpgradeInstruction::Restart]),
4854 ];
4855 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4856 }
4857
4858 #[test]
4859 fn versao_gate_accepts_empty_entries() {
4860 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
4861 // the gate is a no-op when the entries list is empty. Mirrors
4862 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
4863 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
4864 }
4865
4866 #[test]
4867 fn versao_gate_rejects_equal_from() {
4868 // Self-upgrade no-op: declaring `:from "0.2.0"` while
4869 // `:versao "0.2.0"` means "upgrade from myself to myself" —
4870 // the operator's dispatch either skips silently or
4871 // trivially "succeeds" with no observable state change.
4872 // Reject as the canonical "I forgot to bump :versao when
4873 // adding this entry" footgun.
4874 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4875 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4876 assert_eq!(
4877 err,
4878 UpgradeError::FromNotBeforeVersao {
4879 from: "0.2.0".into(),
4880 versao: "0.2.0".into(),
4881 },
4882 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
4883 values verbatim, got {err:?}"
4884 );
4885 }
4886
4887 #[test]
4888 fn versao_gate_rejects_downgrade_from() {
4889 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
4890 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
4891 // the operator's `:from`-match dispatch can never reach (it
4892 // never runs a version >= the current one). Reject as the
4893 // canonical "I copy-pasted from the next minor version and
4894 // forgot to bump :versao" footgun.
4895 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
4896 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4897 assert_eq!(
4898 err,
4899 UpgradeError::FromNotBeforeVersao {
4900 from: "0.3.0".into(),
4901 versao: "0.2.0".into(),
4902 }
4903 );
4904 }
4905
4906 #[test]
4907 fn versao_gate_accepts_prerelease_before_release() {
4908 // SemVer §11 precedence: pre-release versions are *less than*
4909 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
4910 // FROM an RC TO the GA release is the canonical authoring
4911 // shape — must pass. A regression that collapses pre-release
4912 // into the release version (treating them as equal) surfaces
4913 // here as a false-positive rejection.
4914 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
4915 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4916 }
4917
4918 #[test]
4919 fn versao_gate_rejects_release_after_prerelease() {
4920 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
4921 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
4922 // the typical "I'm on an RC of a release that already
4923 // shipped" footgun. The gate names both values verbatim
4924 // so the author can grep for either side and fix in one
4925 // edit.
4926 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
4927 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
4928 assert_eq!(
4929 err,
4930 UpgradeError::FromNotBeforeVersao {
4931 from: "0.2.0".into(),
4932 versao: "0.2.0-rc.1".into(),
4933 }
4934 );
4935 }
4936
4937 #[test]
4938 fn versao_gate_rejects_build_metadata_only_difference() {
4939 // SemVer §11 explicitly excludes build metadata from
4940 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
4941 // *equal* under [`semver::Version::cmp`]. From the
4942 // operator's `:from`-match dispatch perspective this is a
4943 // self-upgrade no-op (no semantic transition between the
4944 // two), so the gate rejects it — *unlike* the peer
4945 // duplicate-`:from` gate which uses derived `PartialEq` and
4946 // treats build-metadata variants as distinct dispatch keys.
4947 // The two gates' different equality notions are deliberate:
4948 // duplicate-check is conservative (preserves operator-side
4949 // tiebreaking surface), precedence-check is permissive
4950 // (matches operator-side dispatch semantic).
4951 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
4952 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
4953 assert_eq!(
4954 err,
4955 UpgradeError::FromNotBeforeVersao {
4956 from: "0.2.0+build.1".into(),
4957 versao: "0.2.0".into(),
4958 }
4959 );
4960 }
4961
4962 #[test]
4963 fn versao_gate_silently_passes_on_unparseable_versao() {
4964 // Defensive arm: a malformed `:versao` (gated by the
4965 // narrower `ManifestError::VersaoInvalid` surface at the
4966 // load-bearing call site) must not regress into a
4967 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
4968 // the precedence error over an unparseable `:versao` would
4969 // mask the more actionable root cause (the author meant to
4970 // type `"0.2.0"`, not `"v0.2.0"`).
4971 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
4972 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
4973 }
4974
4975 #[test]
4976 fn versao_gate_silently_passes_on_unparseable_from() {
4977 // Symmetric defensive arm: a malformed `:from` is gated by
4978 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
4979 // upstream at the LayoutInvariants call site. Surfacing the
4980 // precedence error over an unparseable `:from` from this
4981 // gate alone would mask the narrower `FromInvalid`
4982 // diagnostic that's expected to lead — same fall-through
4983 // posture as the unparseable-`:versao` arm above. The
4984 // wiring in `LayoutInvariants::verify` runs
4985 // `validate_upgrade_from` *before* this gate, so in practice
4986 // an unparseable `:from` surfaces as `FromInvalid` first
4987 // and this gate is never reached on that input.
4988 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
4989 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
4990 }
4991
4992 #[test]
4993 fn versao_gate_reports_first_offending_entry() {
4994 // Determinism pin: with multiple offending entries the gate
4995 // surfaces the *first* one in declaration order — same
4996 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
4997 // on the peer gate. Walks the entries in order; first
4998 // failing `:from >= :versao` short-circuits.
4999 let entries = vec![
5000 entry("0.1.0", vec![UpgradeInstruction::Restart]),
5001 entry("0.3.0", vec![UpgradeInstruction::Restart]),
5002 entry("0.4.0", vec![UpgradeInstruction::Restart]),
5003 ];
5004 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5005 assert_eq!(
5006 err,
5007 UpgradeError::FromNotBeforeVersao {
5008 from: "0.3.0".into(),
5009 versao: "0.2.0".into(),
5010 },
5011 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
5012 );
5013 }
5014
5015 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
5016
5017 #[test]
5018 fn validate_rejects_restart_mixed_with_load_module() {
5019 // The "I'll try the typed path *then* restart anyway" footgun:
5020 // an instructions list with `(:restart)` plus `(:load-module …)`
5021 // is dead code in both directions (succeed → restart discards
5022 // the work that just succeeded, defeating the typed sequence's
5023 // whole point; fail → restart never reached because the entry
5024 // already failed). The gate names the offending entry's `:from`
5025 // verbatim plus the kebab-case lisp-form of every non-`:restart`
5026 // peer so the author can grep their caixa.lisp for either side
5027 // and fix in one edit.
5028 let e = entry(
5029 "0.1.0",
5030 vec![
5031 UpgradeInstruction::LoadModule {
5032 module: "hello-rio".into(),
5033 },
5034 UpgradeInstruction::Restart,
5035 ],
5036 );
5037 let err = e.validate().unwrap_err();
5038 assert_eq!(
5039 err,
5040 UpgradeError::RestartNotExclusive {
5041 from: "0.1.0".into(),
5042 restart_count: 1,
5043 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
5044 },
5045 "restart + load-module mix must surface as RestartNotExclusive naming the \
5046 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
5047 );
5048 }
5049
5050 #[test]
5051 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
5052 // Sweep the typed-sequence universe — every non-`:restart`
5053 // variant alongside `:restart` — and assert every typed
5054 // instruction's lisp-form appears in `other_kinds` in
5055 // declaration order. The author should be able to grep for
5056 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
5057 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
5058 // mapping surfaces here.
5059 let e = entry(
5060 "0.1.0",
5061 vec![
5062 UpgradeInstruction::LoadModule {
5063 module: "hello-rio".into(),
5064 },
5065 UpgradeInstruction::StateChange {
5066 script: PathBuf::from("lib/m.lisp"),
5067 },
5068 UpgradeInstruction::SoftPurge {
5069 module: "hello-rio-old".into(),
5070 },
5071 UpgradeInstruction::Purge {
5072 module: "hello-rio-old".into(),
5073 },
5074 UpgradeInstruction::Restart,
5075 ],
5076 );
5077 let err = e.validate().unwrap_err();
5078 assert_eq!(
5079 err,
5080 UpgradeError::RestartNotExclusive {
5081 from: "0.1.0".into(),
5082 restart_count: 1,
5083 other_kinds: vec![
5084 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5085 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
5086 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5087 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5088 ],
5089 },
5090 );
5091 }
5092
5093 #[test]
5094 fn validate_rejects_restart_duplicated() {
5095 // `((:restart) (:restart))` — multiple Restart variants in one
5096 // entry. The fallback is a single semantic (restart the pod;
5097 // the new version comes up fresh); repeating it is at best
5098 // redundant, at worst suggests the author thought the second
5099 // would re-trigger after the first. The gate reports
5100 // `restart_count: 2` so the diagnostic surfaces the duplication
5101 // mode unambiguously even when `other_kinds` is empty.
5102 let e = entry(
5103 "0.1.0",
5104 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5105 );
5106 let err = e.validate().unwrap_err();
5107 assert_eq!(
5108 err,
5109 UpgradeError::RestartNotExclusive {
5110 from: "0.1.0".into(),
5111 restart_count: 2,
5112 other_kinds: vec![],
5113 },
5114 );
5115 }
5116
5117 #[test]
5118 fn validate_accepts_sole_restart() {
5119 // Positive control: the canonical "this prior version's typed
5120 // upgrade is impossible — restart" authoring shape from the
5121 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
5122 // is the entry's whole instructions list and the only valid
5123 // Restart-bearing shape.
5124 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
5125 e.validate().unwrap();
5126 }
5127
5128 #[test]
5129 fn validate_accepts_typed_sequence_without_restart() {
5130 // Positive control: the canonical typed hot-upgrade authoring
5131 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
5132 // `:state-change` → `:soft-purge`. Absent `:restart` is the
5133 // only shape that lets the sequence run to completion under
5134 // the wasm-operator's `:from`-match dispatch. Drift here =
5135 // a future tighten that rejects any canonical typed-only shape
5136 // surfaces as a regression at this gate.
5137 let e = entry(
5138 "0.1.0",
5139 vec![
5140 UpgradeInstruction::LoadModule {
5141 module: "hello-rio".into(),
5142 },
5143 UpgradeInstruction::StateChange {
5144 script: PathBuf::from("lib/m.lisp"),
5145 },
5146 UpgradeInstruction::SoftPurge {
5147 module: "hello-rio-old".into(),
5148 },
5149 ],
5150 );
5151 e.validate().unwrap();
5152 }
5153
5154 // ── within-entry state-change-ordering invariant ───────────────────
5155
5156 #[test]
5157 fn validate_rejects_state_change_without_load() {
5158 // Fail-before-pass-after pin: a `:state-change` migrates state
5159 // into the newly-loaded code (gen_server:code_change/3 analog),
5160 // so an entry that runs it with no preceding `:load-module`
5161 // migrates state into code that was never loaded. The operator
5162 // runs instructions in declared order, so this is a build error,
5163 // not a runtime surprise (CAIXA-SDLC §III).
5164 let e = entry(
5165 "0.1.0",
5166 vec![UpgradeInstruction::StateChange {
5167 script: PathBuf::from("lib/m.lisp"),
5168 }],
5169 );
5170 let err = e.validate().unwrap_err();
5171 assert_eq!(
5172 err,
5173 UpgradeError::StateChangeWithoutPriorLoad {
5174 from: "0.1.0".into(),
5175 script: PathBuf::from("lib/m.lisp"),
5176 },
5177 "a `:state-change` with no preceding `:load-module` must surface as \
5178 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
5179 );
5180 }
5181
5182 #[test]
5183 fn validate_rejects_state_change_before_load() {
5184 // Right-instructions-wrong-order: the load is present but runs
5185 // *after* the migration. Because the operator executes in
5186 // declared order, the migration runs before the new code is
5187 // resident — the same incoherence as the missing-load case.
5188 let e = entry(
5189 "0.1.0",
5190 vec![
5191 UpgradeInstruction::StateChange {
5192 script: PathBuf::from("lib/m.lisp"),
5193 },
5194 UpgradeInstruction::LoadModule {
5195 module: "hello-rio".into(),
5196 },
5197 ],
5198 );
5199 let err = e.validate().unwrap_err();
5200 assert!(
5201 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5202 "a `:state-change` ahead of its `:load-module` must surface as \
5203 StateChangeWithoutPriorLoad, got {err:?}"
5204 );
5205 }
5206
5207 #[test]
5208 fn validate_accepts_state_change_after_load() {
5209 // Positive control: the canonical `(:load-module …)
5210 // (:state-change …)` order validates. The load need not name
5211 // the same module the migration targets (StateChange carries a
5212 // script, not a module ref), so any preceding `:load-module`
5213 // satisfies "new code is resident before its migration runs".
5214 let e = entry(
5215 "0.1.0",
5216 vec![
5217 UpgradeInstruction::LoadModule {
5218 module: "hello-rio".into(),
5219 },
5220 UpgradeInstruction::StateChange {
5221 script: PathBuf::from("lib/m.lisp"),
5222 },
5223 ],
5224 );
5225 e.validate().unwrap();
5226 }
5227
5228 #[test]
5229 fn validate_accepts_multiple_state_changes_after_one_load() {
5230 // A single leading `:load-module` covers every subsequent
5231 // `:state-change` — the `loaded` latch stays set once the new
5232 // code is resident.
5233 let e = entry(
5234 "0.1.0",
5235 vec![
5236 UpgradeInstruction::LoadModule {
5237 module: "hello-rio".into(),
5238 },
5239 UpgradeInstruction::StateChange {
5240 script: PathBuf::from("lib/m1.lisp"),
5241 },
5242 UpgradeInstruction::StateChange {
5243 script: PathBuf::from("lib/m2.lisp"),
5244 },
5245 ],
5246 );
5247 e.validate().unwrap();
5248 }
5249
5250 #[test]
5251 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5252 {
5253 // Byte-identity pin on the
5254 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5255 // migrate ordering dispatch against the pre-lift
5256 // `match instr { UpgradeInstruction::LoadModule { .. } =>
5257 // loaded = true, UpgradeInstruction::StateChange { script } if
5258 // !loaded => …, _ => {} }` open-coded pattern-match the site
5259 // previously carried. Asserts the two projections agree
5260 // byte-for-byte on every arm of the enum — the load-family
5261 // arm-discriminator via `is_load_module()` and the migration-
5262 // family `:script` scalar via `declared_path()` — so a future
5263 // derive regression that flipped the predicate's arm-set (a
5264 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5265 // a byte-collision flipping a second variant to `true`) or an
5266 // accessor extension that promoted an additional variant onto
5267 // the `PathBuf`-carrying axis would trip here at caixa-core
5268 // test time rather than laundering the arm at the gate's
5269 // per-entry ordering scan far from the derive site.
5270 //
5271 // Peer of the sibling
5272 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5273 // (c9ce91d) pin on the peer within-entry per-instruction-class
5274 // singularity gate's load-family + `String`-carrying dispatch,
5275 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5276 // (580d0f1) pin on the paired load → cleanup ordering gate's
5277 // load-family sticky-latch dispatch, and the
5278 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5279 // pin on the peer within-entry per-instruction-class singularity
5280 // gate's migration-family script-projection dispatch — closes
5281 // the last unlifted `match`-shaped per-arm-hand-rolled load-
5282 // family arm-discriminator + migration-family script-projection
5283 // pair inside `impl UpgradeFromEntry`. The four within-entry
5284 // ordering / singularity gates now share one byte-identity pin
5285 // apiece against their respective substrate-primitive typed
5286 // dispatches on the OTP-appup closed-set enum.
5287 //
5288 // Three-arm projective coverage:
5289 // (a) `LoadModule` satisfies `is_load_module()`, so the
5290 // sticky-latch advances byte-equal to the pre-lift
5291 // `UpgradeInstruction::LoadModule { .. }` arm; every
5292 // other variant leaves the latch untouched;
5293 // (b) a `((:state-change …))`-only entry (no preceding load)
5294 // trips the gate on the first `StateChange` with
5295 // `StateChangeWithoutPriorLoad` carrying the offending
5296 // script verbatim — the migration-family script surfaces
5297 // through `declared_path()` byte-equal to the raw
5298 // `StateChange { script }` pattern-bound field;
5299 // (c) a `((:load-module …) (:state-change …))` entry leaves
5300 // the gate vacuous with `Ok(())` — the `loaded = true`
5301 // latch on the first arm satisfies the `!loaded` guard
5302 // negation on the second, so the `declared_path()`
5303 // `Some(script)` fall-through does not fire — and a
5304 // non-`StateChange`-non-`LoadModule` sequence
5305 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5306 // the gate vacuous because `declared_path()` is `None`
5307 // on all three of those arms.
5308 //
5309 // Fail-before-pass-after verified locally: swapping the
5310 // production `if instr.is_load_module() { loaded = true; }
5311 // else if !loaded && let Some(script) = instr.declared_path()
5312 // { … }` back to `match instr { UpgradeInstruction::LoadModule
5313 // { .. } => loaded = true, UpgradeInstruction::StateChange
5314 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5315 // passing but silently detaches the gate from the accessor's
5316 // typed dispatch — any future `is_load_module` / `declared_path`
5317 // extension (a hole in either predicate, a promotion of an
5318 // additional variant onto either axis, an operator-side
5319 // pre-resolved-path cache the accessor materializes) would
5320 // then silently disagree between this gate's raw pattern-match
5321 // and the peer per-`UpgradeInstruction` consumers that route
5322 // through the accessor pair.
5323
5324 // (a) is_load_module() partitions the arm-set byte-equal to
5325 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5326 // { .. })` and declared_path() surfaces the StateChange
5327 // `:script` byte-equal to the raw field access.
5328 let lm = UpgradeInstruction::LoadModule {
5329 module: "hello-rio".into(),
5330 };
5331 assert!(
5332 lm.is_load_module(),
5333 "LoadModule must satisfy is_load_module() — the gate's \
5334 load-family sticky-latch relies on this partition"
5335 );
5336 assert!(
5337 lm.declared_path().is_none(),
5338 "LoadModule must not carry a declared_path — the gate's \
5339 else-if migration-family arm must not fire on load arms"
5340 );
5341 let sc = UpgradeInstruction::StateChange {
5342 script: PathBuf::from("lib/m.lisp"),
5343 };
5344 assert!(
5345 !sc.is_load_module(),
5346 "StateChange must not satisfy is_load_module() — the gate's \
5347 sticky-latch must not advance on migration arms"
5348 );
5349 assert_eq!(
5350 sc.declared_path().map(std::path::PathBuf::as_path),
5351 Some(PathBuf::from("lib/m.lisp").as_path()),
5352 "declared_path() must project the StateChange :script \
5353 byte-equal to the raw field access — accessor divergence \
5354 would silently detach the gate from the projection every \
5355 peer per-`UpgradeInstruction` consumer routes through"
5356 );
5357
5358 // (b) A `((:state-change …))`-only entry trips
5359 // StateChangeWithoutPriorLoad byte-identical to the
5360 // pre-lift match-pattern shape.
5361 let no_prior_load = entry(
5362 "0.1.0",
5363 vec![UpgradeInstruction::StateChange {
5364 script: PathBuf::from("lib/m.lisp"),
5365 }],
5366 );
5367 assert_eq!(
5368 no_prior_load.validate_state_change_ordering(),
5369 Err(UpgradeError::StateChangeWithoutPriorLoad {
5370 from: "0.1.0".into(),
5371 script: PathBuf::from("lib/m.lisp"),
5372 }),
5373 "a `:state-change` with no preceding `:load-module` must fire \
5374 StateChangeWithoutPriorLoad carrying the offending script \
5375 verbatim through the declared_path() accessor"
5376 );
5377
5378 // (c) `((:load-module …) (:state-change …))` leaves the gate
5379 // vacuous; so does a non-StateChange-non-LoadModule
5380 // sequence (SoftPurge / Purge / Restart alone).
5381 let load_before_migrate = entry(
5382 "0.1.0",
5383 vec![
5384 UpgradeInstruction::LoadModule {
5385 module: "hello-rio".into(),
5386 },
5387 UpgradeInstruction::StateChange {
5388 script: PathBuf::from("lib/m.lisp"),
5389 },
5390 ],
5391 );
5392 assert_eq!(
5393 load_before_migrate.validate_state_change_ordering(),
5394 Ok(()),
5395 "load-before-migrate entries must leave the ordering gate \
5396 vacuous — the `loaded = true` sticky-latch on the first arm \
5397 satisfies the `!loaded` guard negation on the else-if arm"
5398 );
5399 for instr in [
5400 UpgradeInstruction::SoftPurge {
5401 module: "x-old".into(),
5402 },
5403 UpgradeInstruction::Purge {
5404 module: "x-old".into(),
5405 },
5406 UpgradeInstruction::Restart,
5407 ] {
5408 let e = entry("0.1.0", vec![instr.clone()]);
5409 assert_eq!(
5410 e.validate_state_change_ordering(),
5411 Ok(()),
5412 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5413 leave the ordering gate vacuous — declared_path() is None \
5414 on every non-StateChange arm, so the else-if migration-\
5415 family arm never fires"
5416 );
5417 }
5418 }
5419
5420 #[test]
5421 fn validate_state_change_ordering_fires_after_restart_exclusive() {
5422 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5423 // shape is *both* state-change-without-load and restart-mixed.
5424 // The more-fundamental `RestartNotExclusive` must win (a valid
5425 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5426 // entry should reach the ordering gate). Guards the call order
5427 // in `validate` against silent reordering.
5428 let e = entry(
5429 "0.1.0",
5430 vec![
5431 UpgradeInstruction::StateChange {
5432 script: PathBuf::from("lib/m.lisp"),
5433 },
5434 UpgradeInstruction::Restart,
5435 ],
5436 );
5437 let err = e.validate().unwrap_err();
5438 assert!(
5439 matches!(err, UpgradeError::RestartNotExclusive { .. }),
5440 "restart-mixed must surface before the ordering gate, got {err:?}"
5441 );
5442 }
5443
5444 // ── within-entry purge-ordering invariant ──────────────────────────
5445
5446 #[test]
5447 fn validate_rejects_soft_purge_without_load() {
5448 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5449 // module after the new one is resident (OTP's two-phase code
5450 // load — code:load_module/1 then code:soft_purge/1), so an
5451 // entry that runs it with no preceding `:load-module` drains
5452 // the live module with no replacement. The operator runs
5453 // instructions in declared order, so this is a build error,
5454 // not a runtime surprise (CAIXA-SDLC §III).
5455 let e = entry(
5456 "0.1.0",
5457 vec![UpgradeInstruction::SoftPurge {
5458 module: "x-old".into(),
5459 }],
5460 );
5461 let err = e.validate().unwrap_err();
5462 assert_eq!(
5463 err,
5464 UpgradeError::PurgeWithoutPriorLoad {
5465 from: "0.1.0".into(),
5466 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5467 module: "x-old".into(),
5468 },
5469 "a `:soft-purge` with no preceding `:load-module` must surface as \
5470 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5471 );
5472 }
5473
5474 #[test]
5475 fn validate_rejects_purge_without_load() {
5476 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5477 // the more catastrophic peer of `:soft-purge`; same gate, same
5478 // shape, kind-tag differs so the author can grep their
5479 // caixa.lisp for the offending `(:purge …)` form.
5480 let e = entry(
5481 "0.1.0",
5482 vec![UpgradeInstruction::Purge {
5483 module: "x-old".into(),
5484 }],
5485 );
5486 let err = e.validate().unwrap_err();
5487 assert_eq!(
5488 err,
5489 UpgradeError::PurgeWithoutPriorLoad {
5490 from: "0.1.0".into(),
5491 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5492 module: "x-old".into(),
5493 },
5494 );
5495 }
5496
5497 #[test]
5498 fn validate_rejects_soft_purge_before_load() {
5499 // Right-instructions-wrong-order: the load is present but runs
5500 // *after* the purge. Because the operator executes in declared
5501 // order, the cleanup drains the old code before the new code
5502 // is resident — same incoherence as the missing-load case,
5503 // leaving a window during which neither version is available.
5504 let e = entry(
5505 "0.1.0",
5506 vec![
5507 UpgradeInstruction::SoftPurge {
5508 module: "x-old".into(),
5509 },
5510 UpgradeInstruction::LoadModule { module: "x".into() },
5511 ],
5512 );
5513 let err = e.validate().unwrap_err();
5514 assert!(
5515 matches!(
5516 err,
5517 UpgradeError::PurgeWithoutPriorLoad {
5518 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5519 ..
5520 }
5521 ),
5522 "a `:soft-purge` ahead of its `:load-module` must surface as \
5523 PurgeWithoutPriorLoad, got {err:?}"
5524 );
5525 }
5526
5527 #[test]
5528 fn validate_rejects_purge_before_load() {
5529 // Symmetric arm on the `:purge` variant — the kind tag
5530 // distinguishes the diagnostic so the author lands on the
5531 // offending form directly.
5532 let e = entry(
5533 "0.1.0",
5534 vec![
5535 UpgradeInstruction::Purge {
5536 module: "x-old".into(),
5537 },
5538 UpgradeInstruction::LoadModule { module: "x".into() },
5539 ],
5540 );
5541 let err = e.validate().unwrap_err();
5542 assert!(
5543 matches!(
5544 err,
5545 UpgradeError::PurgeWithoutPriorLoad {
5546 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5547 ..
5548 }
5549 ),
5550 "a `:purge` ahead of its `:load-module` must surface as \
5551 PurgeWithoutPriorLoad, got {err:?}"
5552 );
5553 }
5554
5555 #[test]
5556 fn validate_accepts_soft_purge_after_load() {
5557 // Positive control: the canonical `(:load-module …)
5558 // (:soft-purge …)` order validates. The load need not name the
5559 // same module the purge targets — the cleanup typically targets
5560 // the *old* module name (e.g. `"x-old"`) and the load brings up
5561 // the *new* one (`"x"`); the gate only requires that *some*
5562 // `:load-module` precedes the purge, so the new code is resident
5563 // before the old one is drained.
5564 let e = entry(
5565 "0.1.0",
5566 vec![
5567 UpgradeInstruction::LoadModule { module: "x".into() },
5568 UpgradeInstruction::SoftPurge {
5569 module: "x-old".into(),
5570 },
5571 ],
5572 );
5573 e.validate().unwrap();
5574 }
5575
5576 #[test]
5577 fn validate_accepts_multiple_purges_after_one_load() {
5578 // A single leading `:load-module` covers every subsequent
5579 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5580 // the new code is resident. Same shape as
5581 // `validate_accepts_multiple_state_changes_after_one_load` on
5582 // the peer ordering gate.
5583 let e = entry(
5584 "0.1.0",
5585 vec![
5586 UpgradeInstruction::LoadModule { module: "x".into() },
5587 UpgradeInstruction::SoftPurge {
5588 module: "x-old".into(),
5589 },
5590 UpgradeInstruction::Purge {
5591 module: "x-oldest".into(),
5592 },
5593 ],
5594 );
5595 e.validate().unwrap();
5596 }
5597
5598 #[test]
5599 fn validate_purge_ordering_fires_after_state_change_ordering() {
5600 // Diagnostic-precedence pin: an entry like `((:state-change …)
5601 // (:soft-purge …))` is *both* state-change-without-load and
5602 // purge-without-load. The state-change gate must win — it's
5603 // the load-bearing semantic on this ordering contract, and
5604 // surfacing the purge diagnostic first would mask the more-
5605 // fundamental migration-against-stale-code defect. Guards the
5606 // call order in `validate` against silent reordering.
5607 let e = entry(
5608 "0.1.0",
5609 vec![
5610 UpgradeInstruction::StateChange {
5611 script: PathBuf::from("lib/m.lisp"),
5612 },
5613 UpgradeInstruction::SoftPurge {
5614 module: "x-old".into(),
5615 },
5616 ],
5617 );
5618 let err = e.validate().unwrap_err();
5619 assert!(
5620 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5621 "state-change-without-load must surface before purge-without-load, got {err:?}"
5622 );
5623 }
5624
5625 #[test]
5626 fn validate_purge_ordering_fires_after_per_instr_shape() {
5627 // Order pin: a malformed `:module` value on a `:soft-purge` (an
5628 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5629 // diagnostic *before* the within-entry purge-ordering gate fires.
5630 // The per-instruction shape pass walks the list inline before
5631 // the ordering checks, so the narrower self-locating diagnostic
5632 // surfaces first — mirrors the empty-first cascade on every peer
5633 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5634 // per_instr_shape` pin on the sibling ordering gate.
5635 let e = entry(
5636 "0.1.0",
5637 vec![UpgradeInstruction::SoftPurge {
5638 module: String::new(),
5639 }],
5640 );
5641 let err = e.validate().unwrap_err();
5642 assert_eq!(
5643 err,
5644 UpgradeError::ModuleEmpty {
5645 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5646 },
5647 "malformed instruction must surface its kind-tagged diagnostic before the \
5648 purge-ordering gate fires, got {err:?}"
5649 );
5650 }
5651
5652 #[test]
5653 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5654 // The whole-list entry-point surfaces the per-entry ordering
5655 // error (mirrors
5656 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5657 // the gate is reachable from the LayoutInvariants call site, not
5658 // only from a direct `entry.validate()`.
5659 let entries = vec![entry(
5660 "0.1.0",
5661 vec![UpgradeInstruction::Purge {
5662 module: "x-old".into(),
5663 }],
5664 )];
5665 let err = validate_upgrade_from(&entries).unwrap_err();
5666 assert!(
5667 matches!(
5668 err,
5669 UpgradeError::PurgeWithoutPriorLoad {
5670 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5671 ..
5672 }
5673 ),
5674 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5675 );
5676 }
5677
5678 #[test]
5679 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5680 // The whole-list entry-point surfaces the per-entry ordering
5681 // error (mirrors `validate_restart_exclusive_threads_through_…`):
5682 // the gate is reachable from the LayoutInvariants call site, not
5683 // only from a direct `entry.validate()`.
5684 let entries = vec![entry(
5685 "0.1.0",
5686 vec![UpgradeInstruction::StateChange {
5687 script: PathBuf::from("lib/m.lisp"),
5688 }],
5689 )];
5690 let err = validate_upgrade_from(&entries).unwrap_err();
5691 assert!(
5692 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5693 "validate_upgrade_from must thread the ordering error, got {err:?}"
5694 );
5695 }
5696
5697 // ── within-entry cleanup-singularity invariant ─────────────────────
5698
5699 #[test]
5700 fn validate_rejects_duplicate_soft_purge_for_same_module() {
5701 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5702 // its target module (code:soft_purge/1 analog); after the
5703 // first the module is gone, so a second `:soft-purge` of the
5704 // same module is at best a no-op and at worst undefined
5705 // (depending on the operator's handling of a non-resident-
5706 // module purge). Author one cleanup per module.
5707 let e = entry(
5708 "0.1.0",
5709 vec![
5710 UpgradeInstruction::LoadModule { module: "x".into() },
5711 UpgradeInstruction::SoftPurge {
5712 module: "x-old".into(),
5713 },
5714 UpgradeInstruction::SoftPurge {
5715 module: "x-old".into(),
5716 },
5717 ],
5718 );
5719 let err = e.validate().unwrap_err();
5720 assert_eq!(
5721 err,
5722 UpgradeError::DuplicateCleanup {
5723 from: "0.1.0".into(),
5724 module: "x-old".into(),
5725 kinds: vec![
5726 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5727 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5728 ],
5729 },
5730 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5731 module + both kinds in declaration order, got {err:?}"
5732 );
5733 }
5734
5735 #[test]
5736 fn validate_rejects_duplicate_purge_for_same_module() {
5737 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5738 // the more catastrophic peer of `:soft-purge`; same gate, same
5739 // shape, kind-tag distinguishes so the author can grep their
5740 // caixa.lisp for the offending `(:purge …)` form.
5741 let e = entry(
5742 "0.1.0",
5743 vec![
5744 UpgradeInstruction::LoadModule { module: "x".into() },
5745 UpgradeInstruction::Purge {
5746 module: "x-old".into(),
5747 },
5748 UpgradeInstruction::Purge {
5749 module: "x-old".into(),
5750 },
5751 ],
5752 );
5753 let err = e.validate().unwrap_err();
5754 assert_eq!(
5755 err,
5756 UpgradeError::DuplicateCleanup {
5757 from: "0.1.0".into(),
5758 module: "x-old".into(),
5759 kinds: vec![
5760 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5761 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5762 ],
5763 },
5764 );
5765 }
5766
5767 #[test]
5768 fn validate_rejects_soft_purge_then_purge_for_same_module() {
5769 // Soft-then-hard footgun: the author wrote "drain, and if
5770 // drain doesn't clean up, force-discard", but the operator
5771 // runs declared instructions unconditionally — the `:purge`
5772 // fires whether the `:soft-purge` already discarded the
5773 // module or not, so the imagined fallback semantic is
5774 // missing. Fallback on cleanup failure is the operator's
5775 // job, not authored into the entry. Both kinds carry in
5776 // declaration order so the author can grep for either side
5777 // and pick one.
5778 let e = entry(
5779 "0.1.0",
5780 vec![
5781 UpgradeInstruction::LoadModule { module: "x".into() },
5782 UpgradeInstruction::SoftPurge {
5783 module: "x-old".into(),
5784 },
5785 UpgradeInstruction::Purge {
5786 module: "x-old".into(),
5787 },
5788 ],
5789 );
5790 let err = e.validate().unwrap_err();
5791 assert_eq!(
5792 err,
5793 UpgradeError::DuplicateCleanup {
5794 from: "0.1.0".into(),
5795 module: "x-old".into(),
5796 kinds: vec![
5797 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5798 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5799 ],
5800 },
5801 );
5802 }
5803
5804 #[test]
5805 fn validate_rejects_purge_then_soft_purge_for_same_module() {
5806 // Reversed-ordering arm: `:purge` discards immediately; the
5807 // trailing `:soft-purge` has no module to drain. The kinds
5808 // list reflects declaration order so the diagnostic locates
5809 // both forms in the source.
5810 let e = entry(
5811 "0.1.0",
5812 vec![
5813 UpgradeInstruction::LoadModule { module: "x".into() },
5814 UpgradeInstruction::Purge {
5815 module: "x-old".into(),
5816 },
5817 UpgradeInstruction::SoftPurge {
5818 module: "x-old".into(),
5819 },
5820 ],
5821 );
5822 let err = e.validate().unwrap_err();
5823 assert_eq!(
5824 err,
5825 UpgradeError::DuplicateCleanup {
5826 from: "0.1.0".into(),
5827 module: "x-old".into(),
5828 kinds: vec![
5829 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5830 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5831 ],
5832 },
5833 );
5834 }
5835
5836 #[test]
5837 fn validate_accepts_distinct_cleanup_modules() {
5838 // Positive control: `:soft-purge` and `:purge` on *different*
5839 // modules pass the gate. Mirrors
5840 // `validate_accepts_multiple_purges_after_one_load` — the
5841 // cleanup-singularity gate is keyed on (module), not on
5842 // (kind, module) pair, so distinct old-version names render
5843 // distinct cleanup targets and don't collide. Sweep both
5844 // same-class (two `:soft-purge` distinct modules) and cross-
5845 // class (`:soft-purge` then `:purge` distinct modules) so a
5846 // future tighten to a kind-only key (which would over-fire on
5847 // distinct modules) surfaces here.
5848 let two_soft = entry(
5849 "0.1.0",
5850 vec![
5851 UpgradeInstruction::LoadModule { module: "x".into() },
5852 UpgradeInstruction::SoftPurge {
5853 module: "x-old".into(),
5854 },
5855 UpgradeInstruction::SoftPurge {
5856 module: "x-older".into(),
5857 },
5858 ],
5859 );
5860 two_soft.validate().unwrap();
5861 let mixed = entry(
5862 "0.1.0",
5863 vec![
5864 UpgradeInstruction::LoadModule { module: "x".into() },
5865 UpgradeInstruction::SoftPurge {
5866 module: "x-old".into(),
5867 },
5868 UpgradeInstruction::Purge {
5869 module: "x-oldest".into(),
5870 },
5871 ],
5872 );
5873 mixed.validate().unwrap();
5874 }
5875
5876 #[test]
5877 fn validate_accepts_single_cleanup_per_module() {
5878 // Boundary control: a list with exactly one `:soft-purge` and
5879 // one `:purge` (distinct modules, the canonical "drain one,
5880 // hard-discard the other" shape) is the gate's identity
5881 // element. Pin so a future off-by-one in the duplicate-detection
5882 // scan doesn't accidentally flag a single occurrence as
5883 // duplicating itself — mirrors
5884 // `validate_upgrade_from_single_entry_never_duplicates` on
5885 // the peer cross-entry duplicate axis.
5886 let e = entry(
5887 "0.1.0",
5888 vec![
5889 UpgradeInstruction::LoadModule { module: "x".into() },
5890 UpgradeInstruction::SoftPurge {
5891 module: "x-old".into(),
5892 },
5893 UpgradeInstruction::Purge {
5894 module: "y-old".into(),
5895 },
5896 ],
5897 );
5898 e.validate().unwrap();
5899 }
5900
5901 #[test]
5902 fn validate_cleanup_singularity_fires_after_purge_ordering() {
5903 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
5904 // (:soft-purge "x"))` is *both* purge-without-load and
5905 // duplicate-cleanup. The more-fundamental ordering gate must
5906 // win — the missing-load defect is load-bearing (the canonical
5907 // OTP shape requires the new code be resident before any
5908 // cleanup runs), and surfacing the duplicate diagnostic first
5909 // would mask the no-replacement-window defect the ordering
5910 // gate exists to close. Guards the call order in `validate`
5911 // against silent reordering. Same posture as
5912 // `validate_purge_ordering_fires_after_state_change_ordering`
5913 // on the sibling ordering gate.
5914 let e = entry(
5915 "0.1.0",
5916 vec![
5917 UpgradeInstruction::SoftPurge {
5918 module: "x-old".into(),
5919 },
5920 UpgradeInstruction::SoftPurge {
5921 module: "x-old".into(),
5922 },
5923 ],
5924 );
5925 let err = e.validate().unwrap_err();
5926 assert!(
5927 matches!(
5928 err,
5929 UpgradeError::PurgeWithoutPriorLoad {
5930 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5931 ..
5932 }
5933 ),
5934 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
5935 );
5936 }
5937
5938 #[test]
5939 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
5940 // Order pin: a malformed `:module` value on a `:soft-purge`
5941 // (an empty string) surfaces its narrower kind-tagged
5942 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
5943 // singularity gate fires. The per-instruction shape pass walks
5944 // the list inline before the singularity check, so the
5945 // narrower self-locating diagnostic surfaces first — mirrors
5946 // the empty-first cascade on every peer DNS-1123 gate and the
5947 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
5948 // the sibling ordering gate.
5949 //
5950 // Two empty-string `:soft-purge` would *otherwise* duplicate
5951 // (both modules are the same empty string), so this pin
5952 // double-locks the precedence: the per-instr shape gate must
5953 // win on the first malformed instruction before the duplicate
5954 // scan even reaches the second.
5955 let e = entry(
5956 "0.1.0",
5957 vec![
5958 UpgradeInstruction::LoadModule { module: "x".into() },
5959 UpgradeInstruction::SoftPurge {
5960 module: String::new(),
5961 },
5962 UpgradeInstruction::SoftPurge {
5963 module: String::new(),
5964 },
5965 ],
5966 );
5967 let err = e.validate().unwrap_err();
5968 assert_eq!(
5969 err,
5970 UpgradeError::ModuleEmpty {
5971 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5972 },
5973 "malformed instruction must surface its kind-tagged diagnostic before the \
5974 cleanup-singularity gate fires, got {err:?}"
5975 );
5976 }
5977
5978 #[test]
5979 fn validate_cleanup_singularity_reports_first_collision() {
5980 // Determinism pin: with three cleanups of the same module the
5981 // gate reports the *first* collision (the second occurrence)
5982 // and stops — the third's duplicate is masked by the first
5983 // surfaced one. Mirrors
5984 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5985 // on the peer cross-entry duplicate axis.
5986 let e = entry(
5987 "0.1.0",
5988 vec![
5989 UpgradeInstruction::LoadModule { module: "x".into() },
5990 UpgradeInstruction::SoftPurge {
5991 module: "x-old".into(),
5992 },
5993 UpgradeInstruction::SoftPurge {
5994 module: "x-old".into(),
5995 },
5996 UpgradeInstruction::Purge {
5997 module: "x-old".into(),
5998 },
5999 ],
6000 );
6001 let err = e.validate().unwrap_err();
6002 assert_eq!(
6003 err,
6004 UpgradeError::DuplicateCleanup {
6005 from: "0.1.0".into(),
6006 module: "x-old".into(),
6007 kinds: vec![
6008 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6009 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6010 ],
6011 },
6012 "the first colliding pair must surface, not the later `:purge` collision"
6013 );
6014 }
6015
6016 #[test]
6017 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
6018 // The whole-list entry-point surfaces the per-entry singularity
6019 // error (mirrors
6020 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
6021 // the gate is reachable from the LayoutInvariants call site,
6022 // not only from a direct `entry.validate()`.
6023 let entries = vec![entry(
6024 "0.1.0",
6025 vec![
6026 UpgradeInstruction::LoadModule { module: "x".into() },
6027 UpgradeInstruction::SoftPurge {
6028 module: "x-old".into(),
6029 },
6030 UpgradeInstruction::Purge {
6031 module: "x-old".into(),
6032 },
6033 ],
6034 )];
6035 let err = validate_upgrade_from(&entries).unwrap_err();
6036 assert!(
6037 matches!(err, UpgradeError::DuplicateCleanup { .. }),
6038 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
6039 );
6040 }
6041
6042 #[test]
6043 fn validate_rejects_duplicate_load_module_for_same_module() {
6044 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
6045 // §II.4): each module is loaded exactly once per upgrade entry,
6046 // the operator's dispatch table reads the module name to bind
6047 // the wasm component, and a second `(:load-module "x")` re-reads
6048 // the same module name and re-binds the same component — a
6049 // no-op the second time. systools-generated `.relup` files emit
6050 // at most one `load_module` per module per upgrade step for
6051 // this reason. Author one `(:load-module "x")` per old module.
6052 let e = entry(
6053 "0.1.0",
6054 vec![
6055 UpgradeInstruction::LoadModule { module: "x".into() },
6056 UpgradeInstruction::LoadModule { module: "x".into() },
6057 ],
6058 );
6059 let err = e.validate().unwrap_err();
6060 assert_eq!(
6061 err,
6062 UpgradeError::DuplicateLoadModule {
6063 from: "0.1.0".into(),
6064 module: "x".into(),
6065 },
6066 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
6067 the module, got {err:?}"
6068 );
6069 }
6070
6071 #[test]
6072 fn validate_accepts_distinct_load_modules() {
6073 // Positive control: `:load-module` instructions on *different*
6074 // modules pass the gate. Mirrors
6075 // `validate_accepts_distinct_cleanup_modules` on the sibling
6076 // singularity axis — the load-singularity gate is keyed on
6077 // (module), so distinct module names render distinct load
6078 // targets and don't collide. Sweep both the bare two-load shape
6079 // and the canonical load-pair-with-cleanup shape so a future
6080 // tighten that over-fires on distinct loads surfaces here.
6081 let two_loads = entry(
6082 "0.1.0",
6083 vec![
6084 UpgradeInstruction::LoadModule { module: "x".into() },
6085 UpgradeInstruction::LoadModule { module: "y".into() },
6086 ],
6087 );
6088 two_loads.validate().unwrap();
6089 let with_cleanup = entry(
6090 "0.1.0",
6091 vec![
6092 UpgradeInstruction::LoadModule { module: "x".into() },
6093 UpgradeInstruction::LoadModule { module: "y".into() },
6094 UpgradeInstruction::SoftPurge {
6095 module: "x-old".into(),
6096 },
6097 UpgradeInstruction::SoftPurge {
6098 module: "y-old".into(),
6099 },
6100 ],
6101 );
6102 with_cleanup.validate().unwrap();
6103 }
6104
6105 #[test]
6106 fn validate_accepts_single_load_per_module() {
6107 // Boundary control: a list with exactly one `:load-module`
6108 // followed by the canonical `:state-change` + `:soft-purge`
6109 // sequence (the module-doc OTP shape) is the gate's identity
6110 // element. Pin so a future off-by-one in the duplicate-
6111 // detection scan doesn't accidentally flag a single occurrence
6112 // as duplicating itself — mirrors
6113 // `validate_accepts_single_cleanup_per_module` on the sibling
6114 // singularity axis.
6115 let e = entry(
6116 "0.1.0",
6117 vec![
6118 UpgradeInstruction::LoadModule { module: "x".into() },
6119 UpgradeInstruction::StateChange {
6120 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6121 },
6122 UpgradeInstruction::SoftPurge {
6123 module: "x-old".into(),
6124 },
6125 ],
6126 );
6127 e.validate().unwrap();
6128 }
6129
6130 #[test]
6131 fn validate_load_singularity_fires_after_state_change_ordering() {
6132 // Diagnostic-precedence pin: an entry like `((:state-change
6133 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
6134 // state-change-without-load and duplicate-load. The more-
6135 // fundamental ordering gate must win — the missing-load defect
6136 // is load-bearing (the migration runs against unloaded code),
6137 // and surfacing the duplicate diagnostic first would mask the
6138 // migrate-into-unloaded-code defect the ordering gate exists
6139 // to close. Guards the call order in `validate` against silent
6140 // reordering. Same posture as
6141 // `validate_cleanup_singularity_fires_after_purge_ordering`
6142 // on the sibling singularity gate.
6143 let e = entry(
6144 "0.1.0",
6145 vec![
6146 UpgradeInstruction::StateChange {
6147 script: PathBuf::from("lib/m.lisp"),
6148 },
6149 UpgradeInstruction::LoadModule { module: "x".into() },
6150 UpgradeInstruction::LoadModule { module: "x".into() },
6151 ],
6152 );
6153 let err = e.validate().unwrap_err();
6154 assert!(
6155 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6156 "state-change-without-load must surface before duplicate-load, got {err:?}"
6157 );
6158 }
6159
6160 #[test]
6161 fn validate_load_singularity_fires_after_purge_ordering() {
6162 // Diagnostic-precedence pin: an entry like `((:soft-purge
6163 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
6164 // purge-without-load and duplicate-load. The more-fundamental
6165 // ordering gate must win — the missing-load defect is load-
6166 // bearing (the cleanup runs against no-replacement-window),
6167 // and surfacing the duplicate diagnostic first would mask the
6168 // drain-to-nothing defect the ordering gate exists to close.
6169 // Sibling of
6170 // `validate_cleanup_singularity_fires_after_purge_ordering` on
6171 // the load-singularity axis.
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::LoadModule { module: "x".into() },
6180 ],
6181 );
6182 let err = e.validate().unwrap_err();
6183 assert!(
6184 matches!(
6185 err,
6186 UpgradeError::PurgeWithoutPriorLoad {
6187 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6188 ..
6189 }
6190 ),
6191 "purge-without-load must surface before duplicate-load, got {err:?}"
6192 );
6193 }
6194
6195 #[test]
6196 fn validate_load_singularity_fires_after_per_instr_shape() {
6197 // Order pin: a malformed `:module` value on a `:load-module`
6198 // (an empty string) surfaces its narrower kind-tagged
6199 // `ModuleEmpty` diagnostic *before* the within-entry load-
6200 // singularity gate fires. The per-instruction shape pass walks
6201 // the list inline before the singularity check, so the
6202 // narrower self-locating diagnostic surfaces first — mirrors
6203 // the empty-first cascade on every peer DNS-1123 gate and the
6204 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6205 // pin on the sibling singularity gate.
6206 //
6207 // Two empty-string `:load-module` would *otherwise* duplicate
6208 // (both modules are the same empty string), so this pin
6209 // double-locks the precedence: the per-instr shape gate must
6210 // win on the first malformed instruction before the duplicate
6211 // scan even reaches the second.
6212 let e = entry(
6213 "0.1.0",
6214 vec![
6215 UpgradeInstruction::LoadModule {
6216 module: String::new(),
6217 },
6218 UpgradeInstruction::LoadModule {
6219 module: String::new(),
6220 },
6221 ],
6222 );
6223 let err = e.validate().unwrap_err();
6224 assert_eq!(
6225 err,
6226 UpgradeError::ModuleEmpty {
6227 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6228 },
6229 "malformed instruction must surface its kind-tagged diagnostic before the \
6230 load-singularity gate fires, got {err:?}"
6231 );
6232 }
6233
6234 #[test]
6235 fn validate_load_singularity_fires_before_cleanup_singularity() {
6236 // Diagnostic-precedence pin: an entry that violates *both*
6237 // singularities — duplicate load on "x" *and* duplicate cleanup
6238 // on "y-old" — must surface the load-side diagnostic first.
6239 // The load axis precedes the cleanup axis in the canonical OTP
6240 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6241 // in [`UpgradeInstruction`] declaration order (LoadModule
6242 // before SoftPurge/Purge), so the load-side singularity is the
6243 // load-bearing diagnostic when both fire — the cleanup-side
6244 // duplicate is meaningless either way without a coherent load.
6245 // Guards the call order in `validate`: `validate_load_singularity`
6246 // runs before `validate_cleanup_singularity`.
6247 let e = entry(
6248 "0.1.0",
6249 vec![
6250 UpgradeInstruction::LoadModule { module: "x".into() },
6251 UpgradeInstruction::LoadModule { module: "x".into() },
6252 UpgradeInstruction::SoftPurge {
6253 module: "y-old".into(),
6254 },
6255 UpgradeInstruction::SoftPurge {
6256 module: "y-old".into(),
6257 },
6258 ],
6259 );
6260 let err = e.validate().unwrap_err();
6261 assert_eq!(
6262 err,
6263 UpgradeError::DuplicateLoadModule {
6264 from: "0.1.0".into(),
6265 module: "x".into(),
6266 },
6267 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6268 );
6269 }
6270
6271 #[test]
6272 fn validate_load_singularity_reports_first_collision() {
6273 // Determinism pin: with three loads of the same module the gate
6274 // reports the *first* collision (the second occurrence) and
6275 // stops — the third's duplicate is masked by the first surfaced
6276 // one. Mirrors
6277 // `validate_cleanup_singularity_reports_first_collision` on the
6278 // sibling singularity axis and every peer duplicate gate's
6279 // first-collision discipline.
6280 let e = entry(
6281 "0.1.0",
6282 vec![
6283 UpgradeInstruction::LoadModule { module: "x".into() },
6284 UpgradeInstruction::LoadModule { module: "x".into() },
6285 UpgradeInstruction::LoadModule { module: "x".into() },
6286 ],
6287 );
6288 let err = e.validate().unwrap_err();
6289 assert_eq!(
6290 err,
6291 UpgradeError::DuplicateLoadModule {
6292 from: "0.1.0".into(),
6293 module: "x".into(),
6294 },
6295 "the first colliding occurrence must surface, not the later third-load collision"
6296 );
6297 }
6298
6299 #[test]
6300 fn validate_load_singularity_threads_through_validate_upgrade_from() {
6301 // The whole-list entry-point surfaces the per-entry singularity
6302 // error (mirrors
6303 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6304 // the gate is reachable from the LayoutInvariants call site,
6305 // not only from a direct `entry.validate()`.
6306 let entries = vec![entry(
6307 "0.1.0",
6308 vec![
6309 UpgradeInstruction::LoadModule { module: "x".into() },
6310 UpgradeInstruction::LoadModule { module: "x".into() },
6311 ],
6312 )];
6313 let err = validate_upgrade_from(&entries).unwrap_err();
6314 assert!(
6315 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6316 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6317 );
6318 }
6319
6320 // ── within-entry state-change-singularity invariant ────────────────
6321
6322 #[test]
6323 fn validate_rejects_duplicate_state_change_for_same_script() {
6324 // `StateChange` is the `gen_server:code_change/3` analog
6325 // (INSPIRATIONS §II.4): the script folds the prior-version
6326 // state shape into the current-version shape — a one-shot
6327 // transition, not a step that composes with itself. OTP's
6328 // release_handler invokes `code_change/3` exactly once per
6329 // upgrade per gen_server; systools-generated `.relup` files
6330 // emit at most one `code_change` per gen_server per upgrade
6331 // step for this reason. A second `(:state-change "m.lisp")`
6332 // re-runs the same fold on the already-migrated state — at
6333 // best a no-op and at worst silent state corruption from
6334 // double-applied non-idempotent transforms (`add column`,
6335 // `increment counter`, `rename field`). Author one
6336 // `(:state-change "m.lisp")` per migration script per entry.
6337 let e = entry(
6338 "0.1.0",
6339 vec![
6340 UpgradeInstruction::LoadModule { module: "x".into() },
6341 UpgradeInstruction::StateChange {
6342 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6343 },
6344 UpgradeInstruction::StateChange {
6345 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6346 },
6347 ],
6348 );
6349 let err = e.validate().unwrap_err();
6350 assert_eq!(
6351 err,
6352 UpgradeError::DuplicateStateChange {
6353 from: "0.1.0".into(),
6354 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6355 },
6356 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6357 the script, got {err:?}"
6358 );
6359 }
6360
6361 #[test]
6362 fn validate_accepts_distinct_state_change_scripts() {
6363 // Positive control: `:state-change` instructions on *different*
6364 // scripts pass the gate. Mirrors
6365 // `validate_accepts_distinct_cleanup_modules` /
6366 // `validate_accepts_distinct_load_modules` on the sibling
6367 // singularity axes — the state-change-singularity gate is keyed
6368 // on the script PathBuf, so distinct scripts render distinct
6369 // migration targets and don't collide. Sweep both the bare two-
6370 // migration shape and the canonical load-pair-with-cleanup shape
6371 // so a future tighten that over-fires on distinct scripts
6372 // surfaces here. This positive control is the gate-level peer of
6373 // `validate_accepts_multiple_state_changes_after_one_load` (the
6374 // ordering-gate positive control on distinct scripts), pinned
6375 // here independently so a future refactor that decouples the
6376 // gates can't accidentally drop coverage on either.
6377 let two_migrations = entry(
6378 "0.1.0",
6379 vec![
6380 UpgradeInstruction::LoadModule { module: "x".into() },
6381 UpgradeInstruction::StateChange {
6382 script: PathBuf::from("lib/m1.lisp"),
6383 },
6384 UpgradeInstruction::StateChange {
6385 script: PathBuf::from("lib/m2.lisp"),
6386 },
6387 ],
6388 );
6389 two_migrations.validate().unwrap();
6390 let with_cleanup = entry(
6391 "0.1.0",
6392 vec![
6393 UpgradeInstruction::LoadModule { module: "x".into() },
6394 UpgradeInstruction::StateChange {
6395 script: PathBuf::from("lib/m1.lisp"),
6396 },
6397 UpgradeInstruction::StateChange {
6398 script: PathBuf::from("lib/m2.lisp"),
6399 },
6400 UpgradeInstruction::SoftPurge {
6401 module: "x-old".into(),
6402 },
6403 ],
6404 );
6405 with_cleanup.validate().unwrap();
6406 }
6407
6408 #[test]
6409 fn validate_accepts_single_state_change_per_script() {
6410 // Boundary control: a list with exactly one `:state-change`
6411 // wrapped by the canonical `:load-module` + `:soft-purge`
6412 // sequence (the module-doc OTP shape) is the gate's identity
6413 // element. Pin so a future off-by-one in the duplicate-
6414 // detection scan doesn't accidentally flag a single occurrence
6415 // as duplicating itself — mirrors
6416 // `validate_accepts_single_load_per_module` /
6417 // `validate_accepts_single_cleanup_per_module` on the sibling
6418 // singularity axes.
6419 let e = entry(
6420 "0.1.0",
6421 vec![
6422 UpgradeInstruction::LoadModule { module: "x".into() },
6423 UpgradeInstruction::StateChange {
6424 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6425 },
6426 UpgradeInstruction::SoftPurge {
6427 module: "x-old".into(),
6428 },
6429 ],
6430 );
6431 e.validate().unwrap();
6432 }
6433
6434 #[test]
6435 fn validate_state_change_singularity_fires_after_state_change_ordering() {
6436 // Diagnostic-precedence pin: an entry like `((:state-change
6437 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6438 // without-load and duplicate-state-change. The more-fundamental
6439 // ordering gate must win — the missing-load defect is load-
6440 // bearing (the migration runs against unloaded code), and
6441 // surfacing the duplicate diagnostic first would mask the
6442 // migrate-into-unloaded-code defect the ordering gate exists to
6443 // close. Guards the call order in `validate` against silent
6444 // reordering. Same posture as
6445 // `validate_load_singularity_fires_after_state_change_ordering`
6446 // on the sibling singularity gate.
6447 //
6448 // Two same-script `:state-change` would *otherwise* duplicate
6449 // (both scripts collide on the very first `:state-change`-
6450 // without-load encountered), so this pin double-locks the
6451 // precedence: the ordering gate must win on the first un-loaded
6452 // `:state-change` before the singularity scan even reaches the
6453 // second.
6454 let e = entry(
6455 "0.1.0",
6456 vec![
6457 UpgradeInstruction::StateChange {
6458 script: PathBuf::from("lib/m.lisp"),
6459 },
6460 UpgradeInstruction::StateChange {
6461 script: PathBuf::from("lib/m.lisp"),
6462 },
6463 ],
6464 );
6465 let err = e.validate().unwrap_err();
6466 assert!(
6467 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6468 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6469 );
6470 }
6471
6472 #[test]
6473 fn validate_state_change_singularity_fires_after_purge_ordering() {
6474 // Diagnostic-precedence pin: an entry like `((:soft-purge
6475 // "x-old") (:load-module "x") (:state-change "m.lisp")
6476 // (:state-change "m.lisp"))` is *both* purge-without-load and
6477 // duplicate-state-change. The more-fundamental ordering gate
6478 // must win — the missing-load defect (a cleanup that drains the
6479 // only resident version to nothing) is load-bearing, and
6480 // surfacing the duplicate diagnostic first would mask the
6481 // drain-to-nothing defect the ordering gate exists to close.
6482 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6483 // on the state-change-singularity axis.
6484 let e = entry(
6485 "0.1.0",
6486 vec![
6487 UpgradeInstruction::SoftPurge {
6488 module: "x-old".into(),
6489 },
6490 UpgradeInstruction::LoadModule { module: "x".into() },
6491 UpgradeInstruction::StateChange {
6492 script: PathBuf::from("lib/m.lisp"),
6493 },
6494 UpgradeInstruction::StateChange {
6495 script: PathBuf::from("lib/m.lisp"),
6496 },
6497 ],
6498 );
6499 let err = e.validate().unwrap_err();
6500 assert!(
6501 matches!(
6502 err,
6503 UpgradeError::PurgeWithoutPriorLoad {
6504 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6505 ..
6506 }
6507 ),
6508 "purge-without-load must surface before duplicate-state-change, got {err:?}"
6509 );
6510 }
6511
6512 #[test]
6513 fn validate_state_change_singularity_fires_after_per_instr_shape() {
6514 // Order pin: a malformed `:script` value on a `:state-change`
6515 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6516 // *before* the within-entry state-change-singularity gate fires.
6517 // The per-instruction shape pass walks the list inline before
6518 // the singularity check, so the narrower self-locating
6519 // diagnostic surfaces first — mirrors the empty-first cascade on
6520 // every peer path-shape gate and the
6521 // `validate_load_singularity_fires_after_per_instr_shape` /
6522 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6523 // pins on the sibling singularity gates.
6524 //
6525 // Two empty-path `:state-change` would *otherwise* duplicate
6526 // (both scripts are the same empty PathBuf), so this pin double-
6527 // locks the precedence: the per-instr shape gate must win on the
6528 // first malformed instruction before the duplicate scan even
6529 // reaches the second.
6530 let e = entry(
6531 "0.1.0",
6532 vec![
6533 UpgradeInstruction::LoadModule { module: "x".into() },
6534 UpgradeInstruction::StateChange {
6535 script: PathBuf::new(),
6536 },
6537 UpgradeInstruction::StateChange {
6538 script: PathBuf::new(),
6539 },
6540 ],
6541 );
6542 let err = e.validate().unwrap_err();
6543 assert_eq!(
6544 err,
6545 UpgradeError::EmptyScript,
6546 "malformed instruction must surface its narrower diagnostic before the \
6547 state-change-singularity gate fires, got {err:?}"
6548 );
6549 }
6550
6551 #[test]
6552 fn validate_state_change_singularity_fires_after_load_singularity() {
6553 // Diagnostic-precedence pin: an entry that violates *both*
6554 // singularities — duplicate load on "x" *and* duplicate
6555 // state-change on "m.lisp" — must surface the load-side
6556 // diagnostic first. The load axis precedes the migration axis
6557 // in the canonical OTP sequence (`code:load_module/1` then
6558 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6559 // declaration order (LoadModule before StateChange), so the
6560 // load-side singularity is the load-bearing diagnostic when
6561 // both fire — the migration-side duplicate is meaningless
6562 // either way without a coherent load. Guards the call order in
6563 // `validate`: `validate_load_singularity` runs before
6564 // `validate_state_change_singularity`.
6565 let e = entry(
6566 "0.1.0",
6567 vec![
6568 UpgradeInstruction::LoadModule { module: "x".into() },
6569 UpgradeInstruction::LoadModule { module: "x".into() },
6570 UpgradeInstruction::StateChange {
6571 script: PathBuf::from("lib/m.lisp"),
6572 },
6573 UpgradeInstruction::StateChange {
6574 script: PathBuf::from("lib/m.lisp"),
6575 },
6576 ],
6577 );
6578 let err = e.validate().unwrap_err();
6579 assert_eq!(
6580 err,
6581 UpgradeError::DuplicateLoadModule {
6582 from: "0.1.0".into(),
6583 module: "x".into(),
6584 },
6585 "duplicate-load must surface before duplicate-state-change, got {err:?}"
6586 );
6587 }
6588
6589 #[test]
6590 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6591 // Diagnostic-precedence pin: an entry that violates *both*
6592 // singularities — duplicate state-change on "m.lisp" *and*
6593 // duplicate cleanup on "y-old" — must surface the migration-
6594 // side diagnostic first. The migration axis precedes the
6595 // cleanup axis in the canonical OTP sequence
6596 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6597 // [`UpgradeInstruction`] declaration order (StateChange before
6598 // SoftPurge/Purge), so the migration-side singularity is the
6599 // load-bearing diagnostic when both fire — the cleanup-side
6600 // duplicate is irrelevant once the migration has corrupted
6601 // state by double-applying. Guards the call order in
6602 // `validate`: `validate_state_change_singularity` runs before
6603 // `validate_cleanup_singularity`.
6604 let e = entry(
6605 "0.1.0",
6606 vec![
6607 UpgradeInstruction::LoadModule { module: "x".into() },
6608 UpgradeInstruction::StateChange {
6609 script: PathBuf::from("lib/m.lisp"),
6610 },
6611 UpgradeInstruction::StateChange {
6612 script: PathBuf::from("lib/m.lisp"),
6613 },
6614 UpgradeInstruction::SoftPurge {
6615 module: "y-old".into(),
6616 },
6617 UpgradeInstruction::SoftPurge {
6618 module: "y-old".into(),
6619 },
6620 ],
6621 );
6622 let err = e.validate().unwrap_err();
6623 assert_eq!(
6624 err,
6625 UpgradeError::DuplicateStateChange {
6626 from: "0.1.0".into(),
6627 script: PathBuf::from("lib/m.lisp"),
6628 },
6629 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6630 );
6631 }
6632
6633 #[test]
6634 fn validate_state_change_singularity_reports_first_collision() {
6635 // Determinism pin: with three state-changes on the same script
6636 // the gate reports the *first* collision (the second
6637 // occurrence) and stops — the third's duplicate is masked by
6638 // the first surfaced one. Mirrors
6639 // `validate_load_singularity_reports_first_collision` /
6640 // `validate_cleanup_singularity_reports_first_collision` on the
6641 // sibling singularity axes and every peer duplicate gate's
6642 // first-collision discipline.
6643 let e = entry(
6644 "0.1.0",
6645 vec![
6646 UpgradeInstruction::LoadModule { module: "x".into() },
6647 UpgradeInstruction::StateChange {
6648 script: PathBuf::from("lib/m.lisp"),
6649 },
6650 UpgradeInstruction::StateChange {
6651 script: PathBuf::from("lib/m.lisp"),
6652 },
6653 UpgradeInstruction::StateChange {
6654 script: PathBuf::from("lib/m.lisp"),
6655 },
6656 ],
6657 );
6658 let err = e.validate().unwrap_err();
6659 assert_eq!(
6660 err,
6661 UpgradeError::DuplicateStateChange {
6662 from: "0.1.0".into(),
6663 script: PathBuf::from("lib/m.lisp"),
6664 },
6665 "the first colliding occurrence must surface, not the later third-migration collision"
6666 );
6667 }
6668
6669 #[test]
6670 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6671 // The whole-list entry-point surfaces the per-entry singularity
6672 // error (mirrors
6673 // `validate_load_singularity_threads_through_validate_upgrade_from`
6674 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6675 // the gate is reachable from the LayoutInvariants call site,
6676 // not only from a direct `entry.validate()`.
6677 let entries = vec![entry(
6678 "0.1.0",
6679 vec![
6680 UpgradeInstruction::LoadModule { module: "x".into() },
6681 UpgradeInstruction::StateChange {
6682 script: PathBuf::from("lib/m.lisp"),
6683 },
6684 UpgradeInstruction::StateChange {
6685 script: PathBuf::from("lib/m.lisp"),
6686 },
6687 ],
6688 )];
6689 let err = validate_upgrade_from(&entries).unwrap_err();
6690 assert!(
6691 matches!(err, UpgradeError::DuplicateStateChange { .. }),
6692 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6693 );
6694 }
6695
6696 #[test]
6697 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6698 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6699 // per-instruction `StateChange`-arm script-path projection must
6700 // route through the sibling lifted
6701 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6702 // accessor, not the raw
6703 // `match instr { UpgradeInstruction::StateChange { script } =>
6704 // script.as_path(), _ => continue }` open-coded pattern-match
6705 // the gate previously carried.
6706 //
6707 // Structurally: the gate's projection accept-set is the union
6708 // of every [`UpgradeInstruction`] variant for which
6709 // `declared_path().is_some()` — today exactly
6710 // [`UpgradeInstruction::StateChange`] per the sibling
6711 // `declared_path_only_for_state_change` pin, so a
6712 // duplicate-scripts input trips `DuplicateStateChange` and a
6713 // non-`StateChange` input (module-bearing / terminal) leaves
6714 // `seen` empty and the gate returns `Ok(())` byte-identical to
6715 // the pattern-match shape.
6716 //
6717 // Byte-equal today (`declared_path` returns `Some(script)` iff
6718 // `StateChange`, byte-for-byte from the variant's own storage);
6719 // the pin catches any future accessor extension that promotes
6720 // an additional variant onto the `PathBuf`-carrying axis — the
6721 // gate then fires on duplicate scripts from that variant too,
6722 // and the singularity discipline the sibling
6723 // `validate_load_singularity` / `validate_cleanup_singularity`
6724 // gates share on the `String`-carrying axis's per-variant
6725 // consumers extends to the promoted variant by construction.
6726 //
6727 // Peer of the sibling four per-`UpgradeInstruction` consumers
6728 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6729 // sandbox-path fan-out, the layout-side per-`StateChange`
6730 // script-existence fan-out at
6731 // `caixa-core/src/layout.rs:1017`, the cross-slot
6732 // [`validate_upgrade_from_against_behavior`] gate's per-
6733 // `StateChange` detection loop, the peer
6734 // [`UpgradeInstruction::declared_module`] `String`-axis
6735 // per-variant unifier) — this gate now shares one typed
6736 // dispatch on the substrate primitive's `PathBuf`-carrying
6737 // axis with those consumers, so a future rebrand on the axis
6738 // migrates as a single caixa-core edit rather than a
6739 // coordinated rewrite of five call sites.
6740 //
6741 // Three-arm projective coverage:
6742 // (a) `StateChange` scripts project through `declared_path()`
6743 // byte-equal to the raw `script.as_path()` field access;
6744 // (b) a duplicate-`StateChange` input trips the gate on the
6745 // second occurrence with `DuplicateStateChange` carrying
6746 // the offending script verbatim;
6747 // (c) a non-`StateChange`-only input (`LoadModule` /
6748 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6749 // vacuous with `Ok(())` — the `declared_path().is_none()`
6750 // arm's `continue` fall-through pins.
6751 //
6752 // Fail-before-pass-after verified locally: swapping the
6753 // production `let Some(script) = instr.declared_path() else {
6754 // continue };` back to `let script = match instr {
6755 // UpgradeInstruction::StateChange { script } =>
6756 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6757 // passing but silently detaches the gate from the accessor's
6758 // typed dispatch — any future `declared_path` extension
6759 // (promotion of an additional variant onto the axis, an
6760 // operator-side pre-resolved-path cache the accessor
6761 // materializes) would then silently disagree between this
6762 // gate's raw pattern-match and the peer four sibling consumers
6763 // that route through the accessor.
6764 use std::path::PathBuf;
6765
6766 // (a) StateChange projection byte-equal via declared_path.
6767 let sc = UpgradeInstruction::StateChange {
6768 script: PathBuf::from("lib/m.lisp"),
6769 };
6770 assert_eq!(
6771 sc.declared_path().map(std::path::PathBuf::as_path),
6772 Some(PathBuf::from("lib/m.lisp").as_path()),
6773 "declared_path() must project the StateChange :script byte-equal to the raw \
6774 field access — accessor divergence would silently detach the gate from the \
6775 projection every peer per-`UpgradeInstruction` consumer routes through"
6776 );
6777
6778 // (b) Duplicate-StateChange input trips the gate.
6779 let dup = entry(
6780 "0.1.0",
6781 vec![
6782 UpgradeInstruction::LoadModule { module: "x".into() },
6783 UpgradeInstruction::StateChange {
6784 script: PathBuf::from("lib/m.lisp"),
6785 },
6786 UpgradeInstruction::StateChange {
6787 script: PathBuf::from("lib/m.lisp"),
6788 },
6789 ],
6790 );
6791 assert_eq!(
6792 dup.validate_state_change_singularity(),
6793 Err(UpgradeError::DuplicateStateChange {
6794 from: "0.1.0".into(),
6795 script: PathBuf::from("lib/m.lisp"),
6796 }),
6797 "duplicate StateChange scripts must trip the gate on the second occurrence \
6798 through the declared_path accessor's Some(script) arm"
6799 );
6800
6801 // (c) Non-StateChange-only inputs leave the gate vacuous.
6802 for instrs in [
6803 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6804 vec![
6805 UpgradeInstruction::LoadModule { module: "x".into() },
6806 UpgradeInstruction::SoftPurge {
6807 module: "x-old".into(),
6808 },
6809 ],
6810 vec![
6811 UpgradeInstruction::LoadModule { module: "x".into() },
6812 UpgradeInstruction::Purge {
6813 module: "x-old".into(),
6814 },
6815 ],
6816 vec![UpgradeInstruction::Restart],
6817 ] {
6818 for instr in &instrs {
6819 assert!(
6820 instr.declared_path().is_none(),
6821 "non-StateChange variants must project None through declared_path — \
6822 accessor divergence would let this gate silently fire on a duplicate \
6823 module reference far from any :state-change site"
6824 );
6825 }
6826 let e = entry("0.1.0", instrs);
6827 assert_eq!(
6828 e.validate_state_change_singularity(),
6829 Ok(()),
6830 "the state-change-singularity gate must return Ok(()) on an entry whose \
6831 instructions all project None through declared_path — the accessor's \
6832 continue arm the pattern-match's `_ => continue` previously carried"
6833 );
6834 }
6835 }
6836
6837 // ── within-entry state-change-before-cleanup ordering invariant ──
6838
6839 #[test]
6840 fn validate_rejects_state_change_after_soft_purge() {
6841 // Fail-before-pass-after pin: `:state-change` is the
6842 // gen_server:code_change/3 analog and folds the prior-version
6843 // state shape into the current shape; `:soft-purge` drains the
6844 // prior code. The operator runs instructions in declared order,
6845 // so a `:soft-purge` ahead of a `:state-change` drains the
6846 // prior module before the migration callback runs against the
6847 // state it held — the canonical OTP error mode
6848 // "`code_change/3` invoked on a purged module" the
6849 // release_handler closes by always ordering the migration
6850 // before the cleanup.
6851 let e = entry(
6852 "0.1.0",
6853 vec![
6854 UpgradeInstruction::LoadModule { module: "x".into() },
6855 UpgradeInstruction::SoftPurge {
6856 module: "x-old".into(),
6857 },
6858 UpgradeInstruction::StateChange {
6859 script: PathBuf::from("lib/m.lisp"),
6860 },
6861 ],
6862 );
6863 let err = e.validate().unwrap_err();
6864 assert_eq!(
6865 err,
6866 UpgradeError::StateChangeAfterCleanup {
6867 from: "0.1.0".into(),
6868 script: PathBuf::from("lib/m.lisp"),
6869 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6870 prior_cleanup_module: "x-old".into(),
6871 },
6872 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
6873 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
6874 );
6875 }
6876
6877 #[test]
6878 fn validate_rejects_state_change_after_purge() {
6879 // Per-arm coverage: `:purge` (immediate discard, no drain) is
6880 // the more catastrophic peer of `:soft-purge` on the cleanup
6881 // axis; same gate, same shape, the `prior_cleanup_kind` field
6882 // distinguishes the diagnostic so the author can grep their
6883 // caixa.lisp for the offending `(:purge …)` form.
6884 let e = entry(
6885 "0.1.0",
6886 vec![
6887 UpgradeInstruction::LoadModule { module: "x".into() },
6888 UpgradeInstruction::Purge {
6889 module: "x-old".into(),
6890 },
6891 UpgradeInstruction::StateChange {
6892 script: PathBuf::from("lib/m.lisp"),
6893 },
6894 ],
6895 );
6896 let err = e.validate().unwrap_err();
6897 assert_eq!(
6898 err,
6899 UpgradeError::StateChangeAfterCleanup {
6900 from: "0.1.0".into(),
6901 script: PathBuf::from("lib/m.lisp"),
6902 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6903 prior_cleanup_module: "x-old".into(),
6904 },
6905 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
6906 `prior_cleanup_kind: \":purge\"`, got {err:?}"
6907 );
6908 }
6909
6910 #[test]
6911 fn validate_accepts_state_change_before_cleanup() {
6912 // Positive control: the canonical `(:load-module …)
6913 // (:state-change …) (:soft-purge …)` order validates — the
6914 // exact shape the module doc example and `validate_accepts_
6915 // well_formed` already pin, restated here on the new gate's
6916 // identity element so a future shortcut that runs the
6917 // singularity gates first doesn't silently mask a regression
6918 // here.
6919 let e = entry(
6920 "0.1.0",
6921 vec![
6922 UpgradeInstruction::LoadModule { module: "x".into() },
6923 UpgradeInstruction::StateChange {
6924 script: PathBuf::from("lib/m.lisp"),
6925 },
6926 UpgradeInstruction::SoftPurge {
6927 module: "x-old".into(),
6928 },
6929 ],
6930 );
6931 e.validate().unwrap();
6932 }
6933
6934 #[test]
6935 fn validate_accepts_cleanup_without_state_change() {
6936 // Empty-set identity: an entry that carries no `:state-change`
6937 // at all has nothing to order against the cleanup, so the gate
6938 // passes regardless of how the cleanups are placed (after the
6939 // single required `:load-module`). Mirrors the
6940 // `validate_accepts_multiple_purges_after_one_load` positive
6941 // control on the peer purge-ordering gate; metadata-only
6942 // upgrades with cleanup-but-no-migration land here.
6943 let e = entry(
6944 "0.1.0",
6945 vec![
6946 UpgradeInstruction::LoadModule { module: "x".into() },
6947 UpgradeInstruction::SoftPurge {
6948 module: "x-old".into(),
6949 },
6950 UpgradeInstruction::Purge {
6951 module: "x-oldest".into(),
6952 },
6953 ],
6954 );
6955 e.validate().unwrap();
6956 }
6957
6958 #[test]
6959 fn validate_accepts_state_change_without_cleanup() {
6960 // Empty-set identity on the dual axis: an entry that carries no
6961 // cleanup at all has nothing to order against the state-change,
6962 // so the gate passes — additive-upgrade shapes (load new code,
6963 // migrate state, leave old code resident for in-flight callers
6964 // to drain naturally) land here.
6965 let e = entry(
6966 "0.1.0",
6967 vec![
6968 UpgradeInstruction::LoadModule { module: "x".into() },
6969 UpgradeInstruction::StateChange {
6970 script: PathBuf::from("lib/m.lisp"),
6971 },
6972 ],
6973 );
6974 e.validate().unwrap();
6975 }
6976
6977 #[test]
6978 fn validate_accepts_multiple_state_changes_before_cleanup() {
6979 // Coverage: every state-change must precede every cleanup, not
6980 // just the first. A chain `(load) (sc) (sc) (sp)` is the
6981 // canonical "two distinct migration scripts on a chained
6982 // upgrade" shape (one module's schema *and* another's
6983 // projection per the DuplicateStateChange diagnostic), and
6984 // it must pass when each state-change has distinct script
6985 // paths. Pinned here so a future shortcut that only checks
6986 // the first state-change doesn't silently accept a
6987 // `(load) (sc-1) (sp) (sc-2)` regression.
6988 let e = entry(
6989 "0.1.0",
6990 vec![
6991 UpgradeInstruction::LoadModule { module: "x".into() },
6992 UpgradeInstruction::StateChange {
6993 script: PathBuf::from("lib/m1.lisp"),
6994 },
6995 UpgradeInstruction::StateChange {
6996 script: PathBuf::from("lib/m2.lisp"),
6997 },
6998 UpgradeInstruction::SoftPurge {
6999 module: "x-old".into(),
7000 },
7001 ],
7002 );
7003 e.validate().unwrap();
7004 }
7005
7006 #[test]
7007 fn validate_rejects_state_change_sandwiched_between_cleanups() {
7008 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
7009 // (sp-2)` violates the gate because the state-change runs
7010 // after the first cleanup. The reported `prior_cleanup_*`
7011 // names the *first* cleanup (the load-bearing one), not the
7012 // last — mirrors every peer first-collision diagnostic
7013 // posture on this module (`validate_state_change_ordering`,
7014 // `validate_purge_ordering`, `validate_load_singularity`,
7015 // `validate_state_change_singularity`,
7016 // `validate_cleanup_singularity` all report the first
7017 // colliding instruction, not the last).
7018 let e = entry(
7019 "0.1.0",
7020 vec![
7021 UpgradeInstruction::LoadModule { module: "x".into() },
7022 UpgradeInstruction::SoftPurge {
7023 module: "x-old".into(),
7024 },
7025 UpgradeInstruction::StateChange {
7026 script: PathBuf::from("lib/m.lisp"),
7027 },
7028 UpgradeInstruction::Purge {
7029 module: "y-old".into(),
7030 },
7031 ],
7032 );
7033 let err = e.validate().unwrap_err();
7034 assert_eq!(
7035 err,
7036 UpgradeError::StateChangeAfterCleanup {
7037 from: "0.1.0".into(),
7038 script: PathBuf::from("lib/m.lisp"),
7039 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7040 prior_cleanup_module: "x-old".into(),
7041 },
7042 "the first cleanup the state-change follows must surface (not the trailing one), \
7043 got {err:?}"
7044 );
7045 }
7046
7047 #[test]
7048 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
7049 // Diagnostic-precedence pin: an entry like `((:soft-purge
7050 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
7051 // *both* purge-without-load (the cleanup runs before the
7052 // load) and state-change-after-cleanup (the state-change
7053 // runs after the cleanup). The more-fundamental ordering
7054 // gate must win — the missing-load defect (a cleanup that
7055 // drains the only resident version to nothing) is load-
7056 // bearing, and surfacing the state-change-after-cleanup
7057 // diagnostic first would mask the drain-to-nothing defect
7058 // the peer purge-ordering gate exists to close. Guards the
7059 // call order in `validate` against silent reordering. Same
7060 // posture as `validate_purge_ordering_fires_after_state_
7061 // change_ordering` on the sibling ordering gate.
7062 //
7063 // Pin specifically uses the load-after-cleanup shape (rather
7064 // than load-less) so the state-change-ordering gate (which
7065 // would otherwise fire first on a `((:soft-purge …)
7066 // (:state-change …))` shape with no leading load) is
7067 // sidestepped: with the load present after the cleanup,
7068 // state-change-ordering passes (its `loaded` latch is set
7069 // before the state-change is encountered) but purge-ordering
7070 // still fails (the cleanup precedes the load). That isolates
7071 // the precedence between purge-ordering and this gate
7072 // cleanly.
7073 let e = entry(
7074 "0.1.0",
7075 vec![
7076 UpgradeInstruction::SoftPurge {
7077 module: "x-old".into(),
7078 },
7079 UpgradeInstruction::LoadModule { module: "x".into() },
7080 UpgradeInstruction::StateChange {
7081 script: PathBuf::from("lib/m.lisp"),
7082 },
7083 ],
7084 );
7085 let err = e.validate().unwrap_err();
7086 assert!(
7087 matches!(
7088 err,
7089 UpgradeError::PurgeWithoutPriorLoad {
7090 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7091 ..
7092 }
7093 ),
7094 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
7095 );
7096 }
7097
7098 #[test]
7099 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
7100 // Diagnostic-precedence pin: an entry like `((:state-change
7101 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
7102 // load (because no `:load-module` precedes the state-change)
7103 // but *not* state-change-after-cleanup (the state-change
7104 // precedes the cleanup textually). The state-change-ordering
7105 // gate must surface first regardless — the missing-load
7106 // defect on the migration axis is the load-bearing semantic
7107 // and surfacing a different ordering diagnostic would mask
7108 // the migration-against-stale-code defect. Guards the call
7109 // order in `validate` against silent reordering on a shape
7110 // that fires only the state-change-ordering gate (not this
7111 // one), pinning that the state-change-ordering gate wins
7112 // ahead of this gate's chance to look at the list.
7113 let e = entry(
7114 "0.1.0",
7115 vec![
7116 UpgradeInstruction::StateChange {
7117 script: PathBuf::from("lib/m.lisp"),
7118 },
7119 UpgradeInstruction::SoftPurge {
7120 module: "x-old".into(),
7121 },
7122 ],
7123 );
7124 let err = e.validate().unwrap_err();
7125 assert!(
7126 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
7127 "state-change-without-load must surface before purge-without-load (the canonical \
7128 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
7129 );
7130 }
7131
7132 #[test]
7133 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
7134 // Order pin: a malformed `:script` value on a `:state-change`
7135 // (an empty path) surfaces its narrower `EmptyScript`
7136 // diagnostic *before* the within-entry state-change-before-
7137 // cleanup gate fires. The per-instruction shape pass walks
7138 // the list inline before the ordering check, so the narrower
7139 // self-locating diagnostic surfaces first — mirrors the
7140 // empty-first cascade on every peer path-shape gate and the
7141 // `validate_purge_ordering_fires_after_per_instr_shape` pin
7142 // on the sibling ordering gate.
7143 let e = entry(
7144 "0.1.0",
7145 vec![
7146 UpgradeInstruction::LoadModule { module: "x".into() },
7147 UpgradeInstruction::SoftPurge {
7148 module: "x-old".into(),
7149 },
7150 UpgradeInstruction::StateChange {
7151 script: PathBuf::new(),
7152 },
7153 ],
7154 );
7155 let err = e.validate().unwrap_err();
7156 assert_eq!(
7157 err,
7158 UpgradeError::EmptyScript,
7159 "malformed instruction must surface its narrower diagnostic before the \
7160 state-change-before-cleanup gate fires, got {err:?}"
7161 );
7162 }
7163
7164 #[test]
7165 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
7166 // Diagnostic-precedence pin: an entry like `((:load-module
7167 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
7168 // (:state-change "m.lisp"))` violates *both* this ordering
7169 // gate (the first state-change follows the cleanup) and the
7170 // state-change-singularity gate (the same script appears
7171 // twice). The ordering gate must win — the canonical
7172 // "ordering before singularity" precedence the peer
7173 // `validate_state_change_ordering` / `validate_purge_
7174 // ordering` gates already establish over their own singularity
7175 // gates, applied uniformly across the OTP canonical-sequence
7176 // ordering axis here. Guards the call order in `validate`:
7177 // `validate_state_change_before_cleanup` runs before the
7178 // per-instruction-class singularity gates.
7179 let e = entry(
7180 "0.1.0",
7181 vec![
7182 UpgradeInstruction::LoadModule { module: "x".into() },
7183 UpgradeInstruction::SoftPurge {
7184 module: "x-old".into(),
7185 },
7186 UpgradeInstruction::StateChange {
7187 script: PathBuf::from("lib/m.lisp"),
7188 },
7189 UpgradeInstruction::StateChange {
7190 script: PathBuf::from("lib/m.lisp"),
7191 },
7192 ],
7193 );
7194 let err = e.validate().unwrap_err();
7195 assert!(
7196 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7197 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7198 );
7199 }
7200
7201 #[test]
7202 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7203 // The whole-list entry-point surfaces the per-entry ordering
7204 // error (mirrors `validate_purge_ordering_threads_through_
7205 // validate_upgrade_from` and every peer wiring pin): the gate
7206 // is reachable from the LayoutInvariants call site, not only
7207 // from a direct `entry.validate()`.
7208 let entries = vec![entry(
7209 "0.1.0",
7210 vec![
7211 UpgradeInstruction::LoadModule { module: "x".into() },
7212 UpgradeInstruction::SoftPurge {
7213 module: "x-old".into(),
7214 },
7215 UpgradeInstruction::StateChange {
7216 script: PathBuf::from("lib/m.lisp"),
7217 },
7218 ],
7219 )];
7220 let err = validate_upgrade_from(&entries).unwrap_err();
7221 assert!(
7222 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7223 "validate_upgrade_from must thread the state-change-before-cleanup error, \
7224 got {err:?}"
7225 );
7226 }
7227
7228 #[test]
7229 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7230 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7231 // per-instruction `StateChange`-arm script-path projection must
7232 // route through the sibling lifted
7233 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7234 // accessor, not the raw
7235 // `if let UpgradeInstruction::StateChange { script } = instr`
7236 // open-coded pattern-match the gate previously carried inside
7237 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7238 //
7239 // Structurally: the gate's projection accept-set is the union
7240 // of every [`UpgradeInstruction`] variant for which
7241 // `declared_path().is_some()` — today exactly
7242 // [`UpgradeInstruction::StateChange`] per the sibling
7243 // `declared_path_only_for_state_change` pin, so a
7244 // state-change-after-cleanup input trips
7245 // `StateChangeAfterCleanup` and a non-`StateChange` input
7246 // (module-bearing / terminal) leaves the sticky-once latch
7247 // sweep quiet byte-identical to the pattern-match shape.
7248 //
7249 // Byte-equal today (`declared_path` returns `Some(script)` iff
7250 // `StateChange`, byte-for-byte from the variant's own storage);
7251 // the pin catches any future accessor extension that promotes
7252 // an additional variant onto the `PathBuf`-carrying axis — the
7253 // gate then fires on migrate-after-cleanup for that variant too,
7254 // and the migrate→cleanup ordering discipline the peer
7255 // [`validate_state_change_singularity`] /
7256 // [`validate_upgrade_from_against_behavior`] gates share on the
7257 // same axis extends to the promoted variant by construction.
7258 //
7259 // Peer of the sibling four per-`UpgradeInstruction` consumers
7260 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7261 // sandbox-path fan-out, the layout-side per-`StateChange`
7262 // script-existence fan-out at
7263 // `caixa-core/src/layout.rs:1058`, the within-entry
7264 // [`UpgradeFromEntry::validate_state_change_singularity`]
7265 // per-`StateChange` script-projection fan-out, the cross-slot
7266 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7267 // detection loop) — the fifth (and last unlifted inside
7268 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7269 // the `PathBuf`-carrying axis to now route through the accessor.
7270 // Same shape as the sibling
7271 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7272 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7273 // pins extended onto the within-entry migrate→cleanup ordering
7274 // gate.
7275 //
7276 // Three-arm projective coverage:
7277 // (a) `StateChange` scripts project through `declared_path()`
7278 // byte-equal to the raw `script.clone()` field access
7279 // the diagnostic previously carried;
7280 // (b) a `:state-change`-after-cleanup input trips the gate
7281 // with `StateChangeAfterCleanup` carrying the offending
7282 // script + the prior cleanup's kind/module verbatim;
7283 // (c) a non-`StateChange`-only input (`LoadModule` /
7284 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7285 // vacuous with `Ok(())` — the `declared_path().is_none()`
7286 // arm's fall-through pins.
7287 //
7288 // Fail-before-pass-after verified structurally: swapping the
7289 // production
7290 // `else if let Some(script) = instr.declared_path() && … { … }`
7291 // back to
7292 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7293 // keeps arms (a)-(c) passing but silently detaches this within-
7294 // entry ordering gate from the accessor's typed dispatch — any
7295 // future `declared_path` extension (promotion of an additional
7296 // variant onto the axis, an operator-side pre-resolved-path
7297 // cache the accessor materializes) would then silently disagree
7298 // between this gate's raw pattern-match and the peer four
7299 // sibling consumers that route through the accessor.
7300
7301 // (a) StateChange projection byte-equal via declared_path.
7302 let sc = UpgradeInstruction::StateChange {
7303 script: PathBuf::from("lib/m.lisp"),
7304 };
7305 assert_eq!(
7306 sc.declared_path().cloned(),
7307 Some(PathBuf::from("lib/m.lisp")),
7308 "declared_path() must project the StateChange :script byte-equal to the raw \
7309 field access — accessor divergence would silently detach this within-entry \
7310 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7311 consumer routes through"
7312 );
7313
7314 // (b) StateChange-after-cleanup trips the gate through the accessor.
7315 let after = entry(
7316 "0.1.0",
7317 vec![
7318 UpgradeInstruction::LoadModule { module: "x".into() },
7319 UpgradeInstruction::SoftPurge {
7320 module: "x-old".into(),
7321 },
7322 UpgradeInstruction::StateChange {
7323 script: PathBuf::from("lib/m.lisp"),
7324 },
7325 ],
7326 );
7327 assert_eq!(
7328 after.validate(),
7329 Err(UpgradeError::StateChangeAfterCleanup {
7330 from: "0.1.0".into(),
7331 script: PathBuf::from("lib/m.lisp"),
7332 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7333 prior_cleanup_module: "x-old".into(),
7334 }),
7335 "a :state-change following a cleanup must trip the gate through the declared_path \
7336 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7337 kind/module verbatim byte-identical to the pattern-match shape"
7338 );
7339
7340 // (c) Non-StateChange-only inputs leave the gate vacuous.
7341 for instrs in [
7342 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7343 vec![
7344 UpgradeInstruction::LoadModule { module: "x".into() },
7345 UpgradeInstruction::SoftPurge {
7346 module: "x-old".into(),
7347 },
7348 ],
7349 vec![
7350 UpgradeInstruction::LoadModule { module: "x".into() },
7351 UpgradeInstruction::Purge {
7352 module: "x-old".into(),
7353 },
7354 ],
7355 vec![UpgradeInstruction::Restart],
7356 ] {
7357 for instr in &instrs {
7358 assert!(
7359 instr.declared_path().is_none(),
7360 "non-StateChange variants must project None through declared_path — \
7361 accessor divergence would let this within-entry ordering gate silently \
7362 fire on a cleanup-only sequence far from any :state-change site"
7363 );
7364 }
7365 let e = entry("0.1.0", instrs);
7366 assert_eq!(
7367 e.validate(),
7368 Ok(()),
7369 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7370 instructions all project None through declared_path — the accessor's \
7371 None arm the pattern-match's implicit fall-through previously carried"
7372 );
7373 }
7374 }
7375
7376 #[test]
7377 fn validate_restart_order_independent() {
7378 // Position-agnostic: `(:restart)` leading or trailing the
7379 // mixed sequence surfaces the same RestartNotExclusive shape.
7380 // Mirrors OTP appup's order-insensitive
7381 // `restart_emulator | restart_new_emulator` terminal rule —
7382 // the position of the restart instruction in the script is
7383 // irrelevant; what matters is the script *contains* it
7384 // alongside other instructions at all. The gate must not
7385 // gain a false positive by depending on instruction ordering.
7386 let leading = entry(
7387 "0.1.0",
7388 vec![
7389 UpgradeInstruction::Restart,
7390 UpgradeInstruction::LoadModule { module: "x".into() },
7391 ],
7392 );
7393 let trailing = entry(
7394 "0.1.0",
7395 vec![
7396 UpgradeInstruction::LoadModule { module: "x".into() },
7397 UpgradeInstruction::Restart,
7398 ],
7399 );
7400 let middle = entry(
7401 "0.1.0",
7402 vec![
7403 UpgradeInstruction::LoadModule { module: "a".into() },
7404 UpgradeInstruction::Restart,
7405 UpgradeInstruction::SoftPurge {
7406 module: "a-old".into(),
7407 },
7408 ],
7409 );
7410 for e in [&leading, &trailing, &middle] {
7411 assert!(
7412 matches!(
7413 e.validate().unwrap_err(),
7414 UpgradeError::RestartNotExclusive {
7415 restart_count: 1,
7416 ..
7417 }
7418 ),
7419 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7420 instruction order, got {:?}",
7421 e.validate()
7422 );
7423 }
7424 }
7425
7426 #[test]
7427 fn validate_restart_exclusive_fires_after_per_instr_shape() {
7428 // Order pin: a malformed `:module` value on a Module-bearing
7429 // instruction (an empty string) surfaces its narrower
7430 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7431 // entry restart-exclusivity gate fires. The per-instruction
7432 // shape pass walks the list inline before the restart-
7433 // exclusive check, so the narrower self-locating diagnostic
7434 // surfaces first — mirrors the empty-first cascade on every
7435 // peer DNS-1123 gate (`validate_module`,
7436 // `validate_membro_caixa`, `validate_placement_cluster`) and
7437 // the `*_invalid_fires_before_duplicate_check` arm-ordering
7438 // pins on every typed-graph axis. Without this pin a future
7439 // shortcut that runs the restart-exclusive check ahead of
7440 // per-instruction shape would surface a less-actionable
7441 // RestartNotExclusive over an instruction list that's also
7442 // malformed at the per-instruction layer.
7443 let e = entry(
7444 "0.1.0",
7445 vec![
7446 UpgradeInstruction::LoadModule {
7447 module: String::new(),
7448 },
7449 UpgradeInstruction::Restart,
7450 ],
7451 );
7452 let err = e.validate().unwrap_err();
7453 assert_eq!(
7454 err,
7455 UpgradeError::ModuleEmpty {
7456 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7457 },
7458 "malformed instruction must surface its kind-tagged diagnostic before the \
7459 restart-exclusivity gate fires, got {err:?}"
7460 );
7461 }
7462
7463 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7464 // Helper for the cross-slot composition gate's pass arm: a
7465 // BehaviorSpec carrying just the `:on-state-change` callback,
7466 // the runtime hook the per-version `(:state-change "…")`
7467 // instruction is delivered through during hot upgrade. Mirrors
7468 // the canonical authoring shape pinned in the module doc.
7469 crate::BehaviorSpec {
7470 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7471 ..Default::default()
7472 }
7473 }
7474
7475 #[test]
7476 fn behavior_gate_rejects_state_change_without_any_behavior() {
7477 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7478 // caixa carries no `:behavior` at all surfaces the missing-
7479 // callback diagnostic naming the offending entry's `:from` +
7480 // script. The "I added the upgrade path but never declared
7481 // `:behavior`" footgun: `:behavior` is optional at the typed
7482 // root, the typed `:upgrade-from` slot validates on its own
7483 // merits, and the operator's hot-upgrade dispatch reaches for
7484 // a callback that doesn't exist.
7485 let entries = vec![entry(
7486 "0.1.0",
7487 vec![
7488 UpgradeInstruction::LoadModule { module: "x".into() },
7489 UpgradeInstruction::StateChange {
7490 script: PathBuf::from("lib/m.lisp"),
7491 },
7492 ],
7493 )];
7494 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7495 assert_eq!(
7496 err,
7497 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7498 from: "0.1.0".into(),
7499 script: PathBuf::from("lib/m.lisp"),
7500 },
7501 );
7502 }
7503
7504 #[test]
7505 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7506 // `:behavior` declared with *other* callbacks set
7507 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7508 // None still surfaces the missing-callback diagnostic — only
7509 // the `:on-state-change` axis matters for this gate. The
7510 // "I declared `:behavior` but missed the migration callback"
7511 // footgun: a caixa that registers its lifecycle hooks but
7512 // forgets the migration delivery path leaves the
7513 // `:state-change` instruction with no runtime hook to
7514 // dispatch through.
7515 let entries = vec![entry(
7516 "0.1.0",
7517 vec![
7518 UpgradeInstruction::LoadModule { module: "x".into() },
7519 UpgradeInstruction::StateChange {
7520 script: PathBuf::from("lib/m.lisp"),
7521 },
7522 ],
7523 )];
7524 let b = crate::BehaviorSpec {
7525 on_init: Some(PathBuf::from("lib/init.lisp")),
7526 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7527 ..Default::default()
7528 };
7529 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7530 assert_eq!(
7531 err,
7532 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7533 from: "0.1.0".into(),
7534 script: PathBuf::from("lib/m.lisp"),
7535 },
7536 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7537 the missing migration hook"
7538 );
7539 }
7540
7541 #[test]
7542 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7543 // The canonical composition shape: a per-version
7544 // `(:state-change "lib/m.lisp")` instruction paired with the
7545 // `:behavior :on-state-change "lib/migrations.lisp"` callback
7546 // it is delivered through at hot-upgrade time. Pins the gate's
7547 // pass arm — drift here = a future tighten that rejects the
7548 // canonical OTP-shape composition surfaces as a regression at
7549 // this positive-control pin.
7550 let entries = vec![entry(
7551 "0.1.0",
7552 vec![
7553 UpgradeInstruction::LoadModule { module: "x".into() },
7554 UpgradeInstruction::StateChange {
7555 script: PathBuf::from("lib/m.lisp"),
7556 },
7557 ],
7558 )];
7559 let b = behavior_with_state_change_callback();
7560 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7561 }
7562
7563 #[test]
7564 fn behavior_gate_accepts_entries_without_any_state_change() {
7565 // Empty-set identity: entries carrying no `:state-change`
7566 // instruction at all (load + cleanup only — the metadata-only
7567 // upgrade shape the module doc names, "On any failure, the
7568 // current version stays load-bearing — a typed atomic
7569 // upgrade") leave the gate vacuous. The composition only
7570 // requires a callback when the per-version script exists; a
7571 // load + cleanup pair has no migration to deliver, so the
7572 // absence of `:on-state-change` is coherent.
7573 let entries = vec![entry(
7574 "0.1.0",
7575 vec![
7576 UpgradeInstruction::LoadModule { module: "x".into() },
7577 UpgradeInstruction::SoftPurge {
7578 module: "x-old".into(),
7579 },
7580 ],
7581 )];
7582 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7583 }
7584
7585 #[test]
7586 fn behavior_gate_accepts_restart_only_entry() {
7587 // The terminal-fallback `((:restart))` shape carries no
7588 // `:state-change` — the operator restarts the pod and the
7589 // new version comes up fresh against its initial state, no
7590 // migration. Pinned alongside the metadata-only positive
7591 // control above as the second empty-state-change shape.
7592 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7593 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7594 }
7595
7596 #[test]
7597 fn behavior_gate_accepts_empty_entries_list() {
7598 // Empty `:upgrade-from` (a caixa with no declared upgrade
7599 // paths — the v0.1.0 caixa before any upgrade entries are
7600 // added) trivially passes the gate. Pinned so the gate
7601 // doesn't accidentally fire on a caixa that hasn't yet
7602 // declared any upgrades.
7603 let entries: Vec<UpgradeFromEntry> = vec![];
7604 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7605 }
7606
7607 #[test]
7608 fn behavior_gate_reports_first_state_change_in_first_entry() {
7609 // First-collision determinism: with multiple `:state-change`
7610 // instructions across multiple entries, the gate reports the
7611 // *first* one encountered in declaration order — the entry's
7612 // declaration order first, then the within-entry instruction
7613 // order. Mirrors every peer first-collision diagnostic posture
7614 // on this module (`validate_state_change_ordering`,
7615 // `validate_purge_ordering`, the singularity gates), so a
7616 // future shortcut that walks the list in reverse or returns
7617 // the last collision surfaces as a regression here.
7618 let entries = vec![
7619 entry(
7620 "0.1.0",
7621 vec![
7622 UpgradeInstruction::LoadModule { module: "x".into() },
7623 UpgradeInstruction::StateChange {
7624 script: PathBuf::from("lib/m1.lisp"),
7625 },
7626 UpgradeInstruction::StateChange {
7627 script: PathBuf::from("lib/m2.lisp"),
7628 },
7629 ],
7630 ),
7631 entry(
7632 "0.1.5",
7633 vec![
7634 UpgradeInstruction::LoadModule { module: "x".into() },
7635 UpgradeInstruction::StateChange {
7636 script: PathBuf::from("lib/m3.lisp"),
7637 },
7638 ],
7639 ),
7640 ];
7641 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7642 assert_eq!(
7643 err,
7644 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7645 from: "0.1.0".into(),
7646 script: PathBuf::from("lib/m1.lisp"),
7647 },
7648 "the first :state-change in the first entry must surface, not later collisions"
7649 );
7650 }
7651
7652 #[test]
7653 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7654 // Cross-entry pin: a first entry with no `:state-change` (just
7655 // a load + cleanup) leaves the gate's per-entry walk continuing
7656 // to the second entry, where the offending instruction lives.
7657 // The diagnostic names the *second* entry's `:from` because
7658 // that's where the missing-callback shape is exposed — pinned
7659 // so a shortcut that bails on the first entry without a
7660 // `:state-change` (rather than continuing) doesn't mask the
7661 // defect in a later entry.
7662 let entries = vec![
7663 entry(
7664 "0.1.0",
7665 vec![
7666 UpgradeInstruction::LoadModule { module: "x".into() },
7667 UpgradeInstruction::SoftPurge {
7668 module: "x-old".into(),
7669 },
7670 ],
7671 ),
7672 entry(
7673 "0.1.5",
7674 vec![
7675 UpgradeInstruction::LoadModule { module: "x".into() },
7676 UpgradeInstruction::StateChange {
7677 script: PathBuf::from("lib/m.lisp"),
7678 },
7679 ],
7680 ),
7681 ];
7682 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7683 assert_eq!(
7684 err,
7685 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7686 from: "0.1.5".into(),
7687 script: PathBuf::from("lib/m.lisp"),
7688 },
7689 "the offending entry's `:from` must surface even when an earlier entry carries no \
7690 :state-change"
7691 );
7692 }
7693
7694 #[test]
7695 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7696 // Positive control: a multi-entry `:upgrade-from` (chained
7697 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7698 // a `:state-change` passes when the callback is declared once
7699 // at the caixa root. The callback is a single per-caixa
7700 // runtime hook; one declaration covers every entry's
7701 // `:state-change`, mirroring OTP's
7702 // `release_handler:install_release/1` which dispatches every
7703 // appup's `code_change` instruction through the single
7704 // `gen_server:code_change/3` callback registered on the
7705 // module.
7706 let entries = vec![
7707 entry(
7708 "0.1.0",
7709 vec![
7710 UpgradeInstruction::LoadModule { module: "x".into() },
7711 UpgradeInstruction::StateChange {
7712 script: PathBuf::from("lib/m1.lisp"),
7713 },
7714 ],
7715 ),
7716 entry(
7717 "0.1.5",
7718 vec![
7719 UpgradeInstruction::LoadModule { module: "x".into() },
7720 UpgradeInstruction::StateChange {
7721 script: PathBuf::from("lib/m2.lisp"),
7722 },
7723 ],
7724 ),
7725 ];
7726 let b = behavior_with_state_change_callback();
7727 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7728 }
7729
7730 #[test]
7731 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7732 // Symmetry pin: the gate's pass arm doesn't depend on the
7733 // entry actually carrying a `:state-change` — if no
7734 // `:state-change` is declared, the gate is vacuous regardless
7735 // of the callback (an `:on-state-change` declared without a
7736 // matching per-version script is fine, the callback is the
7737 // runtime default for any *future* migration the author hasn't
7738 // yet added). Pins that a caixa author can declare the
7739 // callback ahead of any migration without the gate
7740 // complaining.
7741 let entries = vec![entry(
7742 "0.1.0",
7743 vec![
7744 UpgradeInstruction::LoadModule { module: "x".into() },
7745 UpgradeInstruction::SoftPurge {
7746 module: "x-old".into(),
7747 },
7748 ],
7749 )];
7750 let b = behavior_with_state_change_callback();
7751 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7752 }
7753
7754 #[test]
7755 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7756 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7757 // per-instruction `StateChange`-arm script-path projection must
7758 // route through the sibling lifted
7759 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7760 // accessor, not the raw
7761 // `if let UpgradeInstruction::StateChange { script } = instr`
7762 // open-coded pattern-match the cross-slot gate previously
7763 // carried at caixa-core/src/upgrade.rs:1365.
7764 //
7765 // Structurally: the gate's projection accept-set is the union
7766 // of every [`UpgradeInstruction`] variant for which
7767 // `declared_path().is_some()` — today exactly
7768 // [`UpgradeInstruction::StateChange`] per the sibling
7769 // `declared_path_only_for_state_change` pin, so a
7770 // `:state-change`-carrying entry without an `:on-state-change`
7771 // callback trips `StateChangeWithoutOnStateChangeCallback` and
7772 // a non-`StateChange` entry (load-only / cleanup-only /
7773 // restart-only / empty-`:instructions`) leaves the per-entry
7774 // walk continuing past every non-projecting instruction
7775 // byte-identical to the pattern-match shape.
7776 //
7777 // Byte-equal today (`declared_path` returns `Some(script)` iff
7778 // `StateChange`, byte-for-byte from the variant's own storage);
7779 // the pin catches any future accessor extension that promotes
7780 // an additional variant onto the `PathBuf`-carrying axis — the
7781 // gate then fires on scripts from that variant too, and the
7782 // cross-slot composition discipline the sibling per-
7783 // `UpgradeInstruction` consumers share on the `PathBuf`-
7784 // carrying axis extends to the promoted variant by
7785 // construction.
7786 //
7787 // Peer of the sibling four per-`UpgradeInstruction` consumers
7788 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7789 // sandbox-path fan-out, the layout-side per-`StateChange`
7790 // script-existence fan-out at
7791 // `caixa-core/src/layout.rs:1058`, the within-entry
7792 // [`UpgradeFromEntry::validate_state_change_singularity`]
7793 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7794 // peer [`UpgradeInstruction::declared_module`] `String`-axis
7795 // per-variant unifier) — the fourth (and last) per-
7796 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7797 // to now route through the accessor. Same shape as the
7798 // sibling
7799 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7800 // pin extended onto the cross-slot composition gate.
7801 //
7802 // Three-arm projective coverage:
7803 // (a) `StateChange` scripts project through `declared_path()`
7804 // byte-equal to the raw `script.clone()` field access
7805 // the diagnostic previously carried;
7806 // (b) a `:state-change`-carrying entry with `behavior: None`
7807 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
7808 // carrying the offending script verbatim;
7809 // (c) a non-`StateChange`-only entry (`LoadModule` /
7810 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7811 // vacuous with `Ok(())` — the `declared_path().is_none()`
7812 // arm's fall-through pins.
7813 //
7814 // Fail-before-pass-after verified structurally: swapping the
7815 // production
7816 // `if let Some(script) = instr.declared_path() { … }`
7817 // back to
7818 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
7819 // keeps arms (a)-(c) passing but silently detaches the gate
7820 // from the accessor's typed dispatch — any future
7821 // `declared_path` extension (promotion of an additional
7822 // variant onto the axis, an operator-side pre-resolved-path
7823 // cache the accessor materializes) would then silently
7824 // disagree between this cross-slot gate's raw pattern-match
7825 // and the peer four sibling consumers that route through the
7826 // accessor.
7827
7828 // (a) StateChange projection byte-equal via declared_path.
7829 let sc = UpgradeInstruction::StateChange {
7830 script: PathBuf::from("lib/m.lisp"),
7831 };
7832 assert_eq!(
7833 sc.declared_path().cloned(),
7834 Some(PathBuf::from("lib/m.lisp")),
7835 "declared_path() must project the StateChange :script byte-equal to the raw \
7836 field access — accessor divergence would silently detach this cross-slot \
7837 composition gate from the projection every peer per-`UpgradeInstruction` \
7838 consumer routes through"
7839 );
7840
7841 // (b) StateChange-carrying entry with behavior: None trips gate.
7842 let entries = vec![entry(
7843 "0.1.0",
7844 vec![
7845 UpgradeInstruction::LoadModule { module: "x".into() },
7846 UpgradeInstruction::StateChange {
7847 script: PathBuf::from("lib/m.lisp"),
7848 },
7849 ],
7850 )];
7851 assert_eq!(
7852 validate_upgrade_from_against_behavior(&entries, None),
7853 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
7854 from: "0.1.0".into(),
7855 script: PathBuf::from("lib/m.lisp"),
7856 }),
7857 "a :state-change-carrying entry with behavior: None must trip the gate through \
7858 the declared_path accessor's Some(script) arm — carrying the offending script \
7859 verbatim byte-identical to the pattern-match shape"
7860 );
7861
7862 // (c) Non-StateChange-only inputs leave the gate vacuous.
7863 for instrs in [
7864 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7865 vec![
7866 UpgradeInstruction::LoadModule { module: "x".into() },
7867 UpgradeInstruction::SoftPurge {
7868 module: "x-old".into(),
7869 },
7870 ],
7871 vec![
7872 UpgradeInstruction::LoadModule { module: "x".into() },
7873 UpgradeInstruction::Purge {
7874 module: "x-old".into(),
7875 },
7876 ],
7877 vec![UpgradeInstruction::Restart],
7878 ] {
7879 for instr in &instrs {
7880 assert!(
7881 instr.declared_path().is_none(),
7882 "non-StateChange variants must project None through declared_path — \
7883 accessor divergence would let this cross-slot composition gate silently \
7884 fire on a module reference far from any :state-change site"
7885 );
7886 }
7887 let entries = vec![entry("0.1.0", instrs)];
7888 assert_eq!(
7889 validate_upgrade_from_against_behavior(&entries, None),
7890 Ok(()),
7891 "the cross-slot composition gate must return Ok(()) on an entry whose \
7892 instructions all project None through declared_path — the accessor's \
7893 None arm the pattern-match's implicit fall-through previously carried"
7894 );
7895 }
7896 }
7897
7898 #[test]
7899 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
7900 // Wiring pin: the within-entry restart-exclusivity gate fires
7901 // through [`validate_upgrade_from`] (which delegates to
7902 // [`UpgradeFromEntry::validate`] per entry) before the cross-
7903 // entry duplicate-`:from` gate would have a chance to run on
7904 // the malformed entry. Pinned here so a future refactor that
7905 // walks the cross-entry gate first doesn't accidentally
7906 // surface a DuplicateFrom over an entry that's also malformed
7907 // at the within-entry restart-exclusivity layer.
7908 let entries = vec![
7909 entry(
7910 "0.1.0",
7911 vec![
7912 UpgradeInstruction::LoadModule { module: "x".into() },
7913 UpgradeInstruction::Restart,
7914 ],
7915 ),
7916 entry("0.1.0", vec![UpgradeInstruction::Restart]),
7917 ];
7918 let err = validate_upgrade_from(&entries).unwrap_err();
7919 assert!(
7920 matches!(
7921 err,
7922 UpgradeError::RestartNotExclusive {
7923 restart_count: 1,
7924 ..
7925 }
7926 ),
7927 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
7928 duplicate-`:from` gate fires, got {err:?}"
7929 );
7930 }
7931
7932 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
7933
7934 #[test]
7935 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
7936 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
7937 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
7938 // name the exact camelCase JSON keys the `#[serde(rename_all =
7939 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
7940 // test-side probe across the caixa-core / caixa-flux renderer
7941 // test fixtures navigates into each element of the rendered
7942 // `:upgrade-from` overlay sequence by consulting one of these two
7943 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
7944 // and pin that each canonical byte-sequence appears verbatim in
7945 // the JSON — a future accidental `rename_all = "snake_case"` /
7946 // `"kebab-case"` / verbatim-field-name flip at the derive
7947 // attribute (any of which would silently break every test-side
7948 // probe that reaches for one of the two consts) surfaces here as
7949 // a build-time test failure at `upgrade.rs`, not as an apply-time
7950 // `.get(<stale-canonical-const>)` returning `None` far from the
7951 // derive-attr drift's commit. Same discipline the sibling
7952 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
7953 // (d8b8b4f) and
7954 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
7955 // (21fe462) pins established on the peer `:limits` / `:behavior`
7956 // sub-slot axes: one canonical byte-string per typed sub-key
7957 // axis, pinned to the load-bearing serde derivation at the type
7958 // itself.
7959 let e = UpgradeFromEntry {
7960 from: "0.1.0".into(),
7961 instructions: vec![UpgradeInstruction::LoadModule {
7962 module: "hello-rio".into(),
7963 }],
7964 };
7965 let json = serde_json::to_string(&e).unwrap();
7966 for key in [
7967 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7968 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7969 ] {
7970 let quoted = format!("\"{key}\"");
7971 assert!(
7972 json.contains("ed),
7973 "serialized UpgradeFromEntry must carry the lifted \
7974 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
7975 the JSON emission (got: {json})",
7976 );
7977 }
7978 }
7979
7980 #[test]
7981 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
7982 // Cross-axis drift-detection pin: a future collapse of the two
7983 // canonical sub-key byte-strings onto the same value (e.g. an
7984 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
7985 // to also read `"from"`) would silently reroute every test-side
7986 // probe on one axis onto the sibling axis's per-entry field and
7987 // pass every propagation-probe test that expected only the stale
7988 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
7989 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
7990 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
7991 let all = [
7992 crate::render::M2_UPGRADE_FROM_KEY_FROM,
7993 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
7994 ];
7995 for (i, a) in all.iter().enumerate() {
7996 for b in all.iter().skip(i + 1) {
7997 assert_ne!(
7998 a, b,
7999 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
8000 canonical byte-sequences — got `{a}` == `{b}`",
8001 );
8002 }
8003 }
8004 }
8005
8006 #[test]
8007 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
8008 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8009 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
8010 // tagged variant-discriminator key axis: the
8011 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
8012 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
8013 // attribute on [`UpgradeInstruction`] emits, and every downstream
8014 // consumer that navigates the serialized instruction blob to
8015 // route by variant (the caixa-core reflection-vs-serde round-trip
8016 // check in `dispatcher_registration.rs` that probes
8017 // `v.get("kind")` against every variant's expected kebab-case
8018 // tag, the future M4 admission-webhook path, any wasm-operator
8019 // dispatch step consuming the serialized instruction blob) reads
8020 // through the same `&'static str`. Serialize every variant and
8021 // pin that the const's byte-sequence appears verbatim as the
8022 // tag-slot JSON key with the expected kebab-case value — a
8023 // future accidental `tag = "type"` / `tag = "op"` /
8024 // `tag = "instruction"` rebrand at the derive attribute (any of
8025 // which would silently break every consumer probe reaching for
8026 // the stale-tag-key const) surfaces here as a build-time test
8027 // failure at `upgrade.rs`, not as an apply-time
8028 // `.get(<stale-tag-key>)` returning `None` far from the derive-
8029 // attr drift's commit.
8030 //
8031 // Same "one canonical byte-string per typed axis" discipline the
8032 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8033 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
8034 // outer-container axis — this pin extends the discipline one
8035 // altitude deeper onto the per-instruction *tag* axis inside
8036 // each element of the `:instructions` list, completing the
8037 // typed coverage of the `:upgrade-from :instructions` dual
8038 // (key = "kind" + five variant-value tags): the five
8039 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
8040 // per-variant kebab-case *values*; this pin pins the tag *key*
8041 // above them.
8042 let samples: [(UpgradeInstruction, &'static str); 5] = [
8043 (
8044 UpgradeInstruction::LoadModule {
8045 module: "hello-rio".into(),
8046 },
8047 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
8048 ),
8049 (
8050 UpgradeInstruction::StateChange {
8051 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8052 },
8053 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
8054 ),
8055 (
8056 UpgradeInstruction::SoftPurge {
8057 module: "hello-rio-old".into(),
8058 },
8059 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
8060 ),
8061 (
8062 UpgradeInstruction::Purge {
8063 module: "hello-rio-old".into(),
8064 },
8065 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
8066 ),
8067 (
8068 UpgradeInstruction::Restart,
8069 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
8070 ),
8071 ];
8072 for (sample, expected_value) in &samples {
8073 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
8074 let got = v
8075 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
8076 .and_then(|k| k.as_str());
8077 assert_eq!(
8078 got,
8079 Some(*expected_value),
8080 "serialized {sample:?} must carry the lifted \
8081 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
8082 ({:?}) verbatim as the tag-slot JSON key, holding the \
8083 expected kebab-case value {expected_value:?} (got: {v})",
8084 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
8085 );
8086 }
8087 }
8088
8089 #[test]
8090 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
8091 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
8092 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8093 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8094 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8095 // whitespace / colons / dots) — the canonical shape a serde
8096 // internally-tagged discriminator key takes across every peer
8097 // enum in this crate. A future flip to a non-camelCase byte at
8098 // the const surfaces here at build time. Peer of
8099 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
8100 // sibling per-entry outer-container axis.
8101 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8102 assert!(
8103 !key.is_empty(),
8104 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
8105 );
8106 let first = key.chars().next().unwrap();
8107 assert!(
8108 first.is_ascii_lowercase(),
8109 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
8110 byte (got {key:?}, leads with {first:?})",
8111 );
8112 assert!(
8113 key.chars().all(|c| c.is_ascii_alphanumeric()),
8114 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
8115 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8116 );
8117 }
8118
8119 #[test]
8120 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
8121 // Cross-axis drift-detection pin: the tag-slot key
8122 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
8123 // disjoint from every per-variant data-field key the
8124 // internally-tagged serialization also emits (`"module"` for
8125 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
8126 // future accidental rebrand that collapses `tag = "kind"` onto
8127 // one of the data-field names (e.g. `tag = "module"`) would
8128 // silently corrupt every serialized LoadModule blob (the
8129 // module string and the variant tag would collide on the same
8130 // JSON key) and every consumer probe would either misread the
8131 // tag or fail to distinguish variants. Pin the disjointness at
8132 // build time. Same cross-axis discipline the sibling
8133 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
8134 // (36ffe65) established on the outer container's own
8135 // `from`/`instructions` pair.
8136 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8137 // Enumerate every per-variant data-field key across all five
8138 // variants of [`UpgradeInstruction`], routing through the two
8139 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
8140 // that name the same per-variant data-field JSON keys the
8141 // `variant_fields` reflection in
8142 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
8143 // per-variant struct-field rebrand (`module` → `component`,
8144 // `script` → `path`) lands as an edit to exactly one const and
8145 // reaches this disjointness pin by construction — the two axes
8146 // (tag-slot key on one side, per-variant data-field keys on the
8147 // other) share one source of truth per axis.
8148 for data_field in [
8149 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8150 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8151 ] {
8152 assert_ne!(
8153 key, data_field,
8154 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
8155 must be disjoint from every UpgradeInstruction per-variant \
8156 data-field key — got tag-key {key:?} colliding with \
8157 data-field {data_field:?}, which would silently corrupt \
8158 the internally-tagged serialization",
8159 );
8160 }
8161 }
8162
8163 #[test]
8164 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
8165 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8166 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
8167 // data-field JSON key axis: the two
8168 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
8169 // `_SCRIPT`) name the exact per-variant field JSON keys the
8170 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
8171 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
8172 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
8173 // const — the `module: String` struct-field on
8174 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
8175 // struct-field on `StateChange` are promoted to sibling JSON keys
8176 // at the same nesting level as the tag by the internally-tagged
8177 // serialization, and every downstream consumer that navigates the
8178 // serialized instruction blob to reach the payload (the caixa-core
8179 // reflection round-trip in `dispatcher_registration.rs` that
8180 // consults `variant_fields`, the sibling disjointness pin below,
8181 // any future wasm-operator upgrade-dispatch step consuming the
8182 // serialized instruction blob to route the per-module load /
8183 // soft-purge / purge action or the per-script state-change action)
8184 // reads through the same `&'static str`. Serialize one Module-
8185 // bearing variant and one Script-bearing variant, then pin that
8186 // each const's byte-sequence appears verbatim in the JSON emission
8187 // — a future accidental struct-field rebrand (`module: String` →
8188 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8189 // either variant surfaces here as a build-time test failure at
8190 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8191 // returning `None` far from the field-name drift's commit.
8192 //
8193 // Same "one canonical byte-string per typed axis" discipline the
8194 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8195 // pin established on the peer tag-slot key axis on the same
8196 // enum — this pin extends the discipline onto the per-variant
8197 // data-field key axis, completing the `:upgrade-from :instructions`
8198 // variant-JSON dual (tag key + tag values + per-variant field keys)
8199 // fully into caixa-core.
8200 let module_sample = UpgradeInstruction::LoadModule {
8201 module: "hello-rio".into(),
8202 };
8203 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8204 assert_eq!(
8205 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8206 .and_then(|k| k.as_str()),
8207 Some("hello-rio"),
8208 "serialized {module_sample:?} must carry the lifted \
8209 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8210 ({:?}) verbatim as the data-field JSON key holding the \
8211 module string (got: {v})",
8212 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8213 );
8214
8215 let script_sample = UpgradeInstruction::StateChange {
8216 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8217 };
8218 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8219 assert_eq!(
8220 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8221 .and_then(|k| k.as_str()),
8222 Some("lib/migrations/v01-to-v02.lisp"),
8223 "serialized {script_sample:?} must carry the lifted \
8224 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8225 ({:?}) verbatim as the data-field JSON key holding the \
8226 script path (got: {v})",
8227 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8228 );
8229 }
8230
8231 #[test]
8232 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8233 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8234 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8235 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8236 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8237 // whitespace / colons / dots) — the canonical shape a Rust
8238 // struct-field name promoted to a JSON key by serde takes on this
8239 // internally-tagged variant surface, matching the sibling
8240 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8241 // shape. A future flip to a non-camelCase byte at either const
8242 // (an accidental `rename_all` regime interleave, or a struct-
8243 // field flip like `module` → `module_name`) surfaces here at
8244 // build time. Peer of
8245 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8246 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8247 // the sibling wire-key axes.
8248 for key in [
8249 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8250 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8251 ] {
8252 assert!(
8253 !key.is_empty(),
8254 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8255 );
8256 let first = key.chars().next().unwrap();
8257 assert!(
8258 first.is_ascii_lowercase(),
8259 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8260 byte (got {key:?}, leads with {first:?})",
8261 );
8262 assert!(
8263 key.chars().all(|c| c.is_ascii_alphanumeric()),
8264 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8265 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8266 );
8267 }
8268 }
8269
8270 #[test]
8271 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8272 // Cross-axis drift-detection pin: a future collapse of the two
8273 // canonical per-variant data-field byte-strings onto the same
8274 // value (e.g. an accidental copy-paste flip of
8275 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8276 // `"module"`) would silently reroute every test-side probe on one
8277 // variant's payload onto the sibling variant's payload and pass
8278 // every propagation-probe test that expected only the stale
8279 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8280 // on the sibling per-entry outer-container axis, and of
8281 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8282 // on the sibling tag-slot key ↔ per-variant data-field key axis.
8283 let all = [
8284 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8285 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8286 ];
8287 for (i, a) in all.iter().enumerate() {
8288 for b in all.iter().skip(i + 1) {
8289 assert_ne!(
8290 a, b,
8291 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8292 canonical byte-sequences — got `{a}` == `{b}`",
8293 );
8294 }
8295 }
8296 }
8297
8298 #[test]
8299 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8300 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8301 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8302 // `kebab-case` hyphens, no `PascalCase` leading capital, no
8303 // whitespace / colons / dots) — the canonical shape the
8304 // `#[serde(rename_all = "camelCase")]` derive produces on
8305 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8306 // at the derive surfaces both here (this test fails on the
8307 // stale-constant shape) and at
8308 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8309 // (that test fails on the mismatch between const and derive).
8310 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8311 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8312 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8313 for key in [
8314 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8315 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8316 ] {
8317 assert!(
8318 !key.is_empty(),
8319 "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8320 );
8321 let first = key.chars().next().unwrap();
8322 assert!(
8323 first.is_ascii_lowercase(),
8324 "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8325 byte (got {key:?}, leads with {first:?})",
8326 );
8327 assert!(
8328 key.chars().all(|c| c.is_ascii_alphanumeric()),
8329 "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8330 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8331 );
8332 }
8333 }
8334
8335 #[test]
8336 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8337 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8338 // OTP-appup variant-tag axis: the five canonical author-facing
8339 // kebab-case labels (`:load-module` / `:state-change` /
8340 // `:soft-purge` / `:purge` / `:restart`) the substrate's
8341 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8342 // from and every downstream consumer probes for verbatim. Same
8343 // scalar-value discipline the peer
8344 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8345 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8346 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8347 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8348 // (be40492) established for the sibling M2 / M3 / Supervisor
8349 // top-level and sub-slot author-facing-label axes. Fail-before-
8350 // pass-after locally verified by mutating
8351 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8352 // pin fires as expected; restoring passes.
8353 //
8354 // A future OTP-lineage per-variant rebrand (e.g.
8355 // `:load-module` → `:load` matching Erlang's abbreviated
8356 // `code:load_module` name, `:state-change` → `:code-change`
8357 // matching Erlang's verbatim `code_change/3` callback,
8358 // `:soft-purge` → `:drain` matching a hypothetical operator-side
8359 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8360 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8361 // matching a supervisor-tree vocabulary alignment) lands as an
8362 // edit to exactly one const, and every consumer that reaches for
8363 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8364 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8365 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8366 // `prior_cleanup_kind:` diagnostic field, the
8367 // [`LayoutError::UpgradeViolation`] `issue:` probe in
8368 // `layout.rs`) picks it up at build time rather than at runtime
8369 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8370 // far from the rename's commit.
8371 assert_eq!(
8372 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8373 ":load-module"
8374 );
8375 assert_eq!(
8376 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8377 ":state-change"
8378 );
8379 assert_eq!(
8380 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8381 ":soft-purge"
8382 );
8383 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8384 assert_eq!(
8385 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8386 ":restart"
8387 );
8388 }
8389
8390 #[test]
8391 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8392 // Cross-arm drift-detection pin on the M2
8393 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8394 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8395 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8396 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8397 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8398 // closed-set OTP-appup variant-tag pentad: a future collapse
8399 // of two canonical variant byte-strings onto the same value
8400 // (an accidental copy-paste flip of
8401 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8402 // to also read `":purge"`, a per-arm rebrand that lands one
8403 // const without touching its paired peer) would silently
8404 // reroute every downstream OTP-appup dispatcher's per-
8405 // instruction branch onto the sibling arm's runtime
8406 // behavior and pass every propagation-probe test that
8407 // expected only the stale arm's tag — a `:soft-purge`
8408 // instruction (drain-then-swap: existing callers finish
8409 // under the old module, new callers land on the new one)
8410 // would come up under the `:purge` reconcile branch
8411 // (drop-existing: every in-flight caller terminates
8412 // immediately) on every hot-upgrade cycle, so a rolling
8413 // module swap would silently downgrade to a hard cutover
8414 // against its declared appup discipline, with no field
8415 // naming the instruction-tag drift root cause. Every
8416 // [`crate::UpgradeError`] diagnostic that surfaces the tag
8417 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8418 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8419 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8420 // `prior_cleanup_kind:` field, the
8421 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8422 // `layout.rs`) would emit the sibling arm's stale bytes at
8423 // the operator's console, far from the source rebrand
8424 // commit. Peer of the sibling
8425 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8426 // (09ffb2d) /
8427 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8428 // (ccdf955) /
8429 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8430 // (d739850) distinctness pins on the sibling OTP-shape /
8431 // caixa-kind closed-set typed-enum discriminator axes —
8432 // the fifth closed-set OTP-appup / typed-enum axis to
8433 // converge on the same
8434 // "pairwise-distinct-by-construction" discipline, and the
8435 // canonical companion to the peer
8436 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8437 // (ff980bb) distinctness pin on the sibling internally-
8438 // tagged-JSON per-variant data-field-key axis (the tag axis
8439 // this pin covers vs. the data-field-key axis its peer
8440 // covers — two paired axes on the same
8441 // [`crate::UpgradeInstruction`] typed enum surface).
8442 //
8443 // Fail-before-pass-after locally verified by mutating
8444 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8445 // to also read `":purge"` — this pin fires as expected;
8446 // restoring passes.
8447 let all = [
8448 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8449 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8450 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8451 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8452 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8453 ];
8454 for (i, a) in all.iter().enumerate() {
8455 for (j, b) in all.iter().enumerate() {
8456 if i != j {
8457 assert_ne!(
8458 a, b,
8459 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8460 distinct — got duplicate {a:?} at indices {i} and {j}",
8461 );
8462 }
8463 }
8464 }
8465 }
8466
8467 #[test]
8468 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8469 // Production-through-const pin: the five per-variant labels
8470 // [`UpgradeInstruction::lisp_form`] returns route through the
8471 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8472 // so a future rebrand that reaches the const but not the
8473 // dispatch (or vice versa) surfaces here at build time rather
8474 // than at runtime as a downstream
8475 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8476 // diagnostic drift far from the rename's commit. Mirror of the
8477 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8478 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8479 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8480 // (f49c8b0) production-through-const pins on the sibling M3 /
8481 // M2 top-level slot axes.
8482 //
8483 // Fail-before-pass-after locally verified by mutating
8484 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8485 // `":purge-drift"` — this pin fires as expected; restoring
8486 // passes.
8487 let cases: &[(UpgradeInstruction, &'static str)] = &[
8488 (
8489 UpgradeInstruction::LoadModule { module: "x".into() },
8490 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8491 ),
8492 (
8493 UpgradeInstruction::StateChange {
8494 script: PathBuf::from("lib/m.lisp"),
8495 },
8496 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8497 ),
8498 (
8499 UpgradeInstruction::SoftPurge {
8500 module: "x-old".into(),
8501 },
8502 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8503 ),
8504 (
8505 UpgradeInstruction::Purge {
8506 module: "x-old".into(),
8507 },
8508 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8509 ),
8510 (
8511 UpgradeInstruction::Restart,
8512 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8513 ),
8514 ];
8515 for (instr, expected) in cases {
8516 assert_eq!(
8517 instr.lisp_form(),
8518 *expected,
8519 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8520 const (expected {expected:?})",
8521 );
8522 }
8523 }
8524
8525 #[test]
8526 fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8527 // Return-lifetime pin on the substrate primitive: because
8528 // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8529 // (threaded verbatim from the paired
8530 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8531 // roster's program-lifetime storage), the label survives
8532 // dropping the borrow through `self` — a downstream logger
8533 // that stashes the tag in a `&'static`-bounded position
8534 // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8535 // accept-set, a static formatter's `%s` argument) reads it
8536 // without re-borrowing through the instruction reference. A
8537 // future refactor that accidentally narrows the return to
8538 // `&str` (lifetime-bound to `&self`) — say by projecting through
8539 // an owned `String` intermediate — would fail this compile-time
8540 // pin at build time far from the runtime-side lifetime
8541 // regression at every downstream `&'static str` consumer. Peer
8542 // pin discipline the sibling
8543 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8544 // `pub const _: &str = "..."` shape already carries at the
8545 // paired wire-form axis.
8546 //
8547 // The pin fires by taking the label from an instruction that
8548 // goes out of scope before the label is read — if
8549 // `lisp_form` returned a `&str` tied to `&self`, this would
8550 // fail to compile with "borrowed value does not live long
8551 // enough". Fail-before-pass-after locally verified: narrowing
8552 // the signature to `fn lisp_form(&self) -> &str { … }`
8553 // reproduces the compile error.
8554 fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8555 instr.lisp_form()
8556 }
8557 let label = {
8558 let instr = UpgradeInstruction::LoadModule {
8559 module: "ephemeral".into(),
8560 };
8561 stash_label_as_static(&instr)
8562 // instr drops here; label must survive
8563 };
8564 assert_eq!(
8565 label,
8566 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8567 "the &'static str return must survive the borrowed \
8568 UpgradeInstruction going out of scope — a lifetime narrowing \
8569 to &str would fail this pin at build time",
8570 );
8571 }
8572
8573 #[test]
8574 fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8575 // Const-position pin on the substrate primitive: because
8576 // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8577 // consumers can call it in `const` contexts — a `const`
8578 // declaration threading the label through, a `static` lookup
8579 // table pre-computed at compile time, a `match` arm's
8580 // `const`-eligible branch label. `pub` matters here: a
8581 // `pub(crate) const fn` would compile in-crate const contexts
8582 // but no external caixa-<target> renderer or feira verb could
8583 // reach the projection in a const context. Fail-before-pass-
8584 // after locally verified: reverting the visibility to
8585 // `pub(crate) const fn` (or removing `pub`) makes this pin
8586 // fail to compile at the const-context call site below.
8587 const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8588 assert_eq!(
8589 RESTART_LABEL,
8590 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8591 "const-position dispatch on Restart must yield the lifted \
8592 M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8593 );
8594 }
8595
8596 #[test]
8597 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8598 // The canonical per-`:upgrade-from :instructions` OTP-appup
8599 // migration-instruction-list slice-shape pin:
8600 // [`UpgradeFromEntry::instructions`] must return the
8601 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8602 // a `&[UpgradeInstruction]` slice-view over the same backing
8603 // buffer the raw `self.instructions.as_slice()` field access
8604 // borrows from, byte-equal across every representative fixture
8605 // in the accept-set — the empty slice (the "no-op upgrade" /
8606 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8607 // field's own docstring names), the singleton slice on every
8608 // variant of the [`UpgradeInstruction`] arm-space
8609 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8610 // `Restart` — the five OTP-appup runtime-primitive variants),
8611 // and multi-instruction cohorts (the canonical
8612 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8613 // load + state-migration triad the module doc names as the
8614 // "runs the instructions in order" example).
8615 //
8616 // Pins against a future silent detour that returned
8617 // `&Vec<UpgradeInstruction>` (which would type-check but leak
8618 // the storage-side `Vec`'s grow/push/reserve surface no
8619 // consumer of the typed view reaches for), a fresh-allocated
8620 // `Vec<UpgradeInstruction>` copy (which would type-check via
8621 // a coercion but silently break every downstream caller that
8622 // relied on the slice sharing the backing buffer's identity),
8623 // or an out-of-order or length-drifted projection (which
8624 // would silently split the paired within-entry cross-
8625 // instruction ordering gates' inputs from the peer per-
8626 // instruction shape-check loop's input, one seven-gate cohort
8627 // silently drifting from the peer gate's actual traversal
8628 // input).
8629 //
8630 // Peer of the sibling
8631 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8632 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8633 // `:contratos` edge-list axis, extended onto the M2 per-
8634 // `:upgrade-from :instructions` migration-instruction-list
8635 // axis — the fifth `&[T]`-return byte-equal pin, closing the
8636 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8637 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8638 Vec::new(),
8639 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8640 vec![UpgradeInstruction::StateChange {
8641 script: PathBuf::from("lib/m.lisp"),
8642 }],
8643 vec![UpgradeInstruction::SoftPurge {
8644 module: "x-old".into(),
8645 }],
8646 vec![UpgradeInstruction::Purge {
8647 module: "x-old".into(),
8648 }],
8649 vec![UpgradeInstruction::Restart],
8650 vec![
8651 UpgradeInstruction::LoadModule { module: "x".into() },
8652 UpgradeInstruction::StateChange {
8653 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8654 },
8655 UpgradeInstruction::SoftPurge {
8656 module: "x-old".into(),
8657 },
8658 ],
8659 ];
8660 for instructions in fixtures {
8661 let e = UpgradeFromEntry {
8662 from: "0.1.0".into(),
8663 instructions: instructions.clone(),
8664 };
8665 assert_eq!(
8666 e.instructions(),
8667 e.instructions.as_slice(),
8668 "UpgradeFromEntry::instructions must project the raw \
8669 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8670 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8671 (fixture: {instructions:?})",
8672 );
8673 assert_eq!(
8674 e.instructions().len(),
8675 instructions.len(),
8676 "UpgradeFromEntry::instructions length must match the raw \
8677 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8678 );
8679 }
8680 }
8681
8682 #[test]
8683 fn validate_reads_through_lifted_instructions_accessor() {
8684 // Three-consumer coherence pin on the lifted
8685 // [`UpgradeFromEntry::instructions`] slice-return accessor:
8686 // exercises three of the nine paired production consumers of
8687 // the per-`:upgrade-from :instructions` OTP-appup migration-
8688 // instruction-list surface through end-to-end validate() paths
8689 // that require the accessor to reach each of the fixture's
8690 // instructions.
8691 //
8692 // (1) The per-instruction shape-check fan-out
8693 // ([`UpgradeFromEntry::validate`]'s `for instr in
8694 // self.instructions()` loop): pass the well-formed load →
8695 // state-change → soft-purge triad — `validate()` must accept
8696 // it, which requires the accessor to project every entry so
8697 // each `instr.validate()` fires.
8698 //
8699 // (2) The within-entry state-change-ordering gate
8700 // ([`Self::validate_state_change_ordering`]): pass a
8701 // `((:state-change …))` singleton — `validate()` must return
8702 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8703 // requires the accessor to reach the state-change so the
8704 // no-prior-load probe fires.
8705 //
8706 // (3) The within-entry per-module cleanup-singularity gate
8707 // ([`Self::validate_cleanup_singularity`]): pass a
8708 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8709 // "x-old"))` cohort — `validate()` must return
8710 // [`UpgradeError::DuplicateCleanup`], which requires the
8711 // accessor to iterate the whole list so the second `SoftPurge`
8712 // matches the first via the `seen` set.
8713 //
8714 // Peer of the sibling
8715 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8716 // three-consumer coherence pin on the M3 per-`:contratos`
8717 // edge-list axis, extended onto the M2 per-`:upgrade-from
8718 // :instructions` migration-instruction-list axis.
8719
8720 // (1) accept the well-formed OTP two-phase code-load triad
8721 let well_formed = entry(
8722 "0.1.0",
8723 vec![
8724 UpgradeInstruction::LoadModule { module: "x".into() },
8725 UpgradeInstruction::StateChange {
8726 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8727 },
8728 UpgradeInstruction::SoftPurge {
8729 module: "x-old".into(),
8730 },
8731 ],
8732 );
8733 assert!(
8734 well_formed.validate().is_ok(),
8735 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8736 the per-instruction shape-check fan-out requires the accessor to reach every entry"
8737 );
8738
8739 // (2) refuse a `((:state-change …))` singleton — the
8740 // state-change-without-prior-load gate must fire, which
8741 // requires the accessor to reach the single instruction.
8742 let no_prior_load = entry(
8743 "0.1.0",
8744 vec![UpgradeInstruction::StateChange {
8745 script: PathBuf::from("lib/m.lisp"),
8746 }],
8747 );
8748 match no_prior_load.validate() {
8749 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8750 other => panic!(
8751 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8752 — the within-entry state-change-ordering gate must reach the single \
8753 instruction through the lifted accessor; got: {other:?}"
8754 ),
8755 }
8756
8757 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
8758 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
8759 // singularity gate must fire on the second `SoftPurge`, which
8760 // requires the accessor to iterate the whole list.
8761 let duplicate_cleanup = entry(
8762 "0.1.0",
8763 vec![
8764 UpgradeInstruction::LoadModule { module: "x".into() },
8765 UpgradeInstruction::SoftPurge {
8766 module: "x-old".into(),
8767 },
8768 UpgradeInstruction::SoftPurge {
8769 module: "x-old".into(),
8770 },
8771 ],
8772 );
8773 match duplicate_cleanup.validate() {
8774 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
8775 assert_eq!(
8776 module, "x-old",
8777 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
8778 cleanup-singularity gate must iterate through the lifted accessor to \
8779 match the second SoftPurge against the first via the `seen` set"
8780 );
8781 }
8782 other => panic!(
8783 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
8784 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
8785 iterate the whole list through the lifted accessor; got: {other:?}"
8786 ),
8787 }
8788
8789 // Path::new suppresses the unused-import warning if the
8790 // outer module trims `use std::path::Path;` in a future edit.
8791 let _ = Path::new("lib/m.lisp");
8792 }
8793
8794 // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
8795 // macro definition (see the paired doc-block above the macro
8796 // definition) — every generated `<ctor>(from: &str, script: &Path)
8797 // -> Self` constructor folds the uniform `Self::<Variant> { from:
8798 // from.to_string(), script: script.to_path_buf() }` two-field
8799 // struct-literal onto one substrate primitive. The three per-variant
8800 // equivalence pins below (fail-before-pass-after by construction — a
8801 // byte-mismatched macro arm would trip its equivalence pin first)
8802 // lock each generated constructor to its struct-literal peer under
8803 // `PartialEq`, so every wire-up in
8804 // [`UpgradeFromEntry::validate_state_change_ordering`],
8805 // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
8806 // [`validate_state_change_on_state_change_callback`] on that
8807 // variant produces a byte-equal `UpgradeError` to the pre-lift
8808 // open-coded struct-literal. The cross-axis pin that follows
8809 // (non-default `(from, script)` pair) routes both constructor input
8810 // axes through `.to_string()` / `.to_path_buf()`, so the fold does
8811 // not silently collapse onto a fixed `from` / `script` value.
8812 //
8813 // Peer of the sibling `empty_child_version_ctor_matches_struct_
8814 // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
8815 // literal_wrap` / `child_supervises_self_ctor_matches_struct_
8816 // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
8817 // to_string` equivalence + cross-axis pins the sibling
8818 // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
8819 // established on the peer `SupervisorError` envelope; extended
8820 // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
8821 // two-slot envelope so every substrate-primitive ctor family in
8822 // caixa-core guarantees the same-shape fold every wire-up on the
8823 // family reads through one dispatch.
8824
8825 #[test]
8826 fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
8827 let from = "0.1.0";
8828 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8829 assert_eq!(
8830 UpgradeError::state_change_without_prior_load(from, script),
8831 UpgradeError::StateChangeWithoutPriorLoad {
8832 from: from.to_string(),
8833 script: script.to_path_buf(),
8834 },
8835 "generated state_change_without_prior_load ctor must produce \
8836 byte-equal UpgradeError to the open-coded struct-literal \
8837 wrap on the same (&str, &Path) fixture",
8838 );
8839 }
8840
8841 #[test]
8842 fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
8843 let from = "0.1.0";
8844 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8845 assert_eq!(
8846 UpgradeError::duplicate_state_change(from, script),
8847 UpgradeError::DuplicateStateChange {
8848 from: from.to_string(),
8849 script: script.to_path_buf(),
8850 },
8851 "generated duplicate_state_change ctor must produce byte-equal \
8852 UpgradeError to the open-coded struct-literal wrap on the \
8853 same (&str, &Path) fixture",
8854 );
8855 }
8856
8857 #[test]
8858 fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
8859 let from = "0.1.0";
8860 let script = Path::new("lib/migrations/v01-to-v02.lisp");
8861 assert_eq!(
8862 UpgradeError::state_change_without_on_state_change_callback(from, script),
8863 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8864 from: from.to_string(),
8865 script: script.to_path_buf(),
8866 },
8867 "generated state_change_without_on_state_change_callback ctor \
8868 must produce byte-equal UpgradeError to the open-coded \
8869 struct-literal wrap on the same (&str, &Path) fixture",
8870 );
8871 }
8872
8873 #[test]
8874 fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
8875 // Cross-axis pin: sweep both constructor input axes (`from:
8876 // &str`, `script: &Path`) through non-default fixtures against
8877 // every generated arm in the [`upgrade_from_script_ctors!`]
8878 // macro, so any wrapper-side lowercase / trim / truncate /
8879 // re-order / fixed-path substitution on the two-field
8880 // construction surfaces here rather than at a downstream
8881 // diagnostic-shape mismatch. Also exercises the `&Path`
8882 // parameter under both `&Path` (direct `Path::new`) and
8883 // `&PathBuf` (via Deref coercion), matching the two shapes the
8884 // three wire-up sites thread through — the ordering /
8885 // callback-declaration gates hand a `&PathBuf` from
8886 // `instr.declared_path()`; the uniqueness gate hands a `&Path`
8887 // from `script.as_path()`. Peer of the sibling
8888 // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
8889 // cross-axis pin on the peer `SupervisorError` `{ caixa:
8890 // String }` envelope.
8891 let from = "1.2.3-rc.1";
8892 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
8893 let script_ref: &Path = script_owned.as_path();
8894 for script in [script_ref, &script_owned as &Path] {
8895 assert_eq!(
8896 UpgradeError::state_change_without_prior_load(from, script),
8897 UpgradeError::StateChangeWithoutPriorLoad {
8898 from: from.to_string(),
8899 script: script.to_path_buf(),
8900 },
8901 );
8902 assert_eq!(
8903 UpgradeError::duplicate_state_change(from, script),
8904 UpgradeError::DuplicateStateChange {
8905 from: from.to_string(),
8906 script: script.to_path_buf(),
8907 },
8908 );
8909 assert_eq!(
8910 UpgradeError::state_change_without_on_state_change_callback(from, script),
8911 UpgradeError::StateChangeWithoutOnStateChangeCallback {
8912 from: from.to_string(),
8913 script: script.to_path_buf(),
8914 },
8915 );
8916 }
8917 }
8918
8919 // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
8920 // macro definition (see the paired doc-block above the macro
8921 // definition) — every generated `<ctor>(script: &Path) -> Self`
8922 // constructor folds the uniform `Self::<Variant> { script:
8923 // script.to_path_buf() }` one-field struct-literal onto one substrate
8924 // primitive. The three per-variant equivalence pins below
8925 // (fail-before-pass-after by construction — a byte-mismatched macro
8926 // arm would trip its equivalence pin first) lock each generated
8927 // constructor to its struct-literal peer under `PartialEq`, so every
8928 // closure passed to [`crate::render::require_sandboxed_lisp_path`]
8929 // at [`UpgradeInstruction::validate`] on that variant produces a
8930 // byte-equal `UpgradeError` to the pre-lift open-coded
8931 // struct-literal. The cross-axis pin that follows (non-default
8932 // `script` path, both `&Path` and `&PathBuf` shapes) routes the
8933 // constructor input axis through `.to_path_buf()`, so the fold does
8934 // not silently collapse onto a fixed `script` value or drop the
8935 // Deref-coercion arm the wire-up sites depend on.
8936 //
8937 // Peer of the sibling
8938 // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
8939 // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
8940 // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
8941 // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
8942 // equivalence + cross-axis pins the sibling
8943 // [`upgrade_from_script_ctors!`] family (8e67041) established on the
8944 // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
8945 // extended here onto the `{ script: PathBuf }` one-slot envelope
8946 // shape so every substrate-primitive ctor family on `UpgradeError`
8947 // guarantees the same-shape fold every wire-up on the family reads
8948 // through one dispatch.
8949
8950 #[test]
8951 fn absolute_script_ctor_matches_struct_literal_wrap() {
8952 let script = Path::new("/etc/nope.lisp");
8953 assert_eq!(
8954 UpgradeError::absolute_script(script),
8955 UpgradeError::AbsoluteScript {
8956 script: script.to_path_buf(),
8957 },
8958 "generated absolute_script ctor must produce byte-equal \
8959 UpgradeError to the open-coded struct-literal wrap on the \
8960 same &Path fixture",
8961 );
8962 }
8963
8964 #[test]
8965 fn parent_escape_script_ctor_matches_struct_literal_wrap() {
8966 let script = Path::new("../oops.lisp");
8967 assert_eq!(
8968 UpgradeError::parent_escape_script(script),
8969 UpgradeError::ParentEscapeScript {
8970 script: script.to_path_buf(),
8971 },
8972 "generated parent_escape_script ctor must produce byte-equal \
8973 UpgradeError to the open-coded struct-literal wrap on the \
8974 same &Path fixture",
8975 );
8976 }
8977
8978 #[test]
8979 fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
8980 let script = Path::new("lib/migrations.rs");
8981 assert_eq!(
8982 UpgradeError::non_lisp_extension_script(script),
8983 UpgradeError::NonLispExtensionScript {
8984 script: script.to_path_buf(),
8985 },
8986 "generated non_lisp_extension_script ctor must produce \
8987 byte-equal UpgradeError to the open-coded struct-literal \
8988 wrap on the same &Path fixture",
8989 );
8990 }
8991
8992 #[test]
8993 fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
8994 // Cross-axis pin: sweep the constructor input axis (`script:
8995 // &Path`) through a non-default fixture against every generated
8996 // arm in the [`upgrade_script_only_ctors!`] macro, so any
8997 // wrapper-side lowercase / trim / truncate / re-order /
8998 // fixed-path substitution on the one-field construction
8999 // surfaces here rather than at a downstream diagnostic-shape
9000 // mismatch. Also exercises the `&Path` parameter under both
9001 // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
9002 // coercion), matching the shape the three closures at
9003 // [`UpgradeInstruction::validate`] thread through — the
9004 // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
9005 // each closure, so the Deref-coercion arm the ctor advertises
9006 // must actually route through `.to_path_buf()` and not
9007 // silently swap in a fixed path.
9008 //
9009 // Peer of the sibling
9010 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9011 // cross-axis pin on the sibling `{ from, script }` two-slot
9012 // envelope shape.
9013 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9014 let script_ref: &Path = script_owned.as_path();
9015 for script in [script_ref, &script_owned as &Path] {
9016 assert_eq!(
9017 UpgradeError::absolute_script(script),
9018 UpgradeError::AbsoluteScript {
9019 script: script.to_path_buf(),
9020 },
9021 );
9022 assert_eq!(
9023 UpgradeError::parent_escape_script(script),
9024 UpgradeError::ParentEscapeScript {
9025 script: script.to_path_buf(),
9026 },
9027 );
9028 assert_eq!(
9029 UpgradeError::non_lisp_extension_script(script),
9030 UpgradeError::NonLispExtensionScript {
9031 script: script.to_path_buf(),
9032 },
9033 );
9034 }
9035 }
9036
9037 // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
9038 // macro definition (see the paired doc-block above the macro
9039 // definition) — every generated `<ctor>(from: &str, <axis>: &str)
9040 // -> Self` constructor folds the uniform `Self::<Variant> { from:
9041 // from.to_string(), <axis>: <axis>.to_string() }` two-field
9042 // struct-literal onto one substrate primitive. The three per-variant
9043 // equivalence pins below (fail-before-pass-after by construction — a
9044 // byte-mismatched macro arm would trip its equivalence pin first)
9045 // lock each generated constructor to its struct-literal peer under
9046 // `PartialEq`, so every wire-up in
9047 // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
9048 // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
9049 // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
9050 // `:from < :versao` gate on that variant produces a byte-equal
9051 // `UpgradeError` to the pre-lift open-coded struct-literal. The
9052 // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
9053 // pair) routes both constructor input axes through `.to_string()`
9054 // in declared field order, so the fold does not silently swap `from`
9055 // and the middle `<axis>` field, or silently collapse onto a fixed
9056 // `from` / `<axis>` value on any one variant.
9057 //
9058 // Peer of the sibling `state_change_without_prior_load_ctor_matches_
9059 // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
9060 // struct_literal_wrap` / `state_change_without_on_state_change_
9061 // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
9062 // ctors_route_from_and_script_verbatim` equivalence + cross-axis
9063 // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
9064 // established on the sibling `{ from: String, script: PathBuf }`
9065 // two-slot envelope shape; extended here onto the `{ from: String,
9066 // <axis>: String }` two-slot envelope shape so every substrate-
9067 // primitive ctor family on `UpgradeError` guarantees the same-shape
9068 // fold every wire-up on the family reads through one dispatch. Also
9069 // mirror-symmetric peer of the sibling
9070 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9071 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
9072 // <axis>: String }` two-slot envelope shape.
9073
9074 #[test]
9075 fn from_invalid_ctor_matches_struct_literal_wrap() {
9076 let from = "not-a-semver";
9077 let reason = "unexpected character '-' at position 3";
9078 assert_eq!(
9079 UpgradeError::from_invalid(from, reason),
9080 UpgradeError::FromInvalid {
9081 from: from.to_string(),
9082 reason: reason.to_string(),
9083 },
9084 "generated from_invalid ctor must produce byte-equal \
9085 UpgradeError to the open-coded struct-literal wrap on the \
9086 same (&str, &str) fixture",
9087 );
9088 }
9089
9090 #[test]
9091 fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
9092 let from = "0.2.0";
9093 let versao = "0.1.0";
9094 assert_eq!(
9095 UpgradeError::from_not_before_versao(from, versao),
9096 UpgradeError::FromNotBeforeVersao {
9097 from: from.to_string(),
9098 versao: versao.to_string(),
9099 },
9100 "generated from_not_before_versao ctor must produce byte-equal \
9101 UpgradeError to the open-coded struct-literal wrap on the \
9102 same (&str, &str) fixture",
9103 );
9104 }
9105
9106 #[test]
9107 fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
9108 let from = "0.1.0";
9109 let module = "hello-rio";
9110 assert_eq!(
9111 UpgradeError::duplicate_load_module(from, module),
9112 UpgradeError::DuplicateLoadModule {
9113 from: from.to_string(),
9114 module: module.to_string(),
9115 },
9116 "generated duplicate_load_module ctor must produce byte-equal \
9117 UpgradeError to the open-coded struct-literal wrap on the \
9118 same (&str, &str) fixture",
9119 );
9120 }
9121
9122 #[test]
9123 fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
9124 // Cross-axis routing pin: sweep the two constructor input axes
9125 // (`from: &str`, `<axis>: &str`) through distinct-per-axis
9126 // fixtures against every generated arm in the
9127 // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
9128 // lowercase / trim / truncate at codegen time — a silent field
9129 // swap between `from` and the middle `<axis>` field, or a
9130 // `<axis>` axis silently rerouted through the wrong field on any
9131 // one variant — surfaces here rather than at a downstream
9132 // diagnostic-shape mismatch. Peer of the sibling
9133 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9134 // (8e67041) cross-axis pin on the same envelope's sibling
9135 // `{ from: String, script: PathBuf }` two-slot family, and of the
9136 // sibling
9137 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9138 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
9139 // String, <axis>: String }` two-slot envelope. Distinct-per-
9140 // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
9141 // that would still pass a same-fixture-per-axis pin. Both
9142 // `&str`-literal and `&String` (via Deref coercion) carriers
9143 // are exercised because the three wire-up sites hand a mix of
9144 // both (the `from_invalid` site hands `&e.to_string()` — an
9145 // owned `String` — for `reason`; the `duplicate_load_module`
9146 // site hands a `&str` slice for `module`; the
9147 // `from_not_before_versao` site hands the caller-supplied
9148 // `versao: &str` for `versao`).
9149 let from = "0.1.0";
9150 let axis = "distinct-axis-value";
9151 let from_owned: String = from.to_string();
9152 let axis_owned: String = axis.to_string();
9153 for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
9154 assert_eq!(
9155 UpgradeError::from_invalid(from_in, axis_in),
9156 UpgradeError::FromInvalid {
9157 from: from.to_string(),
9158 reason: axis.to_string(),
9159 },
9160 "from_invalid must route `from` → `from`, `axis` → `reason` \
9161 in declared field order",
9162 );
9163 assert_eq!(
9164 UpgradeError::from_not_before_versao(from_in, axis_in),
9165 UpgradeError::FromNotBeforeVersao {
9166 from: from.to_string(),
9167 versao: axis.to_string(),
9168 },
9169 "from_not_before_versao must route `from` → `from`, \
9170 `axis` → `versao` in declared field order",
9171 );
9172 assert_eq!(
9173 UpgradeError::duplicate_load_module(from_in, axis_in),
9174 UpgradeError::DuplicateLoadModule {
9175 from: from.to_string(),
9176 module: axis.to_string(),
9177 },
9178 "duplicate_load_module must route `from` → `from`, \
9179 `axis` → `module` in declared field order",
9180 );
9181 }
9182 }
9183
9184 // Per-variant equivalence + accessor-fidelity + cross-axis pins for
9185 // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
9186 // the paired doc-block above the ctor definition) — the fold of the
9187 // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9188 // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9189 // duplicate gate onto one substrate primitive on the
9190 // [`UpgradeError`] envelope, projecting through the paired
9191 // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9192 // primitive. A byte-mismatched ctor body would trip the equivalence
9193 // pin first, ahead of any downstream diagnostic-shape drift.
9194 //
9195 // Peer of the sibling standalone-ctor equivalence pins on the peer
9196 // one-off variants across caixa-core:
9197 // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9198 // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9199 // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9200 // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9201 // envelope, the sibling
9202 // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9203 // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9204 // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9205 // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9206
9207 #[test]
9208 fn duplicate_from_ctor_matches_struct_literal_wrap() {
9209 // Equivalence pin: the ctor produces byte-equal
9210 // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9211 // struct-literal that read the same `from` field through
9212 // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9213 // addition / reordering / string-conversion tweak on the
9214 // variant. Same equivalence-pin shape as the sibling
9215 // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9216 // (b30edfe) on the paired two-slot `{ caixa, wit }`
9217 // envelope inside `impl AplicacaoSpec`.
9218 let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9219 let lifted = UpgradeError::duplicate_from(&entry);
9220 let struct_literal = UpgradeError::DuplicateFrom {
9221 from: entry.prior_versao().to_string(),
9222 };
9223 assert_eq!(lifted, struct_literal);
9224 }
9225
9226 #[test]
9227 fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9228 // Routing pin sweeping a non-default `:from` value
9229 // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9230 // release and build metadata) through the paired
9231 // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9232 // wrapper-side lowercase / trim / truncate on the one-field
9233 // construction surfaces here rather than at a downstream
9234 // diagnostic-shape drift. Peer of the sibling
9235 // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9236 // (b30edfe) routing pin on the sibling two-slot envelope.
9237 //
9238 // The pre-release + build-metadata carrier value is deliberately
9239 // chosen to exercise the `.to_string()` path against a `:from`
9240 // shape [`semver::Version::PartialEq`] treats as distinct from
9241 // its release-only sibling (per the
9242 // `validate_upgrade_from_treats_pre_release_as_distinct` and
9243 // build-metadata-tightening-note doc-block on
9244 // [`validate_upgrade_from`]) — so any silent normalization at
9245 // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9246 // `.split_once('-')` collapse) would drop bytes from the
9247 // rendered diagnostic and surface here.
9248 let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9249 let built = UpgradeError::duplicate_from(&entry);
9250 match built {
9251 UpgradeError::DuplicateFrom { from } => {
9252 assert_eq!(
9253 from, "1.2.3-rc.4+build.5",
9254 "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9255 preserving pre-release + build-metadata bytes"
9256 );
9257 }
9258 other => panic!("expected DuplicateFrom, got {other:?}"),
9259 }
9260 }
9261
9262 #[test]
9263 fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9264 // Accessor-fidelity pin: the ctor's `from` slot keys off the
9265 // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9266 // the pre-lift open-coded body's field selection), not any
9267 // stringified rendering of the full entry (e.g. the
9268 // `impl Display for UpgradeFromEntry` output, if one were later
9269 // added, or a `format!("{:?}", entry)` debug dump). Pins the
9270 // projection axis so a silent swap at the ctor body — say, a
9271 // future refactor that projects through `entry.instructions()`
9272 // in shape (dropping the `:from` axis entirely) or through a
9273 // whole-entry `format!` — surfaces here rather than at a
9274 // downstream diagnostic mis-attribution far from the duplicate
9275 // gate's owner.
9276 //
9277 // A future consumer that constructs the ctor against a not-yet-
9278 // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9279 // CR admission webhook re-checking a per-`:upgrade-from`-patched
9280 // candidate before the cross-entry duplicate gate re-fires, a
9281 // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9282 // `(:from …)` introduced by a cluster-local `:upgrade-from`
9283 // override) needs the pre-lift projection axis pinned.
9284 //
9285 // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9286 // paired with a distinctive multi-instruction sequence so a
9287 // silent swap that projects through the whole-entry rendering
9288 // instead of the paired scalar accessor would land debug bytes
9289 // from the `:instructions` list into the `from` slot and trip
9290 // the assertion here.
9291 let entry = entry(
9292 "0.2.0-alpha.7",
9293 vec![
9294 UpgradeInstruction::LoadModule {
9295 module: "distinctive-load-target".into(),
9296 },
9297 UpgradeInstruction::StateChange {
9298 script: PathBuf::from("lib/distinctive-migrate.lisp"),
9299 },
9300 UpgradeInstruction::Restart,
9301 ],
9302 );
9303 let built = UpgradeError::duplicate_from(&entry);
9304 match built {
9305 UpgradeError::DuplicateFrom { from } => {
9306 assert_eq!(
9307 from, "0.2.0-alpha.7",
9308 "from slot must project UpgradeFromEntry::prior_versao() \
9309 (not any whole-entry rendering)"
9310 );
9311 }
9312 other => panic!("expected DuplicateFrom, got {other:?}"),
9313 }
9314 }
9315
9316 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9317 // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9318 // ctor (see the paired doc-block above the ctor definition) — the
9319 // fold of the last open-coded three-slot `{ from: String, kind:
9320 // &'static str, module: String }` struct-literal wire-up on
9321 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9322 // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9323 // load-family sticky-latch dispatch onto one substrate primitive.
9324 // A byte-mismatched ctor body would trip the equivalence pin first,
9325 // ahead of any downstream diagnostic-shape drift.
9326 //
9327 // Peer of the sibling standalone-ctor equivalence + routing pins on
9328 // the sibling one-off variants across `UpgradeError`
9329 // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9330 // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9331 // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9332 // the paired one-slot `{ from: String }` envelope) and across
9333 // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9334 // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9335 // String }` `AplicacaoError` envelope).
9336
9337 #[test]
9338 fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9339 // Equivalence pin: the ctor produces byte-equal
9340 // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9341 // open-coded three-field struct-literal on the same `(&str,
9342 // &'static str, &str)` fixture. Guards any future field-
9343 // addition / reordering / string-conversion tweak on the
9344 // variant. Same equivalence-pin shape as the sibling
9345 // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9346 // on the peer one-slot `{ from: String }` envelope.
9347 let from = "0.1.0";
9348 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9349 let module = "hello-rio-old";
9350 assert_eq!(
9351 UpgradeError::purge_without_prior_load(from, kind, module),
9352 UpgradeError::PurgeWithoutPriorLoad {
9353 from: from.to_string(),
9354 kind,
9355 module: module.to_string(),
9356 },
9357 "generated purge_without_prior_load ctor must produce \
9358 byte-equal UpgradeError to the open-coded struct-literal \
9359 wrap on the same (&str, &'static str, &str) fixture",
9360 );
9361 }
9362
9363 #[test]
9364 fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9365 // Cross-axis routing pin: sweep the three constructor input
9366 // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9367 // through distinct-per-axis fixtures across every cleanup-family
9368 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9369 // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9370 // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9371 // wrapper-side lowercase / trim / truncate / silent axis-swap
9372 // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9373 // three-field construction surfaces at assert time rather than
9374 // at a downstream diagnostic consumer that reads the fields
9375 // back and gets a different value than the one it stored. Both
9376 // `&str`-literal and `&String` (via Deref coercion) carriers
9377 // are exercised for `from` / `module` because the sole wire-up
9378 // hands `self.prior_versao()` (a `&str` accessor) and
9379 // `instr.declared_module().expect(…)` (also a `&str`) — the
9380 // ctor must accept both shapes without a pre-conversion.
9381 let kinds: [&'static str; 2] = [
9382 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9383 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9384 ];
9385 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9386 let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9387 for kind in kinds {
9388 for from in froms {
9389 for module in modules {
9390 let from_owned: String = from.to_string();
9391 let module_owned: String = module.to_string();
9392 for (from_in, module_in) in
9393 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9394 {
9395 assert_eq!(
9396 UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9397 UpgradeError::PurgeWithoutPriorLoad {
9398 from: from.to_string(),
9399 kind,
9400 module: module.to_string(),
9401 },
9402 "purge_without_prior_load must route from → from, \
9403 kind → kind, module → module in declared field \
9404 order verbatim on ({from:?}, {kind:?}, {module:?})",
9405 );
9406 }
9407 }
9408 }
9409 }
9410 }
9411
9412 #[test]
9413 fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9414 // End-to-end wire-up pin: build an entry whose declared
9415 // `:instructions` list places a `:soft-purge` (and separately a
9416 // `:purge`) before any `:load-module` so
9417 // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9418 // sticky-latch dispatch surfaces
9419 // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9420 // observed `Err` byte-equals the substrate-primitive
9421 // [`UpgradeError::purge_without_prior_load`] ctor's output on
9422 // the same fixture. A future silent de-lift of the wire-up back
9423 // to the open-coded struct-literal (or a silent axis-swap on
9424 // the three-field construction at the wire-up site) trips at
9425 // caixa-core test time rather than at a downstream diagnostic
9426 // consumer far from the wire-up commit. Same end-to-end-wire-up
9427 // discipline as the sibling
9428 // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9429 // on the peer cross-entry duplicate-`:from` gate; both key off
9430 // exactly one typed dispatch on the substrate primitive.
9431 let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9432 (
9433 "0.1.0",
9434 UpgradeInstruction::SoftPurge {
9435 module: "hello-rio-old".into(),
9436 },
9437 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9438 "hello-rio-old",
9439 ),
9440 (
9441 "1.2.3-rc.1",
9442 UpgradeInstruction::Purge {
9443 module: "cache-v2-ancient".into(),
9444 },
9445 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9446 "cache-v2-ancient",
9447 ),
9448 ];
9449 for (from, instr, kind, module) in cases {
9450 let e = entry(from, vec![instr]);
9451 let observed = e.validate().unwrap_err();
9452 assert_eq!(
9453 observed,
9454 UpgradeError::purge_without_prior_load(from, kind, module),
9455 "validate_purge_ordering must route its refusal through \
9456 UpgradeError::purge_without_prior_load(from, kind, \
9457 module) on a bare-cleanup {kind:?} entry, byte-equal \
9458 to the pre-lift open-coded struct-literal wrap on the \
9459 same fixture",
9460 );
9461 }
9462 }
9463
9464 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9465 // the standalone [`UpgradeError::state_change_after_cleanup`]
9466 // inherent ctor (see the paired doc-block above the ctor
9467 // definition) — the fold of the last open-coded four-slot `{ from:
9468 // String, script: PathBuf, prior_cleanup_kind: &'static str,
9469 // prior_cleanup_module: String }` struct-literal wire-up on
9470 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9471 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9472 // migrate-family sticky-latch dispatch onto one substrate primitive.
9473 // A byte-mismatched ctor body would trip the equivalence pin first,
9474 // ahead of any downstream diagnostic-shape drift. Peer of the
9475 // sibling standalone-ctor equivalence + routing pins on the sibling
9476 // one-off variants across `UpgradeError`
9477 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9478 // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9479 // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9480 // on the paired three-slot `{ from, kind, module }` envelope;
9481 // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9482 // one-slot `{ from }` envelope).
9483
9484 #[test]
9485 fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9486 // Equivalence pin: the ctor produces byte-equal
9487 // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9488 // open-coded four-field struct-literal on the same `(&str,
9489 // &Path, &'static str, &str)` fixture. Guards any future
9490 // field-addition / reordering / string-conversion tweak on the
9491 // variant. Same equivalence-pin shape as the sibling
9492 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9493 // (9752da1) on the peer three-slot envelope.
9494 let from = "0.1.0";
9495 let script = Path::new("lib/m.lisp");
9496 let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9497 let prior_cleanup_module = "x-old";
9498 assert_eq!(
9499 UpgradeError::state_change_after_cleanup(
9500 from,
9501 script,
9502 prior_cleanup_kind,
9503 prior_cleanup_module,
9504 ),
9505 UpgradeError::StateChangeAfterCleanup {
9506 from: from.to_string(),
9507 script: script.to_path_buf(),
9508 prior_cleanup_kind,
9509 prior_cleanup_module: prior_cleanup_module.to_string(),
9510 },
9511 "generated state_change_after_cleanup ctor must produce \
9512 byte-equal UpgradeError to the open-coded struct-literal \
9513 wrap on the same (&str, &Path, &'static str, &str) fixture",
9514 );
9515 }
9516
9517 #[test]
9518 fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9519 // Cross-axis routing pin: sweep the four constructor input
9520 // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9521 // &'static str`, `prior_cleanup_module: &str`) through
9522 // distinct-per-axis fixtures across every cleanup-family
9523 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9524 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9525 // build-metadata, zero), sibling-`.lisp` script-path shapes
9526 // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9527 // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9528 // lowercase / trim / truncate / silent axis-swap
9529 // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9530 // `from`, `prior_cleanup_kind` misrouted onto
9531 // `prior_cleanup_module`) on the four-field construction
9532 // surfaces at assert time rather than at a downstream diagnostic
9533 // consumer that reads the fields back and gets a different value
9534 // than the one it stored. Both `&str`-literal and `&String` (via
9535 // Deref coercion) carriers are exercised for `from` /
9536 // `prior_cleanup_module` because the sole wire-up hands
9537 // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9538 // (also `&str`, from `declared_module().expect(…)`) — the ctor
9539 // must accept both shapes without a pre-conversion. Both
9540 // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9541 // are exercised for `script` because the sole wire-up hands a
9542 // `&PathBuf` sticky-latch projection from `declared_path()`'s
9543 // `Option<&PathBuf>` return — the ctor must accept both shapes
9544 // without a pre-conversion.
9545 let kinds: [&'static str; 2] = [
9546 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9547 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9548 ];
9549 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9550 let scripts: [&str; 3] = [
9551 "m.lisp",
9552 "lib/migrations.lisp",
9553 "lib/migrations/v01/step-1.lisp",
9554 ];
9555 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9556 for kind in kinds {
9557 for from in froms {
9558 for script_str in scripts {
9559 for module in modules {
9560 let from_owned: String = from.to_string();
9561 let module_owned: String = module.to_string();
9562 let script_path = Path::new(script_str);
9563 let script_pathbuf = PathBuf::from(script_str);
9564 for (from_in, module_in, script_in) in [
9565 (from, module, script_path),
9566 (
9567 from_owned.as_str(),
9568 module_owned.as_str(),
9569 script_pathbuf.as_path(),
9570 ),
9571 ] {
9572 assert_eq!(
9573 UpgradeError::state_change_after_cleanup(
9574 from_in, script_in, kind, module_in,
9575 ),
9576 UpgradeError::StateChangeAfterCleanup {
9577 from: from.to_string(),
9578 script: PathBuf::from(script_str),
9579 prior_cleanup_kind: kind,
9580 prior_cleanup_module: module.to_string(),
9581 },
9582 "state_change_after_cleanup must route from → from, \
9583 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9584 prior_cleanup_module → prior_cleanup_module in declared \
9585 field order verbatim on ({from:?}, {script_str:?}, \
9586 {kind:?}, {module:?})",
9587 );
9588 }
9589 }
9590 }
9591 }
9592 }
9593 }
9594
9595 #[test]
9596 fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9597 // End-to-end wire-up pin: build an entry whose declared
9598 // `:instructions` list places a `:soft-purge` (and separately a
9599 // `:purge`) before a `:state-change` so
9600 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9601 // migrate-family sticky-latch dispatch surfaces
9602 // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9603 // observed `Err` byte-equals the substrate-primitive
9604 // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9605 // the same fixture. A future silent de-lift of the wire-up back
9606 // to the open-coded struct-literal (or a silent axis-swap on
9607 // the four-field construction at the wire-up site) trips at
9608 // caixa-core test time rather than at a downstream diagnostic
9609 // consumer far from the wire-up commit. Same end-to-end-wire-up
9610 // discipline as the sibling
9611 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9612 // on the peer load → cleanup ordering gate; both key off
9613 // exactly one typed dispatch on the substrate primitive. Every
9614 // entry here front-loads a `:load-module` so the sole surviving
9615 // ordering refusal is the migrate → cleanup one this gate
9616 // owns — the peer `validate_purge_ordering` load → cleanup gate
9617 // returns `Ok(())` on these fixtures, so the migrate-after-
9618 // cleanup arm is the only path to an `Err`.
9619 let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9620 (
9621 "0.1.0",
9622 UpgradeInstruction::SoftPurge {
9623 module: "hello-rio-old".into(),
9624 },
9625 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9626 "hello-rio-old",
9627 "lib/migrations/v01.lisp",
9628 ),
9629 (
9630 "1.2.3-rc.1",
9631 UpgradeInstruction::Purge {
9632 module: "cache-v2-ancient".into(),
9633 },
9634 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9635 "cache-v2-ancient",
9636 "lib/migrations/v02.lisp",
9637 ),
9638 ];
9639 for (from, cleanup, kind, module, script_str) in cases {
9640 let script = PathBuf::from(script_str);
9641 let e = entry(
9642 from,
9643 vec![
9644 UpgradeInstruction::LoadModule {
9645 module: "hello-rio".into(),
9646 },
9647 cleanup,
9648 UpgradeInstruction::StateChange {
9649 script: script.clone(),
9650 },
9651 ],
9652 );
9653 let observed = e.validate().unwrap_err();
9654 assert_eq!(
9655 observed,
9656 UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9657 "validate_state_change_before_cleanup must route its \
9658 refusal through \
9659 UpgradeError::state_change_after_cleanup(from, script, \
9660 prior_cleanup_kind, prior_cleanup_module) on a \
9661 `:state-change` after a bare-cleanup {kind:?} entry, \
9662 byte-equal to the pre-lift open-coded struct-literal \
9663 wrap on the same fixture",
9664 );
9665 }
9666 }
9667
9668 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9669 // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9670 // (see the paired doc-block above the ctor definition) — the fold of
9671 // the last open-coded three-slot `{ from: String, module: String,
9672 // kinds: Vec<&'static str> }` struct-literal wire-up on
9673 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9674 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9675 // cleanup-family dedup arm onto one substrate primitive. A byte-
9676 // mismatched ctor body would trip the equivalence pin first, ahead of
9677 // any downstream diagnostic-shape drift. Peer of the sibling
9678 // standalone-ctor equivalence + routing pins on the sibling one-off
9679 // variants across `UpgradeError`
9680 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9681 // paired three-slot `{ from, kind, module }` envelope for the sibling
9682 // load → cleanup ordering axis;
9683 // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9684 // the paired four-slot `{ from, script, prior_cleanup_kind,
9685 // prior_cleanup_module }` envelope for the migrate → cleanup
9686 // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9687 // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9688 // `:from` gate).
9689
9690 #[test]
9691 fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9692 // Equivalence pin: the ctor produces byte-equal
9693 // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9694 // three-field struct-literal on the same `(&str, &str,
9695 // Vec<&'static str>)` fixture. Guards any future field-addition /
9696 // reordering / string-conversion tweak on the variant. Same
9697 // equivalence-pin shape as the sibling
9698 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9699 // (9752da1) on the peer three-slot envelope.
9700 let from = "0.1.0";
9701 let module = "x-old";
9702 let kinds: Vec<&'static str> = vec![
9703 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9704 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9705 ];
9706 assert_eq!(
9707 UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9708 UpgradeError::DuplicateCleanup {
9709 from: from.to_string(),
9710 module: module.to_string(),
9711 kinds,
9712 },
9713 "generated duplicate_cleanup ctor must produce byte-equal \
9714 UpgradeError to the open-coded struct-literal wrap on the \
9715 same (&str, &str, Vec<&'static str>) fixture",
9716 );
9717 }
9718
9719 #[test]
9720 fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9721 // Cross-axis routing pin: sweep the three constructor input axes
9722 // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9723 // through distinct-per-axis fixtures across every ordered pair of
9724 // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9725 // four `(prior_kind, kind)` combinations `validate_cleanup_
9726 // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9727 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9728 // build-metadata, zero) + DNS-1123 module shapes (leaf,
9729 // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9730 // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9731 // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9732 // owned `Vec<&'static str>`) on the three-field construction
9733 // surfaces at assert time rather than at a downstream diagnostic
9734 // consumer that reads the fields back and gets a different value
9735 // than the one it stored. Both `&str`-literal and `&String` (via
9736 // Deref coercion) carriers are exercised for `from` / `module`
9737 // because the sole wire-up hands `self.prior_versao()` (a `&str`
9738 // accessor) and `module` (also `&str`, from `declared_module().
9739 // expect(…)`) — the ctor must accept both shapes without a
9740 // pre-conversion.
9741 let all_kinds: [&'static str; 2] = [
9742 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9743 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9744 ];
9745 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9746 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9747 for prior_kind in all_kinds {
9748 for kind in all_kinds {
9749 for from in froms {
9750 for module in modules {
9751 let from_owned: String = from.to_string();
9752 let module_owned: String = module.to_string();
9753 for (from_in, module_in) in
9754 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9755 {
9756 let kinds: Vec<&'static str> = vec![prior_kind, kind];
9757 assert_eq!(
9758 UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
9759 UpgradeError::DuplicateCleanup {
9760 from: from.to_string(),
9761 module: module.to_string(),
9762 kinds,
9763 },
9764 "duplicate_cleanup must route from → from, \
9765 module → module, kinds → kinds in declared \
9766 field order verbatim on ({from:?}, \
9767 {module:?}, [{prior_kind:?}, {kind:?}])",
9768 );
9769 }
9770 }
9771 }
9772 }
9773 }
9774 }
9775
9776 #[test]
9777 fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
9778 // End-to-end wire-up pin: build an entry whose declared
9779 // `:instructions` list front-loads a `:load-module` (so the
9780 // sibling `validate_purge_ordering` load → cleanup gate returns
9781 // `Ok(())` on the fixture) and then places two cleanup
9782 // instructions targeting the same module so
9783 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9784 // cleanup-family dedup arm surfaces
9785 // `UpgradeError::DuplicateCleanup`, then pin that the observed
9786 // `Err` byte-equals the substrate-primitive
9787 // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
9788 // fixture. A future silent de-lift of the wire-up back to the
9789 // open-coded struct-literal (or a silent axis-swap on the three-
9790 // field construction at the wire-up site, or a kinds-pair
9791 // reorder) trips at caixa-core test time rather than at a
9792 // downstream diagnostic consumer far from the wire-up commit.
9793 // Same end-to-end-wire-up discipline as the sibling
9794 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9795 // on the peer load → cleanup ordering gate and
9796 // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
9797 // on the peer migrate → cleanup boundary; all three key off
9798 // exactly one typed dispatch on the substrate primitive.
9799 let cases: [(
9800 &str,
9801 UpgradeInstruction,
9802 UpgradeInstruction,
9803 &str,
9804 [&'static str; 2],
9805 ); 4] = [
9806 (
9807 "0.1.0",
9808 UpgradeInstruction::SoftPurge {
9809 module: "hello-rio-old".into(),
9810 },
9811 UpgradeInstruction::SoftPurge {
9812 module: "hello-rio-old".into(),
9813 },
9814 "hello-rio-old",
9815 [
9816 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9817 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9818 ],
9819 ),
9820 (
9821 "1.2.3-rc.1",
9822 UpgradeInstruction::Purge {
9823 module: "cache-v2-ancient".into(),
9824 },
9825 UpgradeInstruction::Purge {
9826 module: "cache-v2-ancient".into(),
9827 },
9828 "cache-v2-ancient",
9829 [
9830 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9831 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9832 ],
9833 ),
9834 (
9835 "0.2.0-alpha.7+build.5",
9836 UpgradeInstruction::SoftPurge {
9837 module: "x-old".into(),
9838 },
9839 UpgradeInstruction::Purge {
9840 module: "x-old".into(),
9841 },
9842 "x-old",
9843 [
9844 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9845 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9846 ],
9847 ),
9848 (
9849 "0.0.0",
9850 UpgradeInstruction::Purge {
9851 module: "x-old".into(),
9852 },
9853 UpgradeInstruction::SoftPurge {
9854 module: "x-old".into(),
9855 },
9856 "x-old",
9857 [
9858 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9859 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9860 ],
9861 ),
9862 ];
9863 for (from, first, second, module, kinds) in cases {
9864 let e = entry(
9865 from,
9866 vec![
9867 UpgradeInstruction::LoadModule {
9868 module: "hello-rio".into(),
9869 },
9870 first,
9871 second,
9872 ],
9873 );
9874 let observed = e.validate().unwrap_err();
9875 assert_eq!(
9876 observed,
9877 UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
9878 "validate_cleanup_singularity must route its refusal \
9879 through UpgradeError::duplicate_cleanup(from, module, \
9880 kinds) on a two-cleanup {kinds:?} entry targeting the \
9881 same module, byte-equal to the pre-lift open-coded \
9882 struct-literal wrap on the same fixture",
9883 );
9884 }
9885 }
9886
9887 #[test]
9888 fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
9889 // Equivalence pin: the ctor produces byte-equal
9890 // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
9891 // three-field struct-literal on the same `(&str, usize,
9892 // Vec<&'static str>)` fixture. Guards any future field-addition /
9893 // reordering / string-conversion tweak on the variant. Same
9894 // equivalence-pin shape as the sibling
9895 // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
9896 // on the peer three-slot envelope.
9897 let from = "0.1.0";
9898 let restart_count: usize = 1;
9899 let other_kinds: Vec<&'static str> =
9900 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
9901 assert_eq!(
9902 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
9903 UpgradeError::RestartNotExclusive {
9904 from: from.to_string(),
9905 restart_count,
9906 other_kinds,
9907 },
9908 "generated restart_not_exclusive ctor must produce byte-equal \
9909 UpgradeError to the open-coded struct-literal wrap on the \
9910 same (&str, usize, Vec<&'static str>) fixture",
9911 );
9912 }
9913
9914 #[test]
9915 fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
9916 // Cross-axis routing pin: sweep the three constructor input axes
9917 // (`from: &str`, `restart_count: usize`, `other_kinds:
9918 // Vec<&'static str>`) through distinct-per-axis fixtures across a
9919 // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
9920 // pre-release + build-metadata, zero) × non-degenerate
9921 // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
9922 // pure-duplication shape, 3 — the deeply-duplicated shape) ×
9923 // ordered `other_kinds` lisp-form lists spanning the four
9924 // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
9925 // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
9926 // empty (the `((:restart) (:restart))` shape), singleton
9927 // (`((:load-module …) (:restart))`), and the full typed sequence
9928 // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
9929 // …) (:restart))`) — so any wrapper-side silent lowercase / trim
9930 // / truncate / silent axis-swap (`from` ↔ swap onto
9931 // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
9932 // duplicate on the four-element owned `Vec<&'static str>`,
9933 // `other_kinds` reorder against declared instruction order) on
9934 // the three-field construction surfaces at assert time rather
9935 // than at a downstream diagnostic consumer that reads the fields
9936 // back and gets a different value than the one it stored. Both
9937 // `&str`-literal and `&String` (via Deref coercion) carriers are
9938 // exercised for `from` because the sole wire-up hands
9939 // `self.prior_versao()` (a `&str` accessor).
9940 let all_typed_kinds: [&'static str; 4] = [
9941 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
9942 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
9943 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9944 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9945 ];
9946 let other_kinds_matrix: [Vec<&'static str>; 3] =
9947 [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
9948 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9949 let restart_counts: [usize; 3] = [1, 2, 3];
9950 for other_kinds in &other_kinds_matrix {
9951 for restart_count in restart_counts {
9952 for from in froms {
9953 let from_owned: String = from.to_string();
9954 for from_in in [from, from_owned.as_str()] {
9955 assert_eq!(
9956 UpgradeError::restart_not_exclusive(
9957 from_in,
9958 restart_count,
9959 other_kinds.clone(),
9960 ),
9961 UpgradeError::RestartNotExclusive {
9962 from: from.to_string(),
9963 restart_count,
9964 other_kinds: other_kinds.clone(),
9965 },
9966 "restart_not_exclusive must route from → from, \
9967 restart_count → restart_count, other_kinds → \
9968 other_kinds in declared field order verbatim \
9969 on ({from:?}, {restart_count:?}, \
9970 {other_kinds:?})",
9971 );
9972 }
9973 }
9974 }
9975 }
9976 }
9977
9978 #[test]
9979 fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
9980 // End-to-end wire-up pin: sweep the three canonical exclusivity-
9981 // violation shapes the `validate_restart_exclusive` gate can
9982 // refuse — restart + one typed instruction (`restart_count: 1,
9983 // other_kinds: [load-module]`), restart + full typed sequence
9984 // (`restart_count: 1, other_kinds: [load-module, state-change,
9985 // soft-purge, purge]`), and duplicated restart only
9986 // (`restart_count: 2, other_kinds: []`) — and pin that each
9987 // observed `Err` byte-equals the substrate-primitive
9988 // [`UpgradeError::restart_not_exclusive`] ctor's output on the
9989 // same fixture. A future silent de-lift of the wire-up back to
9990 // the open-coded struct-literal (or a silent axis-swap on the
9991 // three-field construction at the wire-up site, or an
9992 // `other_kinds` reorder / drop) trips at caixa-core test time
9993 // rather than at a downstream diagnostic consumer far from the
9994 // wire-up commit. Same end-to-end-wire-up discipline as the
9995 // sibling
9996 // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
9997 // (10a5b48) on the peer per-module cleanup-singularity axis and
9998 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9999 // on the peer load → cleanup ordering gate; all three key off
10000 // exactly one typed dispatch on the substrate primitive.
10001 let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
10002 (
10003 "0.1.0",
10004 vec![
10005 UpgradeInstruction::LoadModule {
10006 module: "hello-rio".into(),
10007 },
10008 UpgradeInstruction::Restart,
10009 ],
10010 1,
10011 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
10012 ),
10013 (
10014 "1.2.3-rc.1",
10015 vec![
10016 UpgradeInstruction::LoadModule {
10017 module: "hello-rio".into(),
10018 },
10019 UpgradeInstruction::StateChange {
10020 script: PathBuf::from("lib/m.lisp"),
10021 },
10022 UpgradeInstruction::SoftPurge {
10023 module: "hello-rio-old".into(),
10024 },
10025 UpgradeInstruction::Purge {
10026 module: "hello-rio-old".into(),
10027 },
10028 UpgradeInstruction::Restart,
10029 ],
10030 1,
10031 vec![
10032 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10033 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10034 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10035 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10036 ],
10037 ),
10038 (
10039 "0.0.0",
10040 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
10041 2,
10042 vec![],
10043 ),
10044 ];
10045 for (from, instructions, restart_count, other_kinds) in cases {
10046 let e = entry(from, instructions);
10047 let observed = e.validate().unwrap_err();
10048 assert_eq!(
10049 observed,
10050 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10051 "validate_restart_exclusive must route its refusal \
10052 through UpgradeError::restart_not_exclusive(from, \
10053 restart_count, other_kinds) on a mixed-`(:restart)` \
10054 entry, byte-equal to the pre-lift open-coded struct-\
10055 literal wrap on the same fixture",
10056 );
10057 }
10058 }
10059
10060 #[test]
10061 fn module_invalid_ctor_matches_struct_literal_wrap() {
10062 // Fail-before-pass-after equivalence pin on
10063 // [`UpgradeError::module_invalid`] — the constructor must
10064 // produce a byte-equal `UpgradeError` to the pre-lift open-
10065 // coded `Self::ModuleInvalid { kind, module: module.to_string(),
10066 // reason }` struct-literal on the same `(:load-module …)` /
10067 // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
10068 // mismatched constructor body (a stray `.trim()`, a rebased
10069 // field order, a `String::new()` reason substitution) would
10070 // trip this pin first, byte-for-byte against the sibling
10071 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
10072 // [`crate::SupervisorError::child_caixa_invalid`] /
10073 // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
10074 // discipline on the peer three-slot `{ *, reason: String }`
10075 // invalid-arm ctor family.
10076 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10077 let module = "Hello-Rio";
10078 let reason = "must be lowercase alphanumeric or `-`";
10079 assert_eq!(
10080 UpgradeError::module_invalid(kind, module, reason),
10081 UpgradeError::ModuleInvalid {
10082 kind,
10083 module: module.to_string(),
10084 reason: reason.to_string(),
10085 },
10086 "generated module_invalid ctor must produce byte-equal \
10087 UpgradeError to the open-coded struct-literal wrap on the \
10088 same (kind, module, reason) fixture",
10089 );
10090 }
10091
10092 #[test]
10093 fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10094 // Cross-axis pin: sweep the constructor's `kind: &'static str`
10095 // input across every [`UpgradeInstruction::declared_module`]-
10096 // bearing variant's canonical
10097 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10098 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10099 // canonical `":phantom"` fourth arm proving the ctor does not
10100 // silently clamp `kind` to the three-arm roster. The
10101 // `reason: impl Into<String>` bound accepts both `&str`
10102 // literals and the [`String`] the underlying
10103 // [`crate::render::is_dns_1123_label`] predicate returns via
10104 // `.into()`, matching the peer
10105 // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
10106 // sweep on the sibling `:contratos` per-edge envelope.
10107 let module = "Hello-Rio";
10108 let reason = "must be lowercase alphanumeric or `-`";
10109 for kind in [
10110 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10111 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10112 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10113 ":phantom",
10114 ] {
10115 assert_eq!(
10116 UpgradeError::module_invalid(kind, module, reason),
10117 UpgradeError::ModuleInvalid {
10118 kind,
10119 module: module.to_string(),
10120 reason: reason.to_string(),
10121 },
10122 "module_invalid ctor must thread kind={kind:?} verbatim",
10123 );
10124 }
10125 }
10126
10127 #[test]
10128 fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
10129 // End-to-end wire-up pin: [`validate_module`]'s
10130 // [`crate::render::require_valid_dns_1123_label`] invalid-arm
10131 // must emit a diagnostic byte-equal to the ctor's output on the
10132 // same `(kind, module)` fixture — the fold's invariant that
10133 // [`validate_module`]'s cascade reaches the
10134 // [`UpgradeError::ModuleInvalid`] envelope through the
10135 // substrate primitive [`UpgradeError::module_invalid`] rather
10136 // than the pre-lift open-coded struct-literal. Sweep every
10137 // [`UpgradeInstruction::declared_module`]-bearing variant
10138 // against a canonical footgun (`"Hello-Rio"` — the uppercase-
10139 // lead footgun the peer `validate_rejects_non_dns_1123_module`
10140 // test above already carries) so every wire-up on the invalid-
10141 // arm cascade lands on the ctor's output. Matches the peer
10142 // sibling end-to-end pin
10143 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
10144 // carries on `validate_contrato_caixa`'s
10145 // `require_valid_dns_1123_label` invalid-arm.
10146 let module = "Hello-Rio";
10147 let cases: &[(UpgradeInstruction, &'static str)] = &[
10148 (
10149 UpgradeInstruction::LoadModule {
10150 module: module.to_string(),
10151 },
10152 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10153 ),
10154 (
10155 UpgradeInstruction::SoftPurge {
10156 module: module.to_string(),
10157 },
10158 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10159 ),
10160 (
10161 UpgradeInstruction::Purge {
10162 module: module.to_string(),
10163 },
10164 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10165 ),
10166 ];
10167 for (instr, expected_kind) in cases {
10168 let observed = instr.validate().unwrap_err();
10169 let UpgradeError::ModuleInvalid {
10170 reason: observed_reason,
10171 ..
10172 } = &observed
10173 else {
10174 panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
10175 };
10176 assert_eq!(
10177 observed,
10178 UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
10179 "validate_module must route its invalid-arm refusal \
10180 through UpgradeError::module_invalid(kind, module, \
10181 reason) on {instr:?}, byte-equal to the pre-lift open-\
10182 coded struct-literal wrap on the same fixture",
10183 );
10184 }
10185 }
10186
10187 #[test]
10188 fn module_empty_ctor_matches_struct_literal_wrap() {
10189 // Fail-before-pass-after equivalence pin on
10190 // [`UpgradeError::module_empty`] — the constructor must produce
10191 // a byte-equal `UpgradeError` to the pre-lift open-coded
10192 // `Self::ModuleEmpty { kind }` struct-literal on the same
10193 // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10194 // axis-tag fixture. A byte-mismatched constructor body (a stray
10195 // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10196 // one of the three canonical arms, a fixed-slot substitution)
10197 // would trip this pin first, matching the sibling
10198 // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10199 // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10200 // pin discipline on the peer one-slot `{ *: &'static str }`
10201 // empty-arm ctor family.
10202 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10203 assert_eq!(
10204 UpgradeError::module_empty(kind),
10205 UpgradeError::ModuleEmpty { kind },
10206 "generated module_empty ctor must produce byte-equal \
10207 UpgradeError to the open-coded struct-literal wrap on the \
10208 same kind fixture",
10209 );
10210 }
10211
10212 #[test]
10213 fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10214 // Cross-axis pin: sweep the constructor's `kind: &'static str`
10215 // input across every [`UpgradeInstruction::declared_module`]-
10216 // bearing variant's canonical
10217 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10218 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10219 // canonical `":phantom"` fourth arm proving the ctor does not
10220 // silently clamp `kind` to the three-arm roster (a future
10221 // fourth `declared_module`-bearing `UpgradeInstruction` variant
10222 // lands on this ctor without a per-arm rewrite). Matches the
10223 // sibling [`Self::module_invalid`] cross-axis sweep at
10224 // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10225 // on the paired three-slot invalid-arm envelope so both arms of
10226 // the [`validate_module`] two-closure cascade carry the same
10227 // axis-invariance guarantee.
10228 for kind in [
10229 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10230 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10231 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10232 ":phantom",
10233 ] {
10234 assert_eq!(
10235 UpgradeError::module_empty(kind),
10236 UpgradeError::ModuleEmpty { kind },
10237 "module_empty ctor must thread kind={kind:?} verbatim",
10238 );
10239 }
10240 }
10241
10242 #[test]
10243 fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10244 // End-to-end wire-up pin: [`validate_module`]'s
10245 // [`crate::render::require_valid_dns_1123_label`] empty-arm
10246 // must emit a diagnostic byte-equal to the ctor's output on the
10247 // same `(kind, "")` fixture — the fold's invariant that
10248 // [`validate_module`]'s cascade reaches the
10249 // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10250 // primitive [`UpgradeError::module_empty`] rather than the
10251 // pre-lift open-coded struct-literal. Sweep every
10252 // [`UpgradeInstruction::declared_module`]-bearing variant
10253 // against the empty-string module value so every wire-up on the
10254 // empty-arm cascade lands on the ctor's output. Closes the pair
10255 // on the [`validate_module`] two-closure cascade the sibling
10256 // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10257 // (3d0d64a) already anchors on the invalid-arm.
10258 let cases: &[(UpgradeInstruction, &'static str)] = &[
10259 (
10260 UpgradeInstruction::LoadModule {
10261 module: String::new(),
10262 },
10263 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10264 ),
10265 (
10266 UpgradeInstruction::SoftPurge {
10267 module: String::new(),
10268 },
10269 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10270 ),
10271 (
10272 UpgradeInstruction::Purge {
10273 module: String::new(),
10274 },
10275 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10276 ),
10277 ];
10278 for (instr, expected_kind) in cases {
10279 assert_eq!(
10280 instr.validate().unwrap_err(),
10281 UpgradeError::module_empty(expected_kind),
10282 "validate_module must route its empty-arm refusal \
10283 through UpgradeError::module_empty(kind) on {instr:?}, \
10284 byte-equal to the pre-lift open-coded struct-literal \
10285 wrap on the same fixture",
10286 );
10287 }
10288 }
10289
10290 /// Fixture roster covering every [`UpgradeInstruction`] arm — a
10291 /// concrete-instance witness per variant so the four
10292 /// canonical-projection-triple pin tests below sweep the same five
10293 /// arms without duplicating the arm-shape declaration at each
10294 /// probe site. A future arm addition (a `Discard` peer the
10295 /// `code:delete/1` analog might inspire, a `SoftPurge` split into
10296 /// `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-down policy
10297 /// grows a two-arm shape) extends this fixture list as a single
10298 /// edit; the pin sweeps below then reach the new arm by iteration
10299 /// rather than a hand-authored per-arm probe.
10300 fn upgrade_instruction_arm_roster() -> Vec<(UpgradeInstruction, &'static str)> {
10301 vec![
10302 (
10303 UpgradeInstruction::LoadModule {
10304 module: "hello-rio".into(),
10305 },
10306 "load-module",
10307 ),
10308 (
10309 UpgradeInstruction::StateChange {
10310 script: std::path::PathBuf::from("lib/migrations/v01-to-v02.lisp"),
10311 },
10312 "state-change",
10313 ),
10314 (
10315 UpgradeInstruction::SoftPurge {
10316 module: "hello-rio-old".into(),
10317 },
10318 "soft-purge",
10319 ),
10320 (
10321 UpgradeInstruction::Purge {
10322 module: "hello-rio-old".into(),
10323 },
10324 "purge",
10325 ),
10326 (UpgradeInstruction::Restart, "restart"),
10327 ]
10328 }
10329
10330 #[test]
10331 fn upgrade_instruction_as_str_returns_canonical_kebab_wire_bytes() {
10332 // Fail-before-pass-after pin on the [`UpgradeInstruction::as_str`]
10333 // canonical-projection accessor: the five match arms each return
10334 // the un-prefixed kebab wire byte-string every serde-carried CR /
10335 // structured-log / fleet-catalog identity consumer converges onto.
10336 // A future variant rename or a per-arm typo (e.g. dropping the
10337 // hyphen from `"load-module"` → `"loadmodule"`) trips at
10338 // caixa-core test time rather than surfacing as a downstream K8s-
10339 // CR round-trip miss where the paired `Deserialize` derive
10340 // rejects the drifted arm on every apply.
10341 for (variant, expected) in upgrade_instruction_arm_roster() {
10342 assert_eq!(
10343 variant.as_str(),
10344 expected,
10345 "UpgradeInstruction::{variant:?}.as_str() must return the \
10346 canonical un-prefixed kebab wire byte-string"
10347 );
10348 }
10349 }
10350
10351 #[test]
10352 fn upgrade_instruction_as_str_matches_discriminant_derive() {
10353 // Load-bearing pin on the two-source alignment: the hand-authored
10354 // [`UpgradeInstruction::as_str`] match arms must byte-equal the
10355 // [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
10356 // per-arm output for every variant. `.discriminant()` is the
10357 // fleet-wide dispatcher-catalog identity (registered under
10358 // `"caixa.upgrade-instruction"` by the sibling
10359 // `gen_platform::register_dispatcher!` macro invocation at
10360 // upgrade.rs:88); [`Self::as_str`] is the standard-library
10361 // `AsRef<str>` / [`std::fmt::Display`]-routed diagnostic byte-
10362 // string. Both must stay aligned so a consumer that reaches
10363 // through either path lands on the same per-arm byte-string.
10364 // A future rename on either side (a per-arm serde-attribute
10365 // drift silently splitting the derive's kebab output from the
10366 // hand-authored arms, a hand-authored typo on the [`Self::as_str`]
10367 // match arm silently splitting the standard-library-routed path
10368 // from the catalog identity) trips here at caixa-core test time
10369 // rather than as a divergent per-consumer dispatch at some future
10370 // downstream site.
10371 for (variant, _expected) in upgrade_instruction_arm_roster() {
10372 assert_eq!(
10373 variant.as_str(),
10374 variant.discriminant(),
10375 "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10376 the gen_platform::Discriminant-derived discriminant() \
10377 output — the two axes are the substrate's kebab-case wire \
10378 identity and must stay aligned by construction"
10379 );
10380 }
10381 }
10382
10383 #[test]
10384 fn upgrade_instruction_as_str_matches_serialize_wire_kind_tag() {
10385 // Load-bearing pin on the derive-to-hand alignment on the *wire*
10386 // axis: the hand-authored [`UpgradeInstruction::as_str`] match
10387 // arms must byte-equal the JSON tag the un-`rename`d
10388 // `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
10389 // emits under the paired
10390 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag key.
10391 // A future accidental `rename_all = "snake_case"` /
10392 // `"UPPERCASE"` attribute drift at the derive surface, or a
10393 // per-variant `#[serde(rename = "…")]` overlay silently
10394 // targeting one arm, would silently split the wire byte-shape
10395 // every K8s-CR / tatara-lisp round-trip / fleet-catalog
10396 // consumer reads through the two paths — pinning the identity
10397 // here makes any such drift a caixa-core-test-time failure.
10398 // Sibling in shape to
10399 // [`crate::kind::tests::caixa_kind_wire_name_matches_serialize_wire_byte_string`]
10400 // on the top-level [`crate::CaixaKind`] axis.
10401 for (variant, _expected) in upgrade_instruction_arm_roster() {
10402 let json = serde_json::to_value(&variant).expect("serialize must succeed");
10403 let kind_tag = json
10404 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
10405 .and_then(serde_json::Value::as_str)
10406 .unwrap_or_else(|| {
10407 panic!(
10408 "serialized UpgradeInstruction::{variant:?} must \
10409 carry the M2_UPGRADE_INSTRUCTION_KEY_KIND tag as \
10410 a JSON string"
10411 )
10412 });
10413 assert_eq!(
10414 variant.as_str(),
10415 kind_tag,
10416 "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10417 the serde-derived JSON \"kind\" tag — a mismatch means \
10418 either the derive attributes drifted or the as_str match \
10419 arms drifted; either way downstream K8s-CR round-trip \
10420 silently splits from the accessor-routed source of truth"
10421 );
10422 }
10423 }
10424
10425 #[test]
10426 fn upgrade_instruction_display_routes_through_as_str_helper() {
10427 // Fail-before-pass-after pin on the two-path convergence: pre-
10428 // lift [`UpgradeInstruction`] carried no [`std::fmt::Display`]
10429 // surface at all — every consumer past the wire format had to
10430 // pick between [`Self::lisp_form`] returning the tatara-lisp
10431 // author-surface with `:` prefix or `format!("{v:?}")` on the
10432 // `Debug` derive returning the PascalCase variant name plus
10433 // struct-literal fields. Wiring [`std::fmt::Display`] through
10434 // [`Self::as_str`] closes the drift footgun: every
10435 // `format!("{v}")` call reaches the same kebab wire byte-string
10436 // the [`Self::as_str`] helper returns, so a future variant
10437 // rename lands at exactly one place. Pin the routing here so a
10438 // future `impl std::fmt::Display for UpgradeInstruction`
10439 // reimplementation that hand-rolls the arms instead of
10440 // delegating to [`Self::as_str`] fails at caixa-core build
10441 // time. Peer of the sibling
10442 // [`crate::supervisor::tests::restart_strategy_display_routes_through_as_str_helper`]
10443 // /
10444 // [`crate::supervisor::tests::restart_policy_display_routes_through_as_str_helper`]
10445 // /
10446 // [`crate::kind::tests::caixa_kind_display_routes_through_as_str_helper`]
10447 // pins on the sibling closed-set typed-enum discriminator axes.
10448 for (variant, _expected) in upgrade_instruction_arm_roster() {
10449 assert_eq!(
10450 variant.to_string(),
10451 variant.as_str(),
10452 "UpgradeInstruction::{variant:?} Display must route \
10453 through UpgradeInstruction::as_str (single source of \
10454 truth: the kebab wire byte-string per arm)"
10455 );
10456 }
10457 }
10458
10459 #[test]
10460 fn upgrade_instruction_display_matches_as_str_and_not_lisp_form() {
10461 // Two-axis-split pin: the tatara-lisp author-surface form
10462 // ([`UpgradeInstruction::lisp_form`], with `:` prefix) and the
10463 // wire form ([`UpgradeInstruction::as_str`], without `:`
10464 // prefix) are structurally distinct by design. The pin here
10465 // makes the split load-bearing: a future accidental collapse
10466 // of either axis onto the other (routing `Display` through
10467 // [`Self::lisp_form`] via a mistaken match-arm re-inlining, or
10468 // routing [`Self::lisp_form`] through [`Self::as_str`] and
10469 // dropping the `:` prefix) would trip here at caixa-core
10470 // build time rather than silently merging the two axes at
10471 // some future consumer's per-instruction dispatch step. Peer
10472 // of the sibling
10473 // [`crate::kind::tests::caixa_kind_display_matches_as_str_and_not_serialize_wire`]
10474 // pin on the top-level [`crate::CaixaKind`] two-axis surface.
10475 for (variant, _expected) in upgrade_instruction_arm_roster() {
10476 let display = variant.to_string();
10477 let lisp = variant.lisp_form();
10478 assert_eq!(
10479 display,
10480 variant.as_str(),
10481 "UpgradeInstruction::{variant:?} Display must byte-equal \
10482 as_str (kebab wire form, no `:` prefix)"
10483 );
10484 assert_ne!(
10485 display, lisp,
10486 "UpgradeInstruction::{variant:?} Display / as_str (wire \
10487 kebab form) must stay structurally distinct from \
10488 lisp_form (tatara-lisp author-surface with `:` prefix) — \
10489 collapsing the two axes would break the tatara-lisp \
10490 grep-and-fix workflow that keys off the `:` prefix"
10491 );
10492 assert!(
10493 lisp.starts_with(':'),
10494 "UpgradeInstruction::{variant:?}.lisp_form() must open \
10495 with a `:` prefix (tatara-lisp author-surface form)"
10496 );
10497 assert!(
10498 !display.starts_with(':'),
10499 "UpgradeInstruction::{variant:?} Display must not open \
10500 with a `:` prefix (wire form is un-prefixed kebab-case)"
10501 );
10502 }
10503 }
10504
10505 #[test]
10506 fn upgrade_instruction_as_ref_str_routes_through_as_str_accessor() {
10507 // Byte-parity pin on the standard-library `impl AsRef<str>`
10508 // route: every arm's `<UpgradeInstruction as
10509 // AsRef<str>>::as_ref(&v)` must byte-equal `v.as_str()`. Any
10510 // future silent detour that routes the impl through a
10511 // divergent projection (a per-arm inline `match self { … }`
10512 // re-inlining that opens a compile-time link to the un-lifted
10513 // arm-literal, a swap onto [`Self::lisp_form`] that would
10514 // collide the wire axis with the tatara-lisp author-surface
10515 // axis) trips here at caixa-core test time rather than at a
10516 // downstream `impl AsRef<str>`-bound consumer's silent split.
10517 // Peer of the sibling
10518 // [`crate::supervisor::tests::restart_strategy_as_ref_str_routes_through_as_str_accessor`]
10519 // /
10520 // [`crate::kind::tests::caixa_kind_as_ref_str_routes_through_as_str_accessor`]
10521 // pins.
10522 for (variant, _expected) in upgrade_instruction_arm_roster() {
10523 assert_eq!(
10524 <UpgradeInstruction as AsRef<str>>::as_ref(&variant),
10525 variant.as_str(),
10526 "UpgradeInstruction::{variant:?} AsRef<str> must route \
10527 through UpgradeInstruction::as_str"
10528 );
10529 }
10530 }
10531
10532 #[test]
10533 fn upgrade_instruction_as_str_is_const_fn() {
10534 // Const-context pin: [`UpgradeInstruction::as_str`] must remain
10535 // `const fn`. Downstream consumers reaching for the accessor
10536 // from a `const` context (a module-scope `const _:() =
10537 // assert!(<variant>.as_str().len() > 0)` invariant pin, a
10538 // `const fn` per-instruction wire-shape audit table an M4
10539 // admission webhook materializes at build time) rely on the
10540 // const-ness. A future accidental downgrade to non-`const`
10541 // (an added runtime helper reachable only from a non-`const`
10542 // context, a manual hand-rolled `impl` that shadows this
10543 // method) trips at caixa-core build time rather than
10544 // surfacing as a downstream `const`-context regression far
10545 // from the accessor declaration. Peer of the sibling
10546 // [`crate::kind::tests::caixa_kind_wire_name_is_const_fn`] pin
10547 // on the top-level [`crate::CaixaKind`] axis.
10548 const RESTART_WIRE: &str = UpgradeInstruction::Restart.as_str();
10549 assert_eq!(RESTART_WIRE, "restart");
10550 }
10551}