caixa_core/upgrade.rs
1//! Erlang/OTP-style appup — declarative upgrade instructions per
2//! prior caixa version. Composes with the `:behavior :on-state-change`
3//! callback to deliver state migration during hot upgrades.
4//!
5//! See `theory/INSPIRATIONS.md` §II.4 for the prior-art frame.
6//!
7//! ```lisp
8//! (defcaixa
9//! :nome "hello-rio"
10//! :versao "0.2.0"
11//! :upgrade-from
12//! ((:from "0.1.0"
13//! :instructions ((:load-module "hello-rio")
14//! (:state-change "lib/migrations/v01-to-v02.lisp")
15//! (:soft-purge "hello-rio-old")))
16//! (:from "0.1.5"
17//! :instructions ((:load-module "hello-rio")
18//! (:soft-purge "hello-rio-old")))))
19//! ```
20//!
21//! Each `(:from <prior>)` block declares the upgrade path *from* that
22//! version *to* the current `:versao`. wasm-operator picks the
23//! matching block at upgrade time, runs the instructions in order,
24//! and only swaps traffic to the new instance after all instructions
25//! succeed (transactional upgrade). On any failure, the current
26//! version stays load-bearing — a typed atomic upgrade.
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// One upgrade instruction. The set mirrors OTP's appup low-level
34/// instructions: enough to express every common upgrade pattern,
35/// few enough that the wasm-operator can implement each
36/// deterministically.
37#[derive(
38 Serialize,
39 Deserialize,
40 Debug,
41 Clone,
42 PartialEq,
43 Eq,
44 gen_platform::TypedDispatcher,
45 gen_platform::Discriminant,
46 gen_platform::IsVariant,
47)]
48#[serde(tag = "kind", rename_all = "kebab-case")]
49pub enum UpgradeInstruction {
50 /// Load a new wasm module alongside the current one — the analog
51 /// of OTP's `code:load_module/1`. Both versions remain in memory
52 /// after this instruction; in-flight requests stay on the old
53 /// version, new requests route to the new version.
54 LoadModule { module: String },
55
56 /// Run a state-migration tatara-lisp file. Receives the old state
57 /// + the prior version string; returns the new state. Analog of
58 /// `gen_server:code_change/3`.
59 StateChange { script: PathBuf },
60
61 /// Wait for in-flight requests on a named module to drain, then
62 /// GC it — the analog of `code:soft_purge/1`. Default cooldown is
63 /// 60s; longer-running requests block the upgrade.
64 SoftPurge { module: String },
65
66 /// Discard a named module immediately, without waiting for
67 /// drain — the analog of `code:purge/1`. Used when we don't
68 /// care about in-flight callers (cron, oneShot).
69 Purge { module: String },
70
71 /// Fall back to a full restart for this entry. Used when a typed
72 /// upgrade is impossible (e.g. wasm component world incompatible).
73 Restart,
74}
75
76// Fleet-wide dispatcher-catalog registration. UpgradeInstruction is
77// the OTP-style hot-upgrade primitive (load_module/code_change/
78// soft_purge/purge/restart) — the first NON-ADAPTER consumer of
79// gen-platform's typed-dispatcher catamorphism, satisfying the ★★
80// "two classes of consumer" promotion criterion from
81// theory/QUIRK-APPLIER.md §V.1.
82//
83// Operators query via:
84// gen dispatchers --from-catalog | jq '.[] | select(.label=="caixa.upgrade-instruction")'
85//
86// The substrate's lib/build/shared/fleet-catalog-coverage-test.nix
87// adds an assertion row for this label on the next snapshot refresh.
88gen_platform::register_dispatcher!("caixa.upgrade-instruction", UpgradeInstruction);
89
90/// One upgrade entry: the *prior* version we're upgrading from, plus
91/// the instruction sequence to execute.
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct UpgradeFromEntry {
95 /// Semver of the *prior* version. Authored as a literal string;
96 /// validated lazily by [`UpgradeFromEntry::validate`].
97 pub from: String,
98
99 /// Ordered list of instructions to execute. Empty list = "no-op
100 /// upgrade" (rare; usually means only documentation changed).
101 #[serde(default)]
102 pub instructions: Vec<UpgradeInstruction>,
103}
104
105impl UpgradeFromEntry {
106 /// Prior-versao semver-2 literal this entry declares an upgrade
107 /// path *from* — the string the OTP-shape `release_handler:install_release/1`
108 /// analog matches the running caixa's `:versao` against at hot-
109 /// upgrade dispatch time to pick this entry's `:instructions`
110 /// sequence. Returned byte-for-byte from the typed slot's own
111 /// `String` storage; no cloning, no re-parsing.
112 ///
113 /// The M2 companion of the closed M3 mesh-slot scalar-accessor
114 /// family — sibling in shape to [`crate::Membro::versao_requirement`]
115 /// (a40b0e3), [`crate::Membro::nome`] (4a32abf), and the
116 /// [`crate::WitContract::{source, destination, world_ref}`]
117 /// (7f0fd43 / 0804823) / [`crate::Entrada::{hostname, destination}`]
118 /// (11f3dfe / 6db982c) `&str` accessors already routing every
119 /// per-mesh-slot-atom scalar-value axis through one typed dispatch
120 /// on the substrate primitive — extended here onto the first per-
121 /// M2-slot scalar-value axis. Every downstream consumer of the
122 /// M2 `:upgrade-from :from` axis (the [`UpgradeFromEntry::validate`]
123 /// SemVer-2 parse gate, the [`validate_upgrade_from`] cross-entry
124 /// duplicate-detection re-parse assertion, the
125 /// [`validate_upgrade_from_against_versao`] precedence gate,
126 /// the [`validate_upgrade_from_against_behavior`] state-change-
127 /// callback coherence gate, every per-arm error variant carrying
128 /// the offending `:from` verbatim for `feira lint` rendering)
129 /// now reads through this one accessor rather than open-coding
130 /// `&self.from` / `&entry.from` / `self.from.clone()` /
131 /// `entry.from.clone()`.
132 ///
133 /// A future extension of the axis (an M4 typed `:from`-range slot
134 /// composing multiple prior versions into one entry, an operator-
135 /// side pre-parsed [`semver::Version`] cache the accessor could
136 /// materialize behind the same `&str` return contract, a per-
137 /// cluster `:placement`-scoped prior-versao overlay the
138 /// `caixa-operator` reconciles ahead of dispatch) migrates as a
139 /// single caixa-core edit rather than a coordinated rewrite of
140 /// the four validate-side call sites + every downstream error-
141 /// variant carrying `:from`.
142 #[must_use]
143 pub const fn prior_versao(&self) -> &str {
144 self.from.as_str()
145 }
146
147 /// Substrate-canonical per-`:upgrade-from :instructions`
148 /// OTP-appup migration-instruction-list slice-return accessor
149 /// every per-entry instructions-list reader keys off — returns
150 /// the author-declared `:instructions` list verbatim as a
151 /// `&[UpgradeInstruction]` slice-view over the same backing
152 /// buffer the raw `self.instructions.as_slice()` field access
153 /// borrows from. Non-optional: an empty slice is the load-bearing
154 /// "author declared `:instructions ()`" sentinel — the
155 /// `Vec<UpgradeInstruction>::default()`-produced empty tail the
156 /// [`UpgradeFromEntry::instructions`] field's own docstring already
157 /// names as the "no-op upgrade" shape (a metadata-only upgrade
158 /// entry — the operator's `:from`-match dispatch matches the entry
159 /// but runs no instructions, advancing straight to the "traffic
160 /// swap" step) and every peer within-entry cross-instruction gate
161 /// no-ops against without allocating a new `Vec` per gate.
162 ///
163 /// The `:upgrade-from :instructions` slot carries the per-`:from`
164 /// OTP-appup ordered instruction list the wasm-operator's hot-
165 /// upgrade dispatch materializes one per-instruction runtime
166 /// primitive from — the Erlang/OTP appup's per-`{from, to,
167 /// UpgradeInstructions, DowngradeInstructions}` entry's
168 /// `UpgradeInstructions` list (`code:load_module/1` /
169 /// `gen_server:code_change/3` / `code:soft_purge/1` /
170 /// `code:purge/1` / `restart_new_emulator` — see INSPIRATIONS
171 /// §II.4), projected through the tatara-lisp
172 /// `:upgrade-from ((:from … :instructions …))` author surface
173 /// onto a typed `Vec<UpgradeInstruction>` whose per-element
174 /// variant is [`UpgradeInstruction::LoadModule`] /
175 /// [`UpgradeInstruction::StateChange`] /
176 /// [`UpgradeInstruction::SoftPurge`] / [`UpgradeInstruction::Purge`]
177 /// / [`UpgradeInstruction::Restart`]. Every downstream consumer
178 /// that fans on the per-entry instruction list keys off this
179 /// slice (the [`UpgradeFromEntry::validate`] per-instruction
180 /// shape-check fan-out, the seven paired within-entry cross-
181 /// instruction gates [`Self::validate_restart_exclusive`] /
182 /// [`Self::validate_state_change_ordering`] /
183 /// [`Self::validate_purge_ordering`] /
184 /// [`Self::validate_state_change_before_cleanup`] /
185 /// [`Self::validate_load_singularity`] /
186 /// [`Self::validate_state_change_singularity`] /
187 /// [`Self::validate_cleanup_singularity`], the layout-side
188 /// [`crate::layout::StandardLayout`]'s per-`:state-change`
189 /// script-existence fan-out
190 /// ([`crate::layout::LayoutError::MissingEntry`]'s
191 /// `LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT` arm), the cross-slot
192 /// [`validate_upgrade_from_against_behavior`] gate's per-entry
193 /// `:state-change`-instruction detection loop, every future
194 /// wasm-operator (M2.5) per-`:from`-match hot-upgrade dispatch's
195 /// per-instruction runtime-primitive fan-out, every future M4
196 /// `mesh.pleme.io/v1alpha1/Caixa` CR materializer's per-entry
197 /// upgrade-plan admission-webhook fan-out).
198 ///
199 /// Prior to this lift the `.instructions` `Vec<UpgradeInstruction>`
200 /// was accessed inline at nine production sites across
201 /// `caixa-core/src/upgrade.rs` and `caixa-core/src/layout.rs` —
202 /// the [`UpgradeFromEntry::validate`] per-instruction shape-check
203 /// fan-out (`for instr in &self.instructions`), the paired
204 /// [`Self::validate_restart_exclusive`] restart-count / other-kind
205 /// projections + `.len()` probe (three raw-access sites in one
206 /// gate), the [`Self::validate_state_change_ordering`] /
207 /// [`Self::validate_purge_ordering`] /
208 /// [`Self::validate_state_change_before_cleanup`] /
209 /// [`Self::validate_load_singularity`] /
210 /// [`Self::validate_state_change_singularity`] /
211 /// [`Self::validate_cleanup_singularity`] within-entry cross-
212 /// instruction gate traversal heads, the peer
213 /// [`validate_upgrade_from_against_behavior`] cross-slot
214 /// composition gate's `for instr in &entry.instructions`
215 /// per-entry `:state-change` detection loop, and the
216 /// [`crate::layout::StandardLayout`]-side
217 /// `for instr in &entry.instructions` per-`:state-change`
218 /// script-existence fan-out — nine open-coded field-accesses
219 /// that expressed no compile-time link back to the typed slot.
220 /// A future extension of the `:instructions` axis to a richer
221 /// author surface (a per-cluster overlay the operator pins
222 /// through a future `:upgrade-from :instructions-overrides` slot
223 /// so a canary cluster runs a `(:state-change …)` before the
224 /// production fleet does, a per-tenant instruction-list overlay
225 /// the M4 CR materializer resolves per-CR to inject cluster-
226 /// specific `(:soft-purge …)` cooldown adjustments, a promotion
227 /// of the plain `Vec<UpgradeInstruction>` to a richer
228 /// `{static, dynamic}` partition once virtual-actor-style
229 /// dynamic-instruction composition (an operator-derived
230 /// `(:load-module …)` sequence computed from the running
231 /// module set at upgrade time) comes into typed scope, a
232 /// per-instruction pre-condition scalar the future adaptive-
233 /// upgrade engine reads to bias per-instruction retry
234 /// strategy) would have had to be threaded through all nine
235 /// open-coded copies in lockstep or one consumer would silently
236 /// disagree with the peers on which instruction sequence a
237 /// given `:upgrade-from` entry resolves to — the per-
238 /// instruction shape-check reading the raw slot while the
239 /// paired within-entry ordering gates read an operator-resolved
240 /// slot would silently split the build-time per-entry gate
241 /// cohort from the layout-side script-existence gate + the
242 /// cross-slot behavior-composition gate + the runtime hot-
243 /// upgrade dispatch, a nine-consumer split across the seven
244 /// within-entry cross-instruction gates + the layout invariant +
245 /// the cross-slot composition gate far from the source
246 /// `caixa.lisp` with no field naming the instruction-sequence-
247 /// drift root cause. Lifting the resolution rule to a typed
248 /// method on the substrate primitive means every downstream
249 /// consumer of the per-entry OTP-appup instruction-list surface
250 /// reaches for exactly one typed dispatch — the resolver's
251 /// accept-set migrates as a unit on any future axis addition.
252 ///
253 /// Fifth slice-return (`&[T]`) accessor on any M2 or M3 typed
254 /// slot — sibling to the seed M2
255 /// [`crate::SupervisorSpec::children`] (bc92bce) `&[ChildSpec]`
256 /// accessor on the peer per-`:supervisor` static-child-list
257 /// `Vec`-carry axis, the M3 [`crate::Placement::clusters`]
258 /// (a6e18d7) `&[String]` accessor on the peer per-`:placement`
259 /// distribution-target-list `Vec`-carry axis, the M3
260 /// [`crate::AplicacaoSpec::membros`] (6c77e36) `&[Membro]`
261 /// accessor on the peer per-`:membros` node-list `Vec`-carry
262 /// axis, and the M3 [`crate::AplicacaoSpec::contratos`]
263 /// (0dcc926) `&[WitContract]` accessor on the peer per-
264 /// `:contratos` edge-list `Vec`-carry axis. This lift closes the
265 /// last unlifted `Vec`-carry axis on any M2 or M3 typed slot in
266 /// the substrate — the four peer axes named in the
267 /// [`crate::SupervisorSpec::children`] seed docstring
268 /// (`Placement::clusters`, `AplicacaoSpec::membros`,
269 /// `AplicacaoSpec::contratos`, `UpgradeFromEntry::instructions`)
270 /// are now all closed. The per-`UpgradeFromEntry` type carried
271 /// two axes: the scalar `Copy`-return
272 /// [`UpgradeFromEntry::prior_versao`] (75d27a8) on the
273 /// `:from` axis, and now the slice-return
274 /// [`UpgradeFromEntry::instructions`] on the peer
275 /// `:instructions` axis. Named `instructions()` to match the
276 /// storage field's name verbatim and the tatara-lisp
277 /// author-surface term (`:instructions`) the field's own
278 /// docstring already carries; the accessor's identity maps
279 /// onto the canonical OTP-appup vocabulary the
280 /// [`crate::upgrade`] module doc already reaches for ("runs
281 /// the instructions in order"). Returns `&[UpgradeInstruction]`
282 /// (not `&Vec<UpgradeInstruction>`) because every downstream
283 /// consumer of the instruction list treats it as a read-only
284 /// sequence — the slice-view is the narrowest borrow that
285 /// supports every present + roadmapped consumer (`.iter()`,
286 /// `.len()`, `.filter(...).count()`) without leaking the
287 /// backing `Vec`'s grow/push/reserve surface that no consumer
288 /// of the typed view reaches for (the storage-side `Vec`
289 /// remains reachable through the `pub instructions` field for
290 /// the mutation-carrying `Serialize`/`Deserialize` derive
291 /// round-trip and per-test fixture-mutation paths).
292 #[must_use]
293 pub const fn instructions(&self) -> &[UpgradeInstruction] {
294 self.instructions.as_slice()
295 }
296
297 /// Verify the `:from` field is a valid semver, every instruction's
298 /// typed shape, the within-entry `(:restart)`-exclusivity invariant
299 /// (an entry containing `(:restart)` must contain exactly one
300 /// `(:restart)` and nothing else — see
301 /// [`Self::validate_restart_exclusive`]), the within-entry
302 /// state-change-ordering invariant (every `(:state-change …)` must
303 /// be preceded by a `(:load-module …)` — see
304 /// [`Self::validate_state_change_ordering`]), the within-entry
305 /// purge-ordering invariant (every `(:soft-purge …)` / `(:purge …)`
306 /// must be preceded by a `(:load-module …)` — see
307 /// [`Self::validate_purge_ordering`]), the within-entry
308 /// state-change-before-cleanup ordering invariant (no
309 /// `(:state-change …)` may appear after any `(:soft-purge …)` /
310 /// `(:purge …)` — see
311 /// [`Self::validate_state_change_before_cleanup`]), the within-
312 /// entry load-singularity invariant (no module appears as the
313 /// target of `(:load-module …)` more than once — see
314 /// [`Self::validate_load_singularity`]), the within-entry
315 /// state-change-singularity invariant (no script appears as the
316 /// target of `(:state-change …)` more than once — see
317 /// [`Self::validate_state_change_singularity`]), and the within-
318 /// entry cleanup-singularity invariant (no module appears as the
319 /// target of `(:soft-purge …)` or `(:purge …)` more than once
320 /// total — see [`Self::validate_cleanup_singularity`]).
321 pub fn validate(&self) -> Result<(), UpgradeError> {
322 use semver::Version;
323 Version::parse(self.prior_versao())
324 .map_err(|e| UpgradeError::from_invalid(self.prior_versao(), &e.to_string()))?;
325 // Per-instruction typed shape: kind-tagged `:module` /
326 // `:script` value-shape gates fire here, *before* the
327 // within-entry restart-exclusivity gate below — so a
328 // malformed-shape diagnostic on a Module/Script-bearing
329 // instruction surfaces with its narrower self-locating
330 // wording (`ModuleEmpty`, `ModuleInvalid`, `EmptyScript`,
331 // `AbsoluteScript`, `ParentEscapeScript`) rather than
332 // collapsing two unrelated authoring errors into a single
333 // exclusivity diagnostic. Same empty-first cascade discipline
334 // every peer DNS-1123 / path-shape gate inside this module
335 // uses (`validate_module`'s ModuleEmpty arm precedes the
336 // DNS-1123 predicate; `validate` on `StateChange` consults
337 // the lifted `is_sandboxed_relative_path` shape gate first).
338 // Route the per-instruction shape-check fan-out through the
339 // lifted [`Self::instructions`] slice-return accessor rather
340 // than the raw `self.instructions` field access — first of
341 // nine paired production consumers of the per-`:upgrade-from
342 // :instructions` OTP-appup migration-instruction-list surface
343 // that now key off exactly one typed dispatch on the substrate
344 // primitive.
345 for instr in self.instructions() {
346 instr.validate()?;
347 }
348 self.validate_restart_exclusive()?;
349 self.validate_state_change_ordering()?;
350 self.validate_purge_ordering()?;
351 self.validate_state_change_before_cleanup()?;
352 self.validate_load_singularity()?;
353 self.validate_state_change_singularity()?;
354 self.validate_cleanup_singularity()?;
355 Ok(())
356 }
357
358 /// Reject `:upgrade-from :instructions` lists that carry
359 /// `(:restart)` alongside any other instruction, or that carry
360 /// more than one `(:restart)`. The valid Restart-bearing shape is
361 /// exactly `((:restart))` — a single `Restart` as the entry's
362 /// whole instructions list.
363 ///
364 /// Per [`UpgradeInstruction::Restart`]'s doc comment, `(:restart)`
365 /// is the *fallback* for an entry whose typed upgrade is
366 /// impossible (wasm component-model world incompatibility,
367 /// irreversible state shape change). The fallback is terminal by
368 /// construction: the operator restarts the pod and the new version
369 /// comes up fresh, so any other instructions in the same entry
370 /// are dead code in both directions — either the typed sequence
371 /// would have succeeded and `(:restart)` is unreached, or it
372 /// wouldn't and the typed instructions are dead because the
373 /// operator restarts anyway. Two canonical authoring footguns
374 /// close here:
375 ///
376 /// - `((:load-module …) (:state-change …) (:restart))` — the
377 /// "I'll try the typed path *then* restart anyway" footgun.
378 /// There is no coherent OTP-shaped semantic for this: if the
379 /// typed sequence succeeds, the trailing restart discards the
380 /// work that just succeeded (defeating the whole point of
381 /// declaring it); if it fails, the restart is never reached
382 /// because the entry already failed.
383 /// - `((:restart) (:restart))` — multiple `Restart` variants in
384 /// one entry. The fallback is a single semantic; repeating it
385 /// is at best redundant, at worst suggests the author thought
386 /// the second one would re-trigger after the first.
387 ///
388 /// Same within-entry exclusivity discipline OTP's `relup` enforces
389 /// at the `restart_new_emulator | restart_emulator` instruction
390 /// boundary — those instructions are terminal in the upgrade
391 /// script (`systools(3)` rejects sequences that continue past
392 /// them); pleme-io lifts the same shape to a build-time gate,
393 /// matching the CAIXA-SDLC §III "build errors, not runtime
394 /// surprises" frame.
395 ///
396 /// Same within-entry cross-instruction discipline the
397 /// [`crate::AplicacaoSpec::validate_placement`] strategy ↔
398 /// shard-key partition (934bc58) and
399 /// [`validate_upgrade_from_against_versao`]'s `:from` ↔ `:versao`
400 /// precedence partition (de7ab1a) apply on cross-slot axes — now
401 /// extended onto the first within-list cross-instruction axis on
402 /// the `:upgrade-from` typed slot.
403 fn validate_restart_exclusive(&self) -> Result<(), UpgradeError> {
404 // Route the paired restart-count / instructions-len / other-
405 // kind projections through the lifted [`Self::instructions`]
406 // slice-return accessor rather than the raw `self.instructions`
407 // field access — three raw-access sites in one gate collapse
408 // onto exactly one typed dispatch on the substrate primitive.
409 //
410 // The paired positive / negated `Self::Restart` arm-discriminator
411 // predicates route through the `gen_platform::IsVariant`
412 // derive-generated [`UpgradeInstruction::is_restart`] rather than
413 // the raw `matches!(i, UpgradeInstruction::Restart)` /
414 // `!matches!(i, UpgradeInstruction::Restart)` open-coded pattern-
415 // matches — same closed-set-typed-enum arm-discriminator dispatch
416 // discipline the sibling [`crate::CaixaKind`] `IsVariant` derive
417 // (f5bba80) extended onto its ten `caixa.kind() == CaixaKind::X`
418 // / `!= CaixaKind::X` production sites in the substrate's own
419 // layout invariant verifier + typed-view projection gates,
420 // extended here onto the last unlifted `matches!`-based
421 // arm-discriminator axis on the [`UpgradeInstruction`] closed-set
422 // typed enum. A future sixth `UpgradeInstruction` arm (an
423 // adaptive-upgrade-shaped `AwaitReadiness` gate the M2.5
424 // wasm-operator's hot-upgrade runtime could adopt to bracket the
425 // typed instruction sequence against a per-cluster readiness
426 // probe, a `Downgrade` variant OTP's `relup` acknowledges on the
427 // reverse axis, a `CanaryTraffic` split-traffic variant the M4 CR
428 // materializer could resolve per-CR) migrates as a single
429 // enum-declaration edit — the derive auto-generates the paired
430 // `.is_<new_arm>()` predicate; every consumer inherits the new
431 // arm on the next re-derive, rather than the two `matches!` sites
432 // here having to be threaded through in lockstep.
433 let instructions = self.instructions();
434 let restart_count = instructions.iter().filter(|i| i.is_restart()).count();
435 if restart_count == 0 {
436 return Ok(());
437 }
438 if restart_count == 1 && instructions.len() == 1 {
439 return Ok(());
440 }
441 let other_kinds: Vec<&'static str> = instructions
442 .iter()
443 .filter(|i| !i.is_restart())
444 .map(UpgradeInstruction::lisp_form)
445 .collect();
446 Err(UpgradeError::restart_not_exclusive(
447 self.prior_versao(),
448 restart_count,
449 other_kinds,
450 ))
451 }
452
453 /// Reject an entry whose `(:state-change …)` is not preceded by a
454 /// `(:load-module …)` in the same `:instructions` list.
455 ///
456 /// `StateChange` is the `gen_server:code_change/3` analog
457 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4):
458 /// it runs the migration script that folds the *old* state into the
459 /// shape the *new* code expects. In OTP, `code_change/3` is invoked
460 /// in the context of the newly-loaded code — `release_handler`
461 /// always loads the new module before running the advanced update
462 /// that triggers the callback. caixa decomposes that into two
463 /// explicit instructions (`LoadModule` brings the new version up
464 /// "alongside the current one"; `StateChange` migrates the state),
465 /// and the module doc pins that the operator "runs the instructions
466 /// in order" and only swaps traffic after all succeed. So a
467 /// `:state-change` with no preceding `:load-module` migrates state
468 /// into code that was never loaded — the migration script runs while
469 /// the only resident version is still the *old* one, which expects
470 /// the *old* state. Two authoring footguns close here:
471 ///
472 /// - `((:state-change "…"))` — the "I wrote the migration but
473 /// forgot to load the new module" footgun. The new code that
474 /// defines the new state representation (and that the migration
475 /// output is destined for) never comes up; the operator runs
476 /// the script against the old code and either no-ops or corrupts
477 /// live state.
478 /// - `((:state-change "…") (:load-module "…"))` — the
479 /// right-instructions-wrong-order footgun. Because the operator
480 /// executes in declared order, the migration runs *before* the
481 /// new code is resident, then the load brings up code expecting
482 /// already-migrated state that the just-run script produced
483 /// against the old version's shape. The canonical order is
484 /// `(:load-module …) (:state-change …) (:soft-purge …)`
485 /// (module doc example).
486 ///
487 /// Same within-entry cross-instruction discipline as
488 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
489 /// exclusivity gate it runs beside): both reject an
490 /// `:instructions` list whose instructions are individually
491 /// well-shaped but jointly incoherent, at the typed build surface
492 /// rather than as a runtime surprise. Runs *after*
493 /// `validate_restart_exclusive` so a `((:state-change …)
494 /// (:restart))` shape still surfaces the more-fundamental
495 /// `RestartNotExclusive` (a valid `(:restart)` entry is `(:restart)`
496 /// alone, so no Restart-bearing entry reaches this gate carrying a
497 /// `StateChange`).
498 fn validate_state_change_ordering(&self) -> Result<(), UpgradeError> {
499 // Route the per-instruction load-family arm-discriminator through
500 // the `gen_platform::IsVariant`-derive-generated
501 // [`UpgradeInstruction::is_load_module`] predicate and the
502 // per-instruction migration-family `:script` scalar projection
503 // through the sibling lifted [`UpgradeInstruction::declared_path`]
504 // `Option<&PathBuf>` accessor rather than the raw two-arm
505 // `match instr { UpgradeInstruction::LoadModule { .. } =>
506 // loaded = true, UpgradeInstruction::StateChange { script } if
507 // !loaded => …, _ => {} }` open-coded pattern-match — closes the
508 // last unlifted `match`-shaped per-arm-hand-rolled load-family
509 // arm-discriminator + migration-family script-projection pair
510 // inside `impl UpgradeFromEntry`. Sibling of the peer
511 // [`Self::validate_purge_ordering`] (580d0f1) routing already
512 // lifted onto [`UpgradeInstruction::is_load_module`] on the paired
513 // load → cleanup ordering axis, the peer
514 // [`Self::validate_load_singularity`] (c9ce91d) routing lifted
515 // onto the [`UpgradeInstruction::is_load_module`] +
516 // [`UpgradeInstruction::declared_module`] pair on the singularity
517 // axis, and the peer [`Self::validate_state_change_singularity`]
518 // routing already lifted onto the sibling
519 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
520 // accessor on the migration-family script-projection axis — both
521 // ordering-gate load-family sticky-latch dispatches now key off
522 // exactly one typed dispatch on the substrate primitive for
523 // their load-family arm-discriminator, and both migration-family
524 // projection sites (this ordering gate + the peer singularity
525 // gate) now key off exactly one typed dispatch on the substrate
526 // primitive for the `:script`-carrying axis. A future sixth arm
527 // on [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
528 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges, a
529 // `CanaryTraffic` split-traffic variant the M4 CR materializer
530 // could resolve per-CR — INSPIRATIONS §II.4) migrates as one
531 // enum-declaration edit through the derive rather than a
532 // coordinated rewrite of every ordering / singularity gate's
533 // per-arm hand-rolled pattern-match. Byte-identity of this
534 // dispatch against the pre-lift `match` shape is pinned by
535 // [`tests::validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors`].
536 let mut loaded = false;
537 for instr in self.instructions() {
538 if instr.is_load_module() {
539 loaded = true;
540 } else if !loaded && let Some(script) = instr.declared_path() {
541 return Err(UpgradeError::state_change_without_prior_load(
542 self.prior_versao(),
543 script,
544 ));
545 }
546 }
547 Ok(())
548 }
549
550 /// Reject an entry whose `(:soft-purge …)` or `(:purge …)` is not
551 /// preceded by a `(:load-module …)` in the same `:instructions` list.
552 ///
553 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
554 /// `code:purge/1` analogs (INSPIRATIONS §II.4): they remove the
555 /// *old* module from memory after the new one is resident. OTP's
556 /// two-phase code load is `code:load_module/1` *then*
557 /// `code:soft_purge/1` — load the new version alongside the old
558 /// (both in memory, new requests route to new), then purge the old
559 /// after in-flight callers drain. caixa decomposes that into two
560 /// explicit instructions (`LoadModule` brings the new version up
561 /// "alongside the current one", per [`UpgradeInstruction::LoadModule`]
562 /// doc; `SoftPurge` "waits for in-flight requests on a named module
563 /// to drain, then GC it", per [`UpgradeInstruction::SoftPurge`] doc),
564 /// and the module doc pins that the operator "runs the instructions
565 /// in order". So a `:soft-purge` / `:purge` with no preceding
566 /// `:load-module` purges old code while the only resident version is
567 /// still the *same* old code, leaving the upgrade entry asking the
568 /// operator to drain or discard the live module with no replacement
569 /// resident. Two authoring footguns close here:
570 ///
571 /// - `((:soft-purge "…"))` / `((:purge "…"))` — the "I wrote the
572 /// cleanup but forgot to load the new module" footgun. The new
573 /// code never comes up alongside; the operator either drains the
574 /// old version to nothing (`SoftPurge`) or discards it outright
575 /// mid-request (`Purge`), with no replacement to route in-flight
576 /// or future requests to.
577 /// - `((:soft-purge "…") (:load-module "…"))` /
578 /// `((:purge "…") (:load-module "…"))` — the right-instructions-
579 /// wrong-order footgun. Because the operator executes in declared
580 /// order, the cleanup runs *before* the new code is resident,
581 /// leaving a window during which neither version is available;
582 /// the canonical order is `(:load-module …) (:state-change …)
583 /// (:soft-purge …)` (module doc example).
584 ///
585 /// Same within-entry cross-instruction discipline as
586 /// [`Self::validate_state_change_ordering`] (the `:state-change`-
587 /// ordering gate it runs beside): both close the same load-before-X
588 /// post-condition on the OTP appup ordering contract, now extending
589 /// the typed coverage from "new code resident before its state
590 /// migration runs" to "new code resident before the old code is
591 /// drained or discarded" — the second half of OTP's two-phase code
592 /// load. Runs *after* `validate_state_change_ordering` so an entry
593 /// like `((:state-change …) (:soft-purge …))` surfaces the more-
594 /// fundamental `StateChangeWithoutPriorLoad` first (both instructions
595 /// are load-less, but state-change is the load-bearing semantic — the
596 /// purge is meaningless either way without a preceding load, so the
597 /// author should see the migration-side diagnostic first).
598 fn validate_purge_ordering(&self) -> Result<(), UpgradeError> {
599 let mut loaded = false;
600 for instr in self.instructions() {
601 // Route the per-instruction cleanup-family arm-discriminator
602 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
603 // predicate rather than the raw
604 // `UpgradeInstruction::SoftPurge { module } |
605 // UpgradeInstruction::Purge { module }` open-coded per-arm
606 // union pattern-match — the first of three within-entry cross-
607 // instruction cleanup-facing gates now keys off exactly one
608 // typed dispatch on the substrate primitive, so any future
609 // fifth cleanup-shaped variant (a `Discard` variant the
610 // `code:delete/1` peer inspires) added to
611 // [`UpgradeInstruction`] + a composing `|| self.is_discard()`
612 // term at [`UpgradeInstruction::is_cleanup`] reaches this gate
613 // through the accessor's one body. The paired cleanup-arm
614 // `:module` scalar is routed through the sibling
615 // [`UpgradeInstruction::declared_module`] accessor rather than
616 // the raw pattern-bound `module` binding — same substrate-
617 // primitive-owns-the-scalar discipline every peer
618 // per-`UpgradeInstruction` scalar-value axis already routes
619 // through, with the `is_cleanup`-implies-`declared_module`-is-
620 // `Some` composition pin at
621 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
622 // making the `.expect(…)` structurally infallible at build
623 // time. Peer of the sibling
624 // [`UpgradeFromEntry::validate_restart_exclusive`]
625 // paired positive / negated
626 // [`UpgradeInstruction::is_restart`] routing (915a934) on the
627 // per-arm terminal-fallback partition — same closed-set-typed-
628 // enum arm-discriminator dispatch discipline extended from
629 // the single-arm terminal-fallback family onto the two-arm
630 // cleanup family here.
631 //
632 // Route the paired load-family arm-discriminator through the
633 // `gen_platform::IsVariant`-derive-generated
634 // [`UpgradeInstruction::is_load_module`] predicate rather than
635 // the raw `matches!(instr, UpgradeInstruction::LoadModule
636 // { .. })` open-coded pattern-match — closes the last
637 // unlifted `matches!`-based per-variant arm-discriminator
638 // axis on the [`UpgradeInstruction`] closed-set typed enum,
639 // sibling of the [`UpgradeInstruction::is_restart`] terminal-
640 // fallback routing (915a934) and the
641 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
642 // routing (0bc469f) that already lifted the paired
643 // arm-discriminator sites in this method. Every arm-family
644 // partition the gate keys off — load-family (`LoadModule`),
645 // cleanup-family (`SoftPurge | Purge`), terminal-fallback
646 // (`Restart`) — now consults exactly one typed dispatch on
647 // the substrate primitive, so a future sixth arm added to
648 // [`UpgradeInstruction`] (an `AwaitReadiness` gate, a
649 // `Downgrade` reverse-axis variant OTP's `relup` acknowledges,
650 // a `CanaryTraffic` split-traffic variant the M4 CR
651 // materializer could resolve per-CR — INSPIRATIONS §II.4)
652 // migrates as a single enum-declaration edit through the
653 // derive rather than a scattered per-consumer rewrite. The
654 // partition invariant is pinned by
655 // [`tests::upgrade_instruction_is_load_module_predicate_partitions_the_arm_set`]
656 // and the byte-identity of this dispatch against the pre-lift
657 // `matches!` pattern by
658 // [`tests::validate_purge_ordering_routes_through_is_load_module_predicate`].
659 if instr.is_load_module() {
660 loaded = true;
661 } else if instr.is_cleanup() && !loaded {
662 return Err(UpgradeError::purge_without_prior_load(
663 self.prior_versao(),
664 instr.lisp_form(),
665 instr
666 .declared_module()
667 .expect("is_cleanup() implies declared_module() is Some"),
668 ));
669 }
670 }
671 Ok(())
672 }
673
674 /// Reject an entry whose `(:state-change …)` appears after any
675 /// `(:soft-purge …)` / `(:purge …)` in the same `:instructions`
676 /// list — completing the canonical OTP appup `code:load_module/1`
677 /// → `gen_server:code_change/3` → `code:soft_purge/1` ordering
678 /// chain on the typed `:upgrade-from` slot.
679 ///
680 /// `StateChange` is the `gen_server:code_change/3` analog
681 /// ([`UpgradeInstruction::StateChange`] doc; INSPIRATIONS §II.4
682 /// verbatim: "State migration uses `gen_server:code_change/3` …
683 /// migrate state from v0.1.0 shape to current shape"). The
684 /// callback's input is the *prior* version's state shape, which
685 /// only exists while the prior code is still resident — the running
686 /// `gen_server` processes hold the v0.1.0 state, and the operator's
687 /// dispatch invokes `code_change/3` to fold that state into the
688 /// current shape. `SoftPurge` / `Purge` are the `code:soft_purge/1`
689 /// / `code:purge/1` analogs ([`UpgradeInstruction::SoftPurge`] /
690 /// [`UpgradeInstruction::Purge`] docs): they drain or discard the
691 /// *old* module after the new one is resident. The operator runs
692 /// instructions in declared order (module doc), so a cleanup ahead
693 /// of a state-change discards the prior code before the migration
694 /// fold runs against the state it held — the canonical OTP error
695 /// mode "`code_change/3` invoked on a purged module" the
696 /// `release_handler` enforces by always emitting the migration
697 /// callback before the soft-purge step.
698 ///
699 /// `systools`-generated `.relup` files always emit `code_change`
700 /// before `soft_purge` for this reason; the appup cookbook's
701 /// canonical pattern (`[{load_module, m}, {update, m, soft},
702 /// {soft_purge, m}]`) places the migration-triggering `update`
703 /// strictly between the load and the cleanup. The caixa module
704 /// doc pins the same canonical order verbatim — `(:load-module
705 /// …) (:state-change …) (:soft-purge …)` — and this gate makes
706 /// that ordering a structural property at build time. Three
707 /// authoring footguns close here:
708 ///
709 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
710 /// "lib/m.lisp"))` — the right-instructions-wrong-order
711 /// footgun on the migrate ↔ cleanup axis. Because the operator
712 /// executes in declared order, the cleanup drains the v0.1.0
713 /// module to nothing before the migration callback runs, and
714 /// the script either no-ops (no v0.1.0 state left to fold) or
715 /// crashes (`code_change/3` invoked on an unloaded version).
716 /// The canonical order is `(:load-module …) (:state-change
717 /// …) (:soft-purge …)` (module doc example).
718 /// - `((:load-module "x") (:purge "x-old") (:state-change
719 /// "lib/m.lisp"))` — same shape on the more catastrophic
720 /// `:purge` variant. The immediate-discard semantic destroys
721 /// v0.1.0 state mid-request; the trailing migration script
722 /// has nothing to fold from and the `gen_server` processes that
723 /// held v0.1.0 state were killed by the `:purge`.
724 /// - `((:load-module "x") (:soft-purge "x-old") (:state-change
725 /// "lib/m1.lisp") (:soft-purge "y-old"))` — the "migration
726 /// sandwiched between two cleanups" footgun. The first
727 /// cleanup discards v0.1.0; the migration runs against
728 /// drained state; the second cleanup is irrelevant. The first
729 /// cleanup → state-change boundary is the load-bearing defect
730 /// surfaced.
731 ///
732 /// Same within-entry cross-instruction discipline as
733 /// [`Self::validate_state_change_ordering`] (the load → state-
734 /// change ordering gate it runs after) and
735 /// [`Self::validate_purge_ordering`] (the load → cleanup ordering
736 /// gate it runs after): all three close one boundary of the OTP
737 /// canonical sequence `code:load_module/1` →
738 /// `gen_server:code_change/3` → `code:soft_purge/1`. The
739 /// state-change-ordering gate closes the load → migrate boundary;
740 /// the purge-ordering gate closes the load → cleanup boundary;
741 /// this gate closes the migrate → cleanup boundary, completing
742 /// the typed coverage of the canonical sequence. Runs *after*
743 /// [`Self::validate_purge_ordering`] (and therefore after
744 /// [`Self::validate_state_change_ordering`]) so an entry like
745 /// `((:soft-purge "x-old") (:state-change "lib/m.lisp"))` —
746 /// which violates *both* the purge-without-load gate and this
747 /// state-change-after-cleanup gate — surfaces the more-
748 /// fundamental `PurgeWithoutPriorLoad` first (the missing-load
749 /// defect is load-bearing; once a coherent `(:load-module …)`
750 /// precedes both, the migrate ↔ cleanup ordering becomes the
751 /// next live defect). Runs *before* the per-instruction-class
752 /// singularity gates ([`Self::validate_load_singularity`],
753 /// [`Self::validate_state_change_singularity`],
754 /// [`Self::validate_cleanup_singularity`]) so an entry like
755 /// `((:load-module "x") (:soft-purge "x-old") (:state-change
756 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` — which violates
757 /// *both* this ordering gate and the state-change-singularity
758 /// gate — surfaces the ordering defect first; the canonical
759 /// "ordering before singularity" precedence the peer
760 /// `validate_state_change_ordering` / `validate_purge_ordering`
761 /// gates already establish.
762 ///
763 /// Detection: linear scan of the instructions list with a
764 /// `prior_cleanup: Option<(module, kind)>` sticky-once latch
765 /// recording the first cleanup encountered; on any subsequent
766 /// `StateChange` the gate fires with the script + the prior
767 /// cleanup's kind/module. Diagnostic-order pin: the first
768 /// colliding state-change-after-cleanup pair surfaces, not the
769 /// last — mirrors every peer ordering gate's first-collision
770 /// posture ([`Self::validate_state_change_ordering`] returns on
771 /// the first `StateChange` without prior load,
772 /// [`Self::validate_purge_ordering`] on the first cleanup
773 /// without prior load).
774 fn validate_state_change_before_cleanup(&self) -> Result<(), UpgradeError> {
775 let mut prior_cleanup: Option<(&str, &'static str)> = None;
776 for instr in self.instructions() {
777 // Route the per-instruction cleanup-family arm-discriminator
778 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
779 // predicate rather than the raw
780 // `UpgradeInstruction::SoftPurge { module } |
781 // UpgradeInstruction::Purge { module }` open-coded per-arm
782 // union pattern-match — the second of three within-entry
783 // cross-instruction cleanup-facing gates the peer
784 // [`Self::validate_purge_ordering`] routing already lifted;
785 // both now key off exactly one typed dispatch on the substrate
786 // primitive so the "which arms belong to the cleanup family"
787 // question resolves at exactly one caixa-core edit. The
788 // sticky-once latch's `:module` scalar is routed through the
789 // sibling [`UpgradeInstruction::declared_module`] accessor
790 // rather than the raw pattern-bound `module.as_str()`
791 // projection, with the `is_cleanup`-implies-`declared_module`-
792 // is-`Some` composition pin at
793 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
794 // making the `.expect(…)` structurally infallible at build
795 // time.
796 if instr.is_cleanup() && prior_cleanup.is_none() {
797 prior_cleanup = Some((
798 instr
799 .declared_module()
800 .expect("is_cleanup() implies declared_module() is Some"),
801 instr.lisp_form(),
802 ));
803 } else if let Some(script) = instr.declared_path()
804 && let Some((prior_module, prior_kind)) = prior_cleanup
805 {
806 // Route the per-instruction `StateChange`-arm script-path
807 // projection through the sibling lifted
808 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
809 // accessor rather than the raw
810 // `if let UpgradeInstruction::StateChange { script } = instr`
811 // open-coded pattern-match — the last unlifted per-
812 // `UpgradeInstruction` `PathBuf`-carrying-axis consumer
813 // inside `impl UpgradeFromEntry`, sibling to the four peer
814 // per-`UpgradeInstruction` consumers already routed through
815 // the accessor: [`UpgradeInstruction::validate`]'s per-
816 // `StateChange` sandbox-path fan-out, the layout-side per-
817 // `StateChange` script-existence fan-out at
818 // [`crate::layout::StandardLayout::verify`]
819 // (caixa-core/src/layout.rs:1058), the within-entry
820 // [`UpgradeFromEntry::validate_state_change_singularity`]
821 // per-`StateChange` script-projection fan-out, and the
822 // cross-slot
823 // [`validate_upgrade_from_against_behavior`]
824 // per-`StateChange` detection loop. Byte-equal today
825 // (`declared_path` returns `Some(script)` iff the
826 // instruction is [`UpgradeInstruction::StateChange`], per
827 // the sibling `declared_path_only_for_state_change` pin),
828 // so a state-change-after-cleanup surfaces
829 // `StateChangeAfterCleanup` byte-identical to the pattern-
830 // match shape. Any future accessor extension that promotes
831 // an additional variant onto the `PathBuf`-carrying axis
832 // reaches this gate through one caixa-core edit rather
833 // than a coordinated rewrite of five call sites — the
834 // migrate→cleanup ordering discipline extends to the
835 // promoted variant by construction. Same "one typed
836 // dispatch on the substrate primitive, thin projections at
837 // each consumer" trajectory the sibling
838 // [`UpgradeInstruction::declared_module`] `String`-axis
839 // per-variant unifier already established.
840 return Err(UpgradeError::state_change_after_cleanup(
841 self.prior_versao(),
842 script,
843 prior_kind,
844 prior_module,
845 ));
846 }
847 }
848 Ok(())
849 }
850
851 /// Reject an entry whose `:instructions` list names the same module
852 /// as the target of more than one cleanup instruction (`:soft-purge`
853 /// or `:purge`) in total — set-not-multiset on the (cleanup-class,
854 /// module) axis, narrowed to the cleanup class.
855 ///
856 /// `SoftPurge` and `Purge` are the `code:soft_purge/1` /
857 /// `code:purge/1` analogs (INSPIRATIONS §II.4 verbatim: "1.
858 /// `code:load_module/1` — load v2 alongside v1 … 2.
859 /// `code:soft_purge/1` — wait until no process is running v1, then
860 /// discard. (`code:purge/1` kills v1 immediately if you don't
861 /// care.)"). The author picks *one* cleanup semantic per old
862 /// module — `:soft-purge` (preferred: waits for in-flight callers
863 /// to drain) or `:purge` (when the drain isn't possible) — and the
864 /// operator runs that one in declared order alongside any other
865 /// distinct-module cleanups. systools-generated `.relup` files
866 /// always emit at most one purge per module for this reason; any
867 /// retry / fallback decision is the operator's job on
868 /// instruction failure, not authored into the entry. Three
869 /// authoring footguns close here:
870 ///
871 /// - `((:load-module "x") (:soft-purge "x-old") (:soft-purge "x-old"))`
872 /// — the "I copy-pasted the cleanup line twice" footgun. The
873 /// second `:soft-purge` is a no-op (the module is already gone
874 /// after the first drain-and-discard) or undefined depending
875 /// on the operator's handling of a non-resident-module purge
876 /// request; either way the second instruction carries no
877 /// observable semantic, far from the source caixa.lisp.
878 /// - `((:load-module "x") (:soft-purge "x-old") (:purge "x-old"))`
879 /// — the "soft-then-hard fallback" footgun. The author wrote
880 /// "drain, and if drain didn't clean it up, force-discard",
881 /// but the operator runs instructions unconditionally in
882 /// declared order — the `:purge` fires whether the
883 /// `:soft-purge` already discarded the module or not, so the
884 /// fallback semantic the author imagined is missing; the
885 /// pair is incoherent (drain *and* force-discard semantics
886 /// on one module is two contradictory dispositions). The
887 /// operator's failure-handling surface is its own
888 /// responsibility: if `:soft-purge` doesn't drain within its
889 /// cooldown the operator escalates, not the author's entry.
890 /// - `((:load-module "x") (:purge "x-old") (:soft-purge "x-old"))`
891 /// — same shape on the reversed ordering. The `:purge`
892 /// discards immediately; the trailing `:soft-purge` has no
893 /// module to drain.
894 ///
895 /// Same within-entry exclusivity discipline as
896 /// [`Self::validate_restart_exclusive`] (the `(:restart)` terminal-
897 /// exclusivity gate it joins on the per-module cleanup axis): both
898 /// reject an `:instructions` list whose instructions are
899 /// individually well-shaped but jointly incoherent on a chosen
900 /// semantic axis (restart-fallback for the whole entry there;
901 /// cleanup-semantic for one module here), at the typed build
902 /// surface rather than as a runtime surprise. Runs *after*
903 /// [`Self::validate_purge_ordering`] (the load-before-cleanup
904 /// ordering gate) so an entry like `((:soft-purge "x-old")
905 /// (:soft-purge "x-old"))` surfaces the more-fundamental
906 /// `PurgeWithoutPriorLoad` first (both cleanups are load-less, and
907 /// the missing-load defect is the load-bearing one — the duplicate
908 /// is meaningless either way without the preceding load).
909 ///
910 /// Same set-not-multiset discipline applied to every peer
911 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
912 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
913 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
914 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
915 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
916 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
917 /// and `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]).
918 /// Each closes the same authoring footgun: a Vec authoring surface
919 /// that silently accepts duplicate entries and renders the "second
920 /// wins" (or "operator processes both, second is a no-op or
921 /// errors") shape downstream, far from the source caixa.lisp.
922 /// This gate extends the discipline onto the within-entry
923 /// instruction-target axis — duplicate cleanup targets *within*
924 /// one `:upgrade-from` entry — the peer of the cross-entry
925 /// duplicate-`:from` axis at one level of nesting deeper.
926 ///
927 /// Detection: linear scan of the instructions list collecting
928 /// the (module, kind) pair from every `SoftPurge` / `Purge`
929 /// encountered; on the second occurrence of any module the gate
930 /// fires with the prior kind and the colliding kind in declaration
931 /// order. Diagnostic-order pin: the first colliding pair surfaces,
932 /// not the last — mirrors
933 /// [`validate_upgrade_from`]'s
934 /// `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
935 /// posture (the first detected collision wins) and every peer
936 /// duplicate gate's first-collision discipline.
937 fn validate_cleanup_singularity(&self) -> Result<(), UpgradeError> {
938 let mut seen: Vec<(&str, &'static str)> = Vec::new();
939 for instr in self.instructions() {
940 // Route the per-instruction cleanup-family arm-discriminator
941 // through the lifted [`UpgradeInstruction::is_cleanup`] typed
942 // predicate rather than the raw two-arm
943 // `UpgradeInstruction::SoftPurge { module } => (module.as_str(),
944 // M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE)` /
945 // `UpgradeInstruction::Purge { module } => (module.as_str(),
946 // M2_UPGRADE_INSTRUCTION_KIND_PURGE)` / `_ => continue`
947 // per-arm dispatch — the third of three within-entry cross-
948 // instruction cleanup-facing gates the peer
949 // [`Self::validate_purge_ordering`] +
950 // [`Self::validate_state_change_before_cleanup`] routing
951 // already lifted; all three now key off exactly one typed
952 // dispatch on the substrate primitive, structurally. The
953 // cleanup-target `(module, kind)` pair is projected through
954 // the peer [`UpgradeInstruction::declared_module`] /
955 // [`UpgradeInstruction::lisp_form`] accessors rather than
956 // the per-arm-hand-rolled scalar-value + kind-const pair,
957 // with the `is_cleanup`-implies-`declared_module`-is-`Some`
958 // composition pin at
959 // [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
960 // making the `.expect(…)` structurally infallible at build
961 // time. Any future fifth cleanup-shaped variant added under
962 // the `is_cleanup` predicate + registered through the peer
963 // `lisp_form` per-arm kebab-case-const dispatch reaches this
964 // dedup gate through the accessor's one body rather than a
965 // fourth per-arm-hand-rolled scalar/kind projection here.
966 if !instr.is_cleanup() {
967 continue;
968 }
969 let module = instr
970 .declared_module()
971 .expect("is_cleanup() implies declared_module() is Some");
972 let kind = instr.lisp_form();
973 if let Some(prior_idx) = seen.iter().position(|(m, _)| *m == module) {
974 let prior_kind = seen[prior_idx].1;
975 return Err(UpgradeError::duplicate_cleanup(
976 self.prior_versao(),
977 module,
978 vec![prior_kind, kind],
979 ));
980 }
981 seen.push((module, kind));
982 }
983 Ok(())
984 }
985
986 /// Reject an entry whose `:instructions` list names the same module
987 /// as the target of more than one `(:load-module …)` instruction —
988 /// set-not-multiset on the `LoadModule` axis.
989 ///
990 /// `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
991 /// §II.4 verbatim: "1. `code:load_module/1` — load v2 alongside v1;
992 /// new code is 'current', old code is 'old'."). The instruction
993 /// brings the new wasm component up resident alongside the old
994 /// one so the operator can route new traffic to the new code
995 /// while in-flight callers drain on the old — and the operator's
996 /// dispatch table reads the module *name* (a caixa name) to bind
997 /// the component, so two `(:load-module "x")` instructions in one
998 /// entry ask the operator to re-bind the same component twice.
999 /// `systools`-generated `.relup` files emit at most one
1000 /// `load_module` per module per upgrade step for this reason; the
1001 /// second load has no observable semantic relative to the first
1002 /// (the component is already resident). Three authoring footguns
1003 /// close here:
1004 ///
1005 /// - `((:load-module "x") (:load-module "x"))` — the "I
1006 /// copy-pasted the load line twice" footgun. The second
1007 /// `:load-module` re-reads the same module name and re-binds
1008 /// the same wasm component — a no-op in both directions
1009 /// (no new code becomes resident; no old code is purged) —
1010 /// and any cleanup / migration the author intended for a
1011 /// *distinct* module is silently absent from the entry.
1012 /// - `((:load-module "x") (:load-module "x") (:state-change …))`
1013 /// — the "I meant to load two distinct modules" typo. The
1014 /// author intended `((:load-module "x") (:load-module "y"))`
1015 /// but renamed both to "x" (or copied the first line and
1016 /// forgot to change the module). The migration runs against
1017 /// code that's resident only on one module name, and the
1018 /// second module the author imagined was being loaded never
1019 /// comes up at all — far from the source caixa.lisp.
1020 /// - `((:load-module "x") (:load-module "x") (:soft-purge "x-old"))`
1021 /// — same shape with a trailing cleanup. The duplicate load
1022 /// is dead code; the cleanup still fires correctly, masking
1023 /// the load-side duplication as a silently-passing entry.
1024 ///
1025 /// Same within-entry exclusivity discipline as
1026 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1027 /// singularity gate this runs beside) on the sibling
1028 /// `LoadModule` axis: both reject an `:instructions` list whose
1029 /// instructions are individually well-shaped but jointly
1030 /// incoherent on a per-module-per-class basis (load-once for the
1031 /// load axis here; cleanup-once for the cleanup axis there), at
1032 /// the typed build surface rather than as a runtime surprise.
1033 /// Runs *after* [`Self::validate_purge_ordering`] (the load-
1034 /// before-cleanup ordering gate) so an entry like
1035 /// `((:state-change "m.lisp") (:load-module "x") (:load-module "x"))`
1036 /// surfaces the more-fundamental `StateChangeWithoutPriorLoad`
1037 /// first (the missing-load defect is load-bearing — the migration
1038 /// runs against unloaded code; the duplicate is meaningless either
1039 /// way without the preceding load). Runs *before*
1040 /// [`Self::validate_cleanup_singularity`] so an entry like
1041 /// `((:load-module "x") (:load-module "x") (:soft-purge "y-old")
1042 /// (:soft-purge "y-old"))` surfaces `DuplicateLoadModule` first —
1043 /// the load axis precedes the cleanup axis in the canonical OTP
1044 /// sequence (`code:load_module/1` then `code:soft_purge/1`) and
1045 /// in [`UpgradeInstruction`] declaration order (`LoadModule`
1046 /// before `SoftPurge`/`Purge`), so the load-side singularity is
1047 /// the load-bearing diagnostic when both fire.
1048 ///
1049 /// Same set-not-multiset discipline applied to every peer
1050 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1051 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1052 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1053 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1054 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1055 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1056 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), and
1057 /// the per-module cleanup-target axis (9cedd8b —
1058 /// [`UpgradeError::DuplicateCleanup`]). This gate extends the
1059 /// discipline onto the within-entry `LoadModule` instruction-target
1060 /// axis — the third within-entry per-module singularity completing
1061 /// the load+cleanup pair across the OTP two-phase code-load
1062 /// contract.
1063 ///
1064 /// Detection: linear scan of the instructions list collecting the
1065 /// module name from every `LoadModule` encountered; on the second
1066 /// occurrence of any module the gate fires. Diagnostic-order pin:
1067 /// the first colliding occurrence surfaces, not the last — mirrors
1068 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1069 /// and every peer duplicate gate's first-collision discipline.
1070 fn validate_load_singularity(&self) -> Result<(), UpgradeError> {
1071 let mut seen: Vec<&str> = Vec::new();
1072 for instr in self.instructions() {
1073 // Route the per-instruction load-family arm-discriminator
1074 // through the `gen_platform::IsVariant`-derive-generated
1075 // [`UpgradeInstruction::is_load_module`] predicate rather
1076 // than the raw single-arm `match instr {
1077 // UpgradeInstruction::LoadModule { module } =>
1078 // module.as_str(), _ => continue }` open-coded pattern-
1079 // match — closes the last unlifted `matches!`-shaped
1080 // per-arm-hand-rolled scalar-value + arm-discriminator
1081 // pair inside `impl UpgradeFromEntry`, sibling of the
1082 // peer [`Self::validate_cleanup_singularity`] (0bc469f)
1083 // routing already lifted onto the two-arm cleanup-family
1084 // axis's per-arm arm-discriminator + `:module` projection
1085 // dispatch. The load-target `:module` scalar is projected
1086 // through the sibling [`UpgradeInstruction::declared_module`]
1087 // accessor rather than the per-arm-hand-rolled scalar-
1088 // value binding, with the
1089 // `is_load_module`-implies-`declared_module`-is-`Some`
1090 // composition pin at
1091 // [`tests::upgrade_instruction_is_load_module_implies_declared_module_is_some`]
1092 // making the `.expect(…)` structurally infallible at
1093 // build time. Every arm-family partition the three
1094 // within-entry per-instruction-class singularity gates
1095 // key off — load-family
1096 // ([`UpgradeInstruction::LoadModule`]), cleanup-family
1097 // ([`UpgradeInstruction::SoftPurge`] |
1098 // [`UpgradeInstruction::Purge`]), migration-family
1099 // ([`UpgradeInstruction::StateChange`]) — now consults
1100 // exactly one typed dispatch on the substrate primitive
1101 // (`is_load_module()` here, `is_cleanup()` at
1102 // [`Self::validate_cleanup_singularity`],
1103 // `declared_path()` at
1104 // [`Self::validate_state_change_singularity`]), so a
1105 // future sixth arm added to [`UpgradeInstruction`] (an
1106 // `AwaitReadiness` gate, a `Downgrade` reverse-axis
1107 // variant OTP's `relup` acknowledges, a `CanaryTraffic`
1108 // split-traffic variant the M4 CR materializer could
1109 // resolve per-CR — INSPIRATIONS §II.4) migrates as a
1110 // single enum-declaration edit through the derive rather
1111 // than a scattered per-consumer rewrite. Byte-identity of
1112 // this dispatch against the pre-lift match-pattern is
1113 // pinned by
1114 // [`tests::validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`].
1115 if !instr.is_load_module() {
1116 continue;
1117 }
1118 let module = instr
1119 .declared_module()
1120 .expect("is_load_module() implies declared_module() is Some");
1121 if seen.contains(&module) {
1122 return Err(UpgradeError::duplicate_load_module(
1123 self.prior_versao(),
1124 module,
1125 ));
1126 }
1127 seen.push(module);
1128 }
1129 Ok(())
1130 }
1131
1132 /// Reject an entry whose `:instructions` list names the same script
1133 /// as the target of more than one `(:state-change …)` instruction —
1134 /// set-not-multiset on the `StateChange` axis.
1135 ///
1136 /// `StateChange` is the `gen_server:code_change/3` analog
1137 /// (INSPIRATIONS §II.4: "State migration uses
1138 /// `gen_server:code_change/3`"). The instruction folds the *old*
1139 /// state into the shape the *new* code expects — a one-shot
1140 /// transition from one declared state representation to another.
1141 /// OTP's `release_handler:install_release/1` invokes `code_change/3`
1142 /// exactly once per upgrade per `gen_server`; `systools`-generated
1143 /// `.relup` files emit at most one `code_change` per `gen_server` per
1144 /// upgrade step for this reason. A second `(:state-change "m.lisp")`
1145 /// instruction targeting the same script in one entry re-runs the
1146 /// migration fold — at best a no-op (idempotent script masking a
1147 /// typo where the author intended two distinct scripts) and at
1148 /// worst silent state corruption (non-idempotent fold double-
1149 /// applied: an `add column` migration that runs twice, an
1150 /// `increment counter` that double-bumps, a `rename field` that
1151 /// renames-then-fails the second time). Three authoring footguns
1152 /// close here:
1153 ///
1154 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1155 /// (:state-change "lib/m.lisp"))` — the "I copy-pasted the
1156 /// migration line twice" footgun. The second `:state-change`
1157 /// re-runs the same fold on the already-migrated state — a
1158 /// no-op if the script is idempotent (dead code masking the
1159 /// duplication) or state corruption if not (the migration's
1160 /// pre-condition no longer holds because the post-condition is
1161 /// already in place).
1162 /// - `((:load-module "x") (:state-change "lib/m.lisp")
1163 /// (:state-change "lib/m.lisp") (:soft-purge "x-old"))` — the
1164 /// "duplicate migrate masked by trailing cleanup" footgun. The
1165 /// cleanup still fires correctly, masking the migration-side
1166 /// duplication as a silently-passing entry.
1167 /// - `((:load-module "x") (:state-change "lib/m1.lisp")
1168 /// (:state-change "lib/m1.lisp"))` — the "I meant to migrate
1169 /// two distinct modules" typo. The author intended
1170 /// `(:state-change "lib/m1.lisp") (:state-change "lib/m2.lisp")`
1171 /// but renamed both to `m1.lisp` (or copy-pasted the first line
1172 /// and forgot to change the script). The migration that should
1173 /// have folded the second module's state never runs, far from
1174 /// the source caixa.lisp.
1175 ///
1176 /// Same within-entry exclusivity discipline as
1177 /// [`Self::validate_load_singularity`] (the per-module load-
1178 /// singularity gate it runs after) and
1179 /// [`Self::validate_cleanup_singularity`] (the per-module cleanup-
1180 /// singularity gate it runs before) on the sibling `StateChange`
1181 /// axis: each rejects an `:instructions` list whose instructions
1182 /// are individually well-shaped but jointly incoherent on a per-
1183 /// instruction-class basis (load-once per module for the load
1184 /// axis; migrate-once per script for the migration axis here;
1185 /// cleanup-once per module for the cleanup axis), at the typed
1186 /// build surface rather than as a runtime surprise. Runs *after*
1187 /// [`Self::validate_load_singularity`] so an entry like
1188 /// `((:load-module "x") (:load-module "x") (:state-change
1189 /// "lib/m.lisp") (:state-change "lib/m.lisp"))` surfaces
1190 /// `DuplicateLoadModule` first — the load axis precedes the
1191 /// migration axis in the canonical OTP sequence
1192 /// (`code:load_module/1` then `gen_server:code_change/3`) and in
1193 /// [`UpgradeInstruction`] declaration order (`LoadModule` before
1194 /// `StateChange`), so the load-side singularity is the load-
1195 /// bearing diagnostic when both fire. Runs *before*
1196 /// [`Self::validate_cleanup_singularity`] so an entry like
1197 /// `((:load-module "x") (:state-change "lib/m.lisp") (:state-change
1198 /// "lib/m.lisp") (:soft-purge "y-old") (:soft-purge "y-old"))`
1199 /// surfaces `DuplicateStateChange` first — the migration axis
1200 /// precedes the cleanup axis in the canonical OTP sequence
1201 /// (`code:code_change/3` then `code:soft_purge/1`) and in
1202 /// [`UpgradeInstruction`] declaration order (`StateChange` before
1203 /// `SoftPurge`/`Purge`).
1204 ///
1205 /// Same set-not-multiset discipline applied to every peer
1206 /// duplicate-target axis: `:children :caixa` (dbf50a9 —
1207 /// `SupervisorError::DuplicateChildCaixa`), `:membros :caixa`
1208 /// (4bb3f3d — `AplicacaoError::MembroDuplicate`), `:contratos`
1209 /// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1210 /// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1211 /// `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`),
1212 /// `:upgrade-from :from` ([`UpgradeError::DuplicateFrom`]), the
1213 /// per-module cleanup-target axis (9cedd8b —
1214 /// [`UpgradeError::DuplicateCleanup`]), and the per-module load-
1215 /// target axis (a503978 — [`UpgradeError::DuplicateLoadModule`]).
1216 /// This gate extends the discipline onto the within-entry
1217 /// `StateChange` instruction-target axis — the third within-entry
1218 /// per-instruction-class singularity, completing the OTP two-phase
1219 /// code-load + state-migration coverage triad
1220 /// (`code:load_module/1` → `gen_server:code_change/3` →
1221 /// `code:soft_purge/1`).
1222 ///
1223 /// Detection: linear scan of the instructions list collecting the
1224 /// script path from every `StateChange` encountered; on the second
1225 /// occurrence of any script the gate fires. Diagnostic-order pin:
1226 /// the first colliding occurrence surfaces, not the last — mirrors
1227 /// [`Self::validate_load_singularity`]'s and
1228 /// [`Self::validate_cleanup_singularity`]'s first-collision posture
1229 /// and every peer duplicate gate's first-collision discipline.
1230 fn validate_state_change_singularity(&self) -> Result<(), UpgradeError> {
1231 // Route the per-instruction `StateChange`-arm script-path
1232 // projection through the sibling lifted
1233 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1234 // accessor rather than the raw
1235 // `match instr { UpgradeInstruction::StateChange { script } =>
1236 // script.as_path(), _ => continue }` open-coded pattern-match —
1237 // the third within-entry singularity gate's per-instruction
1238 // script-projection site now keys off exactly one typed
1239 // dispatch on the substrate primitive's `PathBuf`-carrying
1240 // axis, sibling to the four peer per-`UpgradeInstruction`
1241 // consumers ([`Self::validate`]'s per-`StateChange`
1242 // sandbox-path fan-out, the layout-side per-`StateChange`
1243 // script-existence fan-out at
1244 // `caixa-core/src/layout.rs:1017`, the cross-slot
1245 // [`validate_upgrade_from_against_behavior`] gate's
1246 // per-`StateChange` detection loop, the future wasm-operator's
1247 // per-`StateChange` runtime hook-dispatch) that already route
1248 // through `declared_path` / `declared_module`. Byte-equal
1249 // today (`declared_path` returns `Some(script)` iff the
1250 // instruction is [`UpgradeInstruction::StateChange`], per the
1251 // sibling `declared_path_only_for_state_change` pin), so a
1252 // duplicate `:state-change` script surfaces
1253 // `DuplicateStateChange` byte-identical to the pattern-match
1254 // shape. Same "one typed dispatch on the substrate primitive,
1255 // thin projections at each consumer" discipline the sibling
1256 // [`UpgradeInstruction::declared_module`] accessor established
1257 // (b13c4f9) on the peer `String`-carrying axis's per-variant
1258 // consumers, extended here onto the last unlifted
1259 // pattern-match on the `PathBuf`-carrying axis inside
1260 // `impl UpgradeFromEntry`.
1261 let mut seen: Vec<&std::path::Path> = Vec::new();
1262 for instr in self.instructions() {
1263 let Some(script) = instr.declared_path() else {
1264 continue;
1265 };
1266 let script = script.as_path();
1267 if seen.contains(&script) {
1268 return Err(UpgradeError::duplicate_state_change(
1269 self.prior_versao(),
1270 script,
1271 ));
1272 }
1273 seen.push(script);
1274 }
1275 Ok(())
1276 }
1277}
1278
1279/// Validate a whole `:upgrade-from` list: per-entry typed shape via
1280/// [`UpgradeFromEntry::validate`] *and* the cross-entry graph-edge-set
1281/// invariant — at most one `(:from <prior>)` block per parsed semver.
1282///
1283/// OTP's appup picks at most one matching block to apply to the running
1284/// release (`release_handler:install_release/1` matches the loaded
1285/// `:from` against the currently-running version and executes the
1286/// associated instruction sequence; the wasm-operator picks the matching
1287/// block at upgrade time, per `upgrade.rs` module doc). Two blocks with
1288/// the same parsed-semver `:from` are an ambiguous edge in the typed
1289/// upgrade graph — the operator can pick either set deterministically,
1290/// but each set may carry different `LoadModule | StateChange |
1291/// SoftPurge | Purge | Restart` instructions, so the *chosen* path is
1292/// non-deterministic relative to the source caixa.lisp. The author's
1293/// intent is one path per prior version; the typed graph must enforce
1294/// that shape.
1295///
1296/// Same set-not-multiset discipline already applied to every peer
1297/// typed-graph axis: `:children :caixa` (dbf50a9 —
1298/// `SupervisorError::DuplicateChildCaixa`, `child_spec.id` is required-
1299/// unique per supervisor in OTP), `:membros :caixa` (4bb3f3d —
1300/// `AplicacaoError::MembroDuplicate`), `:contratos`
1301/// (5dbcfaf — `AplicacaoError::ContratoDuplicate`), `:placement
1302/// :clusters` (c7c7799 — `AplicacaoError::PlacementClusterDuplicate`),
1303/// and `:entrada :paths` (eb3456d — `AplicacaoError::EntradaPathDuplicate`).
1304/// Each closes the same authoring footgun: a Vec authoring surface that
1305/// silently accepts duplicate entries and renders the "second wins"
1306/// (or "operator picks arbitrarily") shape downstream, far from the
1307/// source caixa.lisp.
1308///
1309/// Duplicates are detected by [`semver::Version`] equality (the
1310/// crate's `PartialEq` compares the full identity — major.minor.patch +
1311/// pre-release + build metadata — so `1.0.0` and `1.0.0-rc.1` and
1312/// `1.0.0+build1` and `1.0.0+build2` are all distinct upgrade paths).
1313/// The conservative choice mirrors what the wasm-operator's
1314/// `:from`-match dispatch can see; collapsing build metadata to catch
1315/// a wider net of duplicates is a future tightening that requires
1316/// coordinating with the operator's match step.
1317///
1318/// Per-entry shape errors fire before the duplicate gate so the
1319/// diagnostic names the malformed slot (`FromInvalid`, `EmptyScript`,
1320/// `ModuleInvalid`, …) rather than collapsing two unrelated authoring
1321/// errors into a single duplicate diagnostic. Mirrors the
1322/// `*_invalid_fires_before_duplicate_check` order pins on every peer
1323/// axis ([`crate::SupervisorSpec::validate`],
1324/// [`crate::AplicacaoSpec::validate_membros`],
1325/// [`crate::AplicacaoSpec::validate_placement`]).
1326pub fn validate_upgrade_from(entries: &[UpgradeFromEntry]) -> Result<(), UpgradeError> {
1327 use semver::Version;
1328 let mut seen: Vec<Version> = Vec::with_capacity(entries.len());
1329 for entry in entries {
1330 entry.validate()?;
1331 // `entry.validate()` accepted this `:from`, so parse cannot
1332 // fail here — the FromInvalid arm above is the only gate
1333 // and both call `Version::parse(entry.prior_versao())`.
1334 let parsed = Version::parse(entry.prior_versao()).expect(
1335 "UpgradeFromEntry::validate must accept `:from` iff Version::parse does — keep the \
1336 two gates aligned",
1337 );
1338 if seen.contains(&parsed) {
1339 return Err(UpgradeError::duplicate_from(entry));
1340 }
1341 seen.push(parsed);
1342 }
1343 Ok(())
1344}
1345
1346/// Reject `:upgrade-from` entries whose `:from` is not strictly less
1347/// than the caixa's current `:versao` (under SemVer-2 precedence — the
1348/// same ordering [`semver::Version::cmp`] implements, with build
1349/// metadata ignored per [SemVer §11][semver-11]).
1350///
1351/// The whole point of an `:upgrade-from :from "<prior>"` block is the
1352/// declarative answer to "given the wasm-operator is loading a node
1353/// running `<prior>`, how do I upgrade it to the *current* `:versao`?"
1354/// (`upgrade.rs` module doc, OTP appup `release_handler:install_release/1`
1355/// semantic). The operator's `:from`-match dispatch loads the
1356/// current `:versao` and matches the *running* version against each
1357/// entry's `:from`; an entry whose `:from >= :versao` is structurally
1358/// unreachable — the operator never runs a version greater than or
1359/// equal to the current `:versao` that it could then "upgrade *to*"
1360/// the current `:versao`. Two authoring footguns close here:
1361///
1362/// - `:from > :versao` (downgrade-shaped) — the canonical
1363/// "I copy-pasted from the next minor version and forgot to bump
1364/// `:versao`" / "I bumped `:versao` then reverted but left the
1365/// `:upgrade-from` entry behind" footgun. Until this gate landed
1366/// `(defcaixa :versao "0.1.5" :upgrade-from ((:from "0.2.0" …)))`
1367/// silently passed `feira build` and the wasm-operator's
1368/// `:from`-match dispatch would never fire on the entry — the
1369/// instructions sat dormant in the caixa.lisp forever, the
1370/// author's intent ("upgrade users coming from 0.2.0") permanently
1371/// unreached because they actually meant to bump `:versao`.
1372///
1373/// - `:from == :versao` (precedence-equal self-upgrade) — the
1374/// "I declared an upgrade from myself to myself" no-op the
1375/// operator's dispatch would either skip silently (no semantic
1376/// transition) or attempt and trivially "succeed" with no
1377/// observable state change. Includes the build-metadata-only
1378/// difference case (`:versao "0.2.0"`, `:from "0.2.0+build.1"`):
1379/// SemVer-2 precedence ignores build metadata so they compare
1380/// equal under [`semver::Version::cmp`] — the gate rejects this
1381/// even though [`UpgradeError::DuplicateFrom`] doesn't (the peer
1382/// gate uses derived `PartialEq` which keeps them distinct;
1383/// they're distinct dispatch keys but the same "from" version
1384/// for our purposes here).
1385///
1386/// Same cross-slot value-shape discipline as
1387/// [`crate::AplicacaoSpec::validate_placement`]'s strategy ↔ shard-key
1388/// partition (934bc58 — the typed partition between two declared
1389/// slots): one slot's value constrains the valid set of another's,
1390/// and the constraint is a structural property visible at validate
1391/// time. The validated set after this gate satisfies
1392/// `entry.from.parse::<Version>().unwrap() < versao.parse::<Version>().unwrap()`
1393/// for every entry, so the future operator-side hot-upgrade dispatch
1394/// step can reach for `entry.from` knowing the precedence relation
1395/// holds without re-deriving it from inline checks.
1396///
1397/// Silent-pass semantics on malformed inputs:
1398///
1399/// - When `versao` itself doesn't parse as semver, this gate
1400/// returns `Ok(())` silently — the narrower
1401/// [`crate::ManifestError::VersaoInvalid`] / [`UpgradeError::FromInvalid`]
1402/// diagnostics are the load-bearing surfaces for those failure
1403/// modes, and surfacing a `FromNotBeforeVersao` over an
1404/// unparseable `:versao` would mask the more actionable root
1405/// cause.
1406/// - Likewise, an entry whose `:from` itself doesn't parse falls
1407/// through to its narrower diagnostic surface
1408/// ([`UpgradeError::FromInvalid`]), which is expected to fire
1409/// via [`validate_upgrade_from`] *before* this gate runs at the
1410/// [`crate::LayoutInvariants`] call site.
1411///
1412/// [semver-11]: https://semver.org/#spec-item-11
1413pub fn validate_upgrade_from_against_versao(
1414 entries: &[UpgradeFromEntry],
1415 versao: &str,
1416) -> Result<(), UpgradeError> {
1417 use semver::Version;
1418 let Ok(current) = Version::parse(versao) else {
1419 // Malformed `:versao` is a separate gate (ManifestError::VersaoInvalid);
1420 // surfacing a precedence-relation diagnostic over an unparseable
1421 // top-level version would mask the more actionable root cause.
1422 return Ok(());
1423 };
1424 for entry in entries {
1425 // Per-entry shape — including a malformed `:from` — is gated
1426 // by [`validate_upgrade_from`] / [`UpgradeFromEntry::validate`]
1427 // upstream at the LayoutInvariants call site; an unparseable
1428 // `:from` here falls through silently to keep the
1429 // FromInvalid diagnostic load-bearing. Same fall-through
1430 // posture as the `versao` arm above.
1431 let Ok(prior) = Version::parse(entry.prior_versao()) else {
1432 continue;
1433 };
1434 if prior >= current {
1435 return Err(UpgradeError::from_not_before_versao(
1436 entry.prior_versao(),
1437 versao,
1438 ));
1439 }
1440 }
1441 Ok(())
1442}
1443
1444/// Reject `:upgrade-from` entries whose `:instructions` list carries any
1445/// `(:state-change <script>)` instruction unless the caixa also declares
1446/// `:behavior :on-state-change` — the runtime callback the per-version
1447/// migration script is delivered through during hot upgrade.
1448///
1449/// The module doc on [`crate::upgrade`] pins the composition verbatim:
1450/// the `:upgrade-from` slot "Composes with the `:behavior :on-state-change`
1451/// callback to deliver state migration during hot upgrades." The peer
1452/// module doc on [`crate::BehaviorSpec::on_state_change`] mirrors the
1453/// promise from the callback side: the slot is the
1454/// `gen_server:code_change/3` analog — "receives old state + version,
1455/// returns new state. Composes with the `:upgrade-from` slot declared at
1456/// the Caixa root." OTP's `release_handler:install_release/1` realizes
1457/// the composition by invoking the running `gen_server`'s
1458/// `code_change/3` callback during the appup's `code_change` /
1459/// `update, m, soft` step — the appup's instruction triggers the
1460/// callback, the callback folds the prior-version state shape into the
1461/// current-version shape, and the operator advances to the next
1462/// instruction only after the callback returns successfully. caixa
1463/// decomposes the same composition into two typed slots: the per-version
1464/// migration logic lives in the `(:state-change "lib/migrations/v01-to-v02.lisp")`
1465/// instruction's `:script` (the `:upgrade-from` author surface), and the
1466/// runtime hook the operator dispatches the migration through lives in
1467/// the `:behavior :on-state-change` callback (the `:behavior` author
1468/// surface). A `:state-change` instruction declared without the callback
1469/// is half the composition: the per-version script the author wrote has
1470/// no runtime delivery path, and the operator's hot-upgrade dispatch
1471/// reaches for `caixa.behavior.on_state_change` at the migration step,
1472/// finds `None`, and either fails the upgrade mid-flight (the
1473/// transactional rollback the module doc names — "On any failure, the
1474/// current version stays load-bearing — a typed atomic upgrade") or
1475/// silently skips the migration depending on the operator's handling of
1476/// a missing callback, both far from the source caixa.lisp.
1477///
1478/// Two authoring footguns close here:
1479///
1480/// - `(:behavior ((:on-init …)))` + `(:upgrade-from ((:from "0.1.0"
1481/// :instructions ((:load-module "x") (:state-change "lib/m.lisp")
1482/// (:soft-purge "x-old")))))` — the "I declared the migration script
1483/// but forgot the callback" footgun. The author wrote the per-version
1484/// fold against the prior state shape, the typed `:upgrade-from`
1485/// slot validated every per-instruction shape + ordering + singularity
1486/// gate, and the missing callback only surfaces at upgrade time as
1487/// either a transactional rollback to the prior version (no progress
1488/// across the upgrade) or as a silently-skipped migration that leaves
1489/// v0.2.0 code running against unmigrated v0.1.0 state (corrupted
1490/// state shape).
1491/// - `:behavior` absent entirely + `:upgrade-from` carrying any
1492/// `:state-change` — the "I added the upgrade path but never declared
1493/// `:behavior`" footgun. `:behavior` is optional at the typed root
1494/// ([`crate::Caixa::behavior: Option<BehaviorSpec>`]) so the typed
1495/// `:upgrade-from` slot validates on its own merits, but a `Caixa`
1496/// with `behavior: None` and a `:state-change` instruction is the
1497/// same missing-callback shape — the operator's dispatch can't reach
1498/// a callback that doesn't exist.
1499///
1500/// Same cross-slot composition discipline as
1501/// [`validate_upgrade_from_against_versao`] (the `:from` ↔ `:versao`
1502/// precedence gate at the peer wire-up site): one slot's value
1503/// (`:from` < `:versao` there; `:state-change` declared here) constrains
1504/// the valid set of another's (the entry must be dispatchable there; the
1505/// callback must be declared here), and the constraint is a structural
1506/// property visible at validate time. The validated set after this gate
1507/// satisfies the documented composition: every `:state-change`
1508/// instruction the operator iterates at hot-upgrade time has a
1509/// corresponding `:on-state-change` callback declared on the same caixa,
1510/// so the future wasm-operator's hot-upgrade dispatch (the OTP
1511/// `release_handler` canonical-sequence loop) can reach for
1512/// `behavior.on_state_change` at the migration step knowing the
1513/// `Option<PathBuf>` is `Some(_)` without re-deriving the precondition
1514/// from inline checks.
1515///
1516/// Diagnostic-precedence:
1517///
1518/// - Runs *after* [`UpgradeFromEntry::validate`] (per-instruction
1519/// shape + the within-entry ordering / singularity gates) and
1520/// [`validate_upgrade_from`] (the cross-entry duplicate-`:from`
1521/// gate), so a malformed `:state-change` (`EmptyScript`,
1522/// `AbsoluteScript`, `ParentEscapeScript`) or an ill-ordered entry
1523/// (`StateChangeWithoutPriorLoad`, `StateChangeAfterCleanup`) or a
1524/// duplicate `:from` (`DuplicateFrom`) surfaces its narrower
1525/// self-locating diagnostic first — the canonical "per-instr-shape +
1526/// within-entry ordering + cross-entry uniqueness before
1527/// cross-slot composition" precedence the peer
1528/// `validate_upgrade_from_against_versao` gate establishes at the
1529/// same wire-up site. Without this precedence pin a malformed
1530/// `:state-change` instruction would surface this gate's
1531/// missing-callback diagnostic over the narrower
1532/// `EmptyScript` / `StateChangeWithoutPriorLoad`, masking the
1533/// load-bearing per-instruction defect with a cross-slot composition
1534/// diagnostic.
1535/// - Within the entries, walks the list in declaration order and
1536/// surfaces the *first* `:state-change` instruction encountered —
1537/// mirrors every peer first-collision diagnostic posture on this
1538/// module (`validate_state_change_ordering` returns on the first
1539/// `StateChange` without prior load,
1540/// `validate_load_singularity` returns on the second matching
1541/// module, etc.). A future entry's later `:state-change` doesn't
1542/// surface a different diagnostic — the missing callback is the same
1543/// defect regardless of which entry's `:state-change` exposes it.
1544///
1545/// Silent-pass semantics:
1546///
1547/// - Entries carrying no `:state-change` instruction (load-only,
1548/// cleanup-only, restart-only, or empty `:instructions`) leave the
1549/// gate vacuous — no per-version migration means no callback to
1550/// dispatch through, so the absence of `:on-state-change` is
1551/// coherent. Pins the gate's identity element on the empty-set side
1552/// of the composition.
1553/// - `behavior: None` is *not* a free pass when a `:state-change`
1554/// instruction is present — the same missing-callback shape as
1555/// `behavior: Some(_)` with `on_state_change: None`. The gate reads
1556/// `behavior.and_then(BehaviorSpec::on_state_change)` so both shapes
1557/// surface the same diagnostic.
1558pub fn validate_upgrade_from_against_behavior(
1559 entries: &[UpgradeFromEntry],
1560 behavior: Option<&crate::BehaviorSpec>,
1561) -> Result<(), UpgradeError> {
1562 if behavior
1563 .and_then(crate::BehaviorSpec::on_state_change)
1564 .is_some()
1565 {
1566 return Ok(());
1567 }
1568 for entry in entries {
1569 // Route the per-instruction `StateChange`-arm script-path
1570 // projection through the sibling lifted
1571 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
1572 // accessor rather than the raw
1573 // `if let UpgradeInstruction::StateChange { script } = instr`
1574 // open-coded pattern-match — the cross-slot
1575 // `:upgrade-from ↔ :behavior` composition gate's per-instruction
1576 // script-projection site now keys off exactly one typed dispatch
1577 // on the substrate primitive's `PathBuf`-carrying axis, sibling
1578 // to the four peer per-`UpgradeInstruction` consumers
1579 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
1580 // sandbox-path fan-out, the layout-side per-`StateChange`
1581 // script-existence fan-out at
1582 // [`crate::layout::StandardLayout::verify`] (caixa-core/src/layout.rs:1058),
1583 // the within-entry [`UpgradeFromEntry::validate_state_change_singularity`]
1584 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
1585 // peer [`UpgradeInstruction::declared_module`] `String`-axis
1586 // per-variant unifier) that already route through
1587 // `declared_path` / `declared_module`. Byte-equal today
1588 // (`declared_path` returns `Some(script)` iff the instruction is
1589 // [`UpgradeInstruction::StateChange`], per the sibling
1590 // `declared_path_only_for_state_change` pin), so a
1591 // `:state-change`-without-`:on-state-change`-callback
1592 // composition surfaces `StateChangeWithoutOnStateChangeCallback`
1593 // byte-identical to the pattern-match shape. Fourth (and last)
1594 // per-`UpgradeInstruction`-consumer of the `PathBuf`-carrying
1595 // axis now routed through the accessor — closes the last
1596 // unlifted `if let UpgradeInstruction::StateChange { script } = instr`
1597 // site outside `impl UpgradeFromEntry`, so the peer four
1598 // consumer set named in the sibling
1599 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
1600 // pin (caixa-core/src/upgrade.rs:4598) is now structurally
1601 // closed.
1602 for instr in entry.instructions() {
1603 if let Some(script) = instr.declared_path() {
1604 return Err(UpgradeError::state_change_without_on_state_change_callback(
1605 entry.prior_versao(),
1606 script,
1607 ));
1608 }
1609 }
1610 }
1611 Ok(())
1612}
1613
1614impl UpgradeInstruction {
1615 /// Substrate-canonical exhaustive accept-set on the OTP-appup
1616 /// tatara-lisp author-surface form axis — the closed five-arm
1617 /// roster of every `:` -prefixed instruction kind tag
1618 /// [`Self::lisp_form`] emits, routed byte-for-byte through the
1619 /// paired [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`]
1620 /// / [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1621 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1622 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1623 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] lifted
1624 /// `pub const` roster the [`Self::lisp_form`] emitter walks.
1625 ///
1626 /// The fieldless-enum peer discipline [`crate::CaixaKind::ALL`] /
1627 /// [`crate::supervisor::RestartStrategy::ALL`] /
1628 /// [`crate::supervisor::RestartPolicy::ALL`] /
1629 /// [`crate::aplicacao::PlacementStrategy::ALL`] /
1630 /// [`crate::aplicacao::RateLimitUnit::ALL`] /
1631 /// [`crate::dep::DepList::ALL`] /
1632 /// [`crate::dialeto::CaixaDialeto::ALL`] carry as `&'static [Self]`
1633 /// exhaustive-iteration surfaces cannot land on this enum
1634 /// verbatim: [`UpgradeInstruction`] is a discriminated union
1635 /// carrying per-variant data ([`String`] `:module` on the
1636 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
1637 /// arms, [`std::path::PathBuf`] `:script` on [`Self::StateChange`]),
1638 /// so a `&'static [Self]` roster would demand static-lifetime
1639 /// dummy instances at build time that leak the "no canonical
1640 /// value" defect at every consumer. The closed set that *is*
1641 /// exhaustively enumerable on this enum is the per-arm lisp-form
1642 /// tag byte-string — the discriminant axis. Lifting it here as
1643 /// `&'static [&'static str]` closes the exhaustive-iteration
1644 /// surface on the axis that admits one, matching the peer
1645 /// fieldless-enum discipline through the discriminator projection
1646 /// rather than the variant enumeration.
1647 ///
1648 /// Consumers today (and future): an M4 `mesh.pleme.io/v1alpha1/Caixa`
1649 /// CR admission-webhook rejection body naming the accepted
1650 /// `:upgrade-from :instructions (…)` kind-tag set verbatim, a
1651 /// future `feira lint --upgrade-from` per-instruction author-time
1652 /// audit surface listing accepted tags on an unknown-tag miss, a
1653 /// future `caixa-actions` renderer that surfaces the accepted
1654 /// appup instruction vocabulary in a workflow annotation, an LSP
1655 /// hover completion source that offers the accepted-tag set on a
1656 /// partial `:upgrade-from :instructions (` author position — every
1657 /// consumer that wants to enumerate the closed OTP-appup
1658 /// kind-tag set outside caixa-core now reaches for one lifted
1659 /// substrate-primitive roster rather than open-coding a
1660 /// `[":load-module", ":state-change", ":soft-purge", ":purge",
1661 /// ":restart"]` array-literal whose arm-set has no compile-time
1662 /// link back to the typed [`UpgradeInstruction`] enum. A future
1663 /// variant addition (a `Discard` peer the `code:delete/1` analog
1664 /// might inspire, a `SoftPurge` split into `SoftPurgeCoop` /
1665 /// `SoftPurgeForce` as the drain-cool-down policy grows a two-arm
1666 /// shape) extends this roster as a single edit — paired with the
1667 /// [`Self::lisp_form`] match's compiler-checked exhaustiveness on
1668 /// the new arm — and every consumer picks up the new tag by
1669 /// construction rather than a coordinated array-literal rewrite
1670 /// across every downstream site.
1671 ///
1672 /// Distinct axis from the un-prefixed kebab wire-form
1673 /// [`Self::as_str`] emits (`"load-module"` / `"state-change"` /
1674 /// `"soft-purge"` / `"purge"` / `"restart"` — the serde-carried
1675 /// JSON `"kind"` tag and the fleet-wide dispatcher-catalog identity
1676 /// under `"caixa.upgrade-instruction"`): the two-axis split the
1677 /// sibling
1678 /// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
1679 /// pin already makes load-bearing is preserved here by
1680 /// construction — this roster lands on the tatara-lisp author-
1681 /// surface form (`:` -prefixed) that every `feira lint`
1682 /// diagnostic and per-arm [`UpgradeError`] `list:` payload
1683 /// carries verbatim, not the kebab wire byte-string. A future
1684 /// author-facing rebrand (an Elixir/Phoenix hot-reload
1685 /// convergence collapsing `:load-module` under `:reload`, an M4-
1686 /// side rename of `:state-change` onto Erlang's own `code_change/3`
1687 /// verbatim) lands at one match arm in [`Self::lisp_form`] plus
1688 /// one edit to the corresponding
1689 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const, and this
1690 /// roster (routed through the same consts) migrates in lockstep.
1691 ///
1692 /// Length is pinned load-bearing at 5 by
1693 /// [`tests::upgrade_instruction_lisp_forms_covers_every_arm`] via
1694 /// the shared `upgrade_instruction_arm_roster()` fixture, and
1695 /// every entry is pinned to a member of the roster on every arm
1696 /// so a silent skew between the [`Self::lisp_form`] match's
1697 /// arm-set and this const's arm-set trips at caixa-core test time
1698 /// rather than at a downstream admission-webhook rejection body's
1699 /// accepted-set enumeration miss.
1700 pub const LISP_FORMS: &'static [&'static str] = &[
1701 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1702 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1703 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1704 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1705 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1706 ];
1707
1708 /// Substrate-canonical exhaustive accept-set on the OTP-appup
1709 /// peer wire-form axis — the closed five-arm roster of every
1710 /// un-prefixed kebab-case byte-string [`Self::as_str`] emits,
1711 /// routed byte-for-byte through the paired
1712 /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE`] /
1713 /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE`] /
1714 /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE`] /
1715 /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE`] /
1716 /// [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART`] lifted
1717 /// `pub const` roster the [`Self::as_str`] emitter walks — and
1718 /// byte-for-byte the same five strings the
1719 /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
1720 /// carries on every K8s-CR JSON / YAML round-trip and the
1721 /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1722 /// returns for fleet-wide dispatcher-catalog registration under
1723 /// `"caixa.upgrade-instruction"`.
1724 ///
1725 /// Peer to the sibling [`Self::LISP_FORMS`] roster on the
1726 /// tatara-lisp author-surface form axis (`:` -prefixed): the
1727 /// two-axis split the sibling
1728 /// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
1729 /// pin already makes load-bearing on the scalar-accessor pair is
1730 /// now extended onto the roster pair. `LISP_FORMS` / `WIRE_FORMS`
1731 /// carry per-arm byte-strings that are byte-distinct by design
1732 /// (one opens with `:`, the other does not); the paired
1733 /// [`tests::upgrade_instruction_wire_forms_covers_every_arm`] and
1734 /// [`tests::upgrade_instruction_lisp_and_wire_forms_are_length_aligned`]
1735 /// pins keep the two rosters in structural lockstep so a variant
1736 /// addition on either axis trips at caixa-core test time rather
1737 /// than silently splitting the two rosters at some downstream
1738 /// consumer's accepted-set enumeration.
1739 ///
1740 /// Consumers today (and future): an M4
1741 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection
1742 /// body enumerating the accepted wire-form `"kind"` tag-set
1743 /// verbatim on an unknown-tag miss (the un-prefixed kebab bytes
1744 /// the JSON payload carries, not the `:` -prefixed tatara-lisp
1745 /// form), a future `caixa-actions` renderer that surfaces the
1746 /// accepted appup instruction wire-vocabulary in a workflow
1747 /// annotation the CI pipeline reads verbatim, a fleet-side
1748 /// operator's per-cluster catalog enumeration listing the
1749 /// registered dispatcher identities under
1750 /// `"caixa.upgrade-instruction"` (each dispatcher's identity
1751 /// being a member of this roster by construction of the paired
1752 /// `.discriminant()` derive), an M4-side per-instruction structured
1753 /// log audit table keyed off the wire-form byte-string — every
1754 /// consumer that wants to enumerate the closed OTP-appup wire
1755 /// kind-tag set outside caixa-core now reaches for one lifted
1756 /// substrate-primitive roster rather than open-coding a
1757 /// `["load-module", "state-change", "soft-purge", "purge",
1758 /// "restart"]` array-literal whose arm-set has no compile-time
1759 /// link back to the typed [`UpgradeInstruction`] enum. A future
1760 /// variant addition (a `Discard` peer the `code:delete/1` analog
1761 /// might inspire, a `SoftPurge` split into `SoftPurgeCoop` /
1762 /// `SoftPurgeForce` peers as the drain-cool-down policy grows a
1763 /// two-arm shape) extends this roster as a single edit — paired
1764 /// with the [`Self::as_str`] match's compiler-checked
1765 /// exhaustiveness on the new arm — and every consumer picks up
1766 /// the new tag by construction rather than a coordinated
1767 /// array-literal rewrite across every downstream site.
1768 ///
1769 /// Length is pinned load-bearing at 5 by
1770 /// [`tests::upgrade_instruction_wire_forms_covers_every_arm`] via
1771 /// the shared `upgrade_instruction_arm_roster()` fixture, and
1772 /// every entry is pinned to a member of the roster on every arm
1773 /// so a silent skew between the [`Self::as_str`] match's arm-set
1774 /// and this const's arm-set trips at caixa-core test time rather
1775 /// than at a downstream admission-webhook rejection body's
1776 /// accepted-set enumeration miss. The paired
1777 /// [`tests::upgrade_instruction_lisp_and_wire_forms_are_length_aligned`]
1778 /// pin further gates the peer-axis alignment.
1779 pub const WIRE_FORMS: &'static [&'static str] = &[
1780 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
1781 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
1782 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
1783 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
1784 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
1785 ];
1786
1787 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup kind-tag
1788 /// projection every consumer that renders / classifies / grepping-
1789 /// projects an instruction's lisp form keys off — returns the
1790 /// kebab-case `:kind` tag verbatim as a `&'static str`, threaded
1791 /// straight through the paired
1792 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
1793 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
1794 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
1795 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
1796 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`] `pub const`
1797 /// roster the substrate already carries at the wire-form axis.
1798 ///
1799 /// Consumers today: [`Self::validate`] threads the label through the
1800 /// per-variant [`UpgradeError::ModuleEmpty`] /
1801 /// [`UpgradeError::ModuleInvalid`] / [`UpgradeError::PurgeWithoutPriorLoad`]
1802 /// / [`UpgradeError::DuplicateCleanup`] diagnostics so the author can
1803 /// grep their caixa.lisp for `(:load-module …)` / `(:soft-purge …)` /
1804 /// `(:purge …)` and fix it in one edit; every within-entry cross-
1805 /// instruction gate on `caixa-core/src/upgrade.rs` reaches for the
1806 /// same accessor's `&'static str` return in place of hand-rolling
1807 /// the per-arm match.
1808 ///
1809 /// Promoted from `pub(self)` to `pub`: every future consumer that
1810 /// wants to render / classify / diagnose an [`UpgradeInstruction`]
1811 /// by its OTP-appup lisp form outside caixa-core — a deferred
1812 /// wasm-operator `install_release/1` per-instruction dispatch
1813 /// logger tagging each executed instruction under its kebab-case
1814 /// kind, a `feira lint --upgrade-from` per-instruction author-time
1815 /// audit surface, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
1816 /// webhook naming the offending instruction's kind in its rejection
1817 /// body, a future `caixa-actions` renderer that surfaces the
1818 /// declared appup instruction list in a workflow annotation, an
1819 /// LSP hover projecting the per-instruction kind onto a text-
1820 /// document diagnostic — reaches this projection through one call
1821 /// on the substrate primitive rather than open-coding the same
1822 /// five-arm match plus per-arm const imports at every consumer.
1823 /// A future variant addition (a `Discard` peer the `code:delete/1`
1824 /// analog inspires, an M4 `SoftPurge` split into
1825 /// `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-cool-down
1826 /// policy grows a two-arm shape) reaches every consumer at one edit
1827 /// — this method's match — rather than fanning out through hand-
1828 /// rolled per-arm dispatch across every downstream site.
1829 ///
1830 /// Peer of the sibling substrate-canonical arm-family accessors on
1831 /// the same closed-set enum: [`Self::declared_module`] on the
1832 /// `String`-carrying axis (`Some(_)` for [`Self::LoadModule`] /
1833 /// [`Self::SoftPurge`] / [`Self::Purge`]; `None` for
1834 /// [`Self::StateChange`] / [`Self::Restart`]),
1835 /// [`Self::declared_path`] on the `PathBuf`-carrying axis
1836 /// (`Some(_)` for [`Self::StateChange`]), and
1837 /// the arm-discriminator predicates [`Self::is_cleanup`] on the
1838 /// two-arm cleanup family and the [`gen_platform::IsVariant`]-derive-
1839 /// generated per-variant `is_*` predicate family — every downstream
1840 /// consumer that fans on an [`UpgradeInstruction`] axis now reaches
1841 /// one typed dispatch on the substrate primitive rather than open-
1842 /// coding a per-arm match.
1843 ///
1844 /// `const fn` preserves the zero-runtime-work property of the pre-
1845 /// promotion body verbatim, and the `&'static str` return (not
1846 /// `&str` tied to `&self`'s lifetime) matches the paired
1847 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster's
1848 /// program-lifetime discipline so callers can stash the returned
1849 /// label in `&'static`-bounded positions (a static logger's format
1850 /// argument, a `HashMap<&'static str, _>` key, a `matches!`-style
1851 /// slice-of-`&'static str` accept-set) without re-borrowing through
1852 /// the instruction reference. Named `lisp_form` (not `kind_label` /
1853 /// `discriminant_label`) to name the axis the substrate already
1854 /// reaches for in the paired
1855 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster and
1856 /// in every per-arm `UpgradeError` diagnostic that carries the
1857 /// kebab-case tag verbatim — the lisp author-surface term, not the
1858 /// Rust discriminant name.
1859 #[must_use]
1860 pub const fn lisp_form(&self) -> &'static str {
1861 match self {
1862 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
1863 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
1864 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
1865 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
1866 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
1867 }
1868 }
1869
1870 /// Substrate-canonical per-`UpgradeInstruction` kebab-case wire-form
1871 /// discriminator every consumer that lands on the un-prefixed
1872 /// kebab byte-string (matching serde's
1873 /// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive's
1874 /// per-variant tag output and the
1875 /// [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
1876 /// fleet-catalog identity) reaches through — returns `"load-module"`
1877 /// / `"state-change"` / `"soft-purge"` / `"purge"` / `"restart"`,
1878 /// byte-for-byte the same five strings the JSON `"kind"` tag carries
1879 /// (per the sibling
1880 /// [`crate::tests::dispatcher_registration::reflection_round_trips_through_serde_tags`]
1881 /// pin) and the fleet-wide dispatcher-catalog registers under
1882 /// `"caixa.upgrade-instruction"` (per
1883 /// [`crate::tests::dispatcher_registration::variant_kinds_match_otp_appup_kebab`]).
1884 ///
1885 /// Distinct axis from the peer [`Self::lisp_form`] accessor, which
1886 /// returns the tatara-lisp author-surface form with the leading `:`
1887 /// prefix (`":load-module"` / `":state-change"` / `":soft-purge"` /
1888 /// `":purge"` / `":restart"`) that lands in `feira lint` per-
1889 /// instruction diagnostics and every
1890 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const's docstring.
1891 /// The two axes carry different bytes by design, not drift: the lisp
1892 /// form is the author-facing tag the caixa.lisp grep-and-fix
1893 /// workflow reaches for (`grep '(:load-module '` finds the offending
1894 /// entry verbatim), while [`Self::as_str`] is the wire-format byte-
1895 /// string every serde-serialized CR / [`std::fmt::Display`]-formatted
1896 /// diagnostic line / [`AsRef<str>`]-bound consumer / fleet-catalog
1897 /// identity converge onto — the same two-axis discipline the sibling
1898 /// [`crate::CaixaKind::as_str`] / [`crate::CaixaKind::wire_name`]
1899 /// pair (2aa6d23) documents on the top-level `:kind` closed-set
1900 /// discriminator, extended here onto the M2 OTP-appup
1901 /// per-instruction tag axis.
1902 ///
1903 /// Peer of the sibling closed-set typed enums' `as_str` /
1904 /// `as_suffix` canonical-projection accessors:
1905 /// [`crate::CaixaKind::as_str`] (6b1f4fb),
1906 /// [`crate::supervisor::RestartStrategy::as_str`] (09ffb2d),
1907 /// [`crate::supervisor::RestartPolicy::as_str`] (ccdf955),
1908 /// [`crate::aplicacao::PlacementStrategy::as_str`] (cc8f749),
1909 /// [`crate::aplicacao::RateLimitUnit::as_suffix`] (6bce03d) — the
1910 /// last closed-set typed enum on the caixa `:upgrade-from` surface
1911 /// to converge onto the substrate-canonical
1912 /// `(as_str, AsRef<str>, Display)` triple through one lifted
1913 /// `const fn` scalar accessor, so a future author-facing rebrand
1914 /// (a per-consumer disambiguation of the OTP-appup vocabulary, a
1915 /// hypothetical `:reload` collapse of `:load-module` under an
1916 /// Elixir/Phoenix hot-reload convergence, an M4-side rename of
1917 /// `:state-change` onto Erlang's own `code_change/3` verbatim) lands
1918 /// at one match arm — the paired [`std::fmt::Display`] impl and
1919 /// [`AsRef<str>`] impl route through this accessor by construction,
1920 /// so every consumer downstream of any of the three reaches the same
1921 /// per-arm byte-string in lockstep.
1922 ///
1923 /// `pub const fn` matches the peer accessors' const-context posture:
1924 /// downstream `const`-context callers (a module-scope
1925 /// `const _:() = assert!(<variant>.as_str().len() > 0)` invariant
1926 /// pin, a `const fn` per-instruction wire-shape audit table the M4
1927 /// admission webhook materializes at build time) reach the accessor
1928 /// through one dispatch on the substrate primitive without an
1929 /// intermediate non-`const` step. Returns `&'static str` (not
1930 /// `&str` bound to `&self`'s lifetime) so callers can stash the
1931 /// returned label in `&'static`-bounded positions (a static logger's
1932 /// format argument, a `HashMap<&'static str, _>` key, a `matches!`-
1933 /// style slice-of-`&'static str` accept-set) without re-borrowing
1934 /// through the instruction reference.
1935 #[must_use]
1936 pub const fn as_str(&self) -> &'static str {
1937 match self {
1938 Self::LoadModule { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
1939 Self::StateChange { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
1940 Self::SoftPurge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
1941 Self::Purge { .. } => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
1942 Self::Restart => crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
1943 }
1944 }
1945
1946 /// Validate the instruction's typed shape. Path existence is
1947 /// checked separately by [`crate::layout::StandardLayout`].
1948 ///
1949 /// The per-variant scalar the value-shape gates fire against is
1950 /// read through this method's two sibling accessors — the
1951 /// `String`-carrying axis via [`Self::declared_module`] (the
1952 /// `LoadModule` / `SoftPurge` / `Purge` variants unifying on their
1953 /// K8s DNS-1123-label `:module` reference) and the `PathBuf`-
1954 /// carrying axis via [`Self::declared_path`] (the `StateChange`
1955 /// variant's tatara-lisp `:script`) — rather than the per-arm
1956 /// `Self::LoadModule { module } | Self::SoftPurge { module } |
1957 /// Self::Purge { module }` pattern the module-axis previously
1958 /// open-coded and the per-arm `Self::StateChange { script }` the
1959 /// script-axis previously open-coded. Every scalar this enum
1960 /// carries now flows through one of the two `Option<&…>`
1961 /// accessors, so a future extension of either axis (a fifth
1962 /// module-bearing variant, an operator-side pre-parsed scalar
1963 /// cache the accessors materialize behind the same return
1964 /// contract, an M4 typed sub-slot the accessors could route
1965 /// alongside the existing scalar) migrates as a single edit on
1966 /// the accessor rather than a coordinated rewrite of every
1967 /// downstream value-shape gate. `Restart` (the only variant that
1968 /// carries neither scalar) falls through both `Option` checks and
1969 /// returns `Ok(())` — the terminal-fallback shape the
1970 /// [`Self::Restart`] variant doc pins.
1971 pub fn validate(&self) -> Result<(), UpgradeError> {
1972 if let Some(module) = self.declared_module() {
1973 return validate_module(self.lisp_form(), module);
1974 }
1975 if let Some(script) = self.declared_path() {
1976 // Delegate the four-arm cascade (empty / absolute /
1977 // parent-escape / non-`.lisp`-extension) to the lifted
1978 // [`crate::render::require_sandboxed_lisp_path`] helper —
1979 // same `Empty → Absolute → ParentEscape → NonLispExtension`
1980 // arm-ordering this method previously inlined verbatim,
1981 // now shared with [`crate::BehaviorSpec::validate`]'s
1982 // per-`:on-*`-callback gate so every author-supplied
1983 // tatara-lisp source path on every M2 typed slot consults
1984 // one gate, not two-and-counting verbatim copies of the
1985 // same four-arm cascade. Each closure wraps the tag in
1986 // the same `*Script` variant the original inline code
1987 // raised, so the diagnostic shape every caller depends
1988 // on (the `:state-change :script` self-locating error)
1989 // is preserved by construction. See
1990 // [`crate::render::require_sandboxed_lisp_path`] for the
1991 // smallest-scope-arm-fires-last ordering rationale.
1992 crate::render::require_sandboxed_lisp_path(
1993 script,
1994 || UpgradeError::EmptyScript,
1995 || UpgradeError::absolute_script(script),
1996 || UpgradeError::parent_escape_script(script),
1997 || UpgradeError::non_lisp_extension_script(script),
1998 )?;
1999 }
2000 // `Restart` (the only variant with no `Option<&…>`-carrying
2001 // scalar) falls through both accessor gates and returns
2002 // `Ok(())` — the terminal-fallback shape.
2003 Ok(())
2004 }
2005
2006 /// The `:module` scalar carried by this instruction — the
2007 /// K8s DNS-1123-label OTP-appup caixa-name reference every
2008 /// [`Self::LoadModule`] / [`Self::SoftPurge`] / [`Self::Purge`]
2009 /// variant declares against, and every author expects `feira lint`
2010 /// to name verbatim in per-instruction diagnostics. Returns `None`
2011 /// on [`Self::StateChange`] (which carries a `:script` — closed by
2012 /// the sibling [`Self::declared_path`]) and on [`Self::Restart`]
2013 /// (which carries no data at all, the OTP terminal-fallback
2014 /// shape).
2015 ///
2016 /// Sibling in shape to [`Self::declared_path`] on the second and
2017 /// final scalar-carrying axis of [`UpgradeInstruction`]:
2018 /// `declared_path` closes the `PathBuf`-carrying arm
2019 /// (`StateChange`); `declared_module` closes the `String`-carrying
2020 /// arms (`LoadModule` / `SoftPurge` / `Purge`). Every scalar the
2021 /// enum carries now routes through one of the two `Option<&…>`
2022 /// accessors — a caller that doesn't care which variant declared
2023 /// the scalar reads through one `if let Some(…)` rather than a
2024 /// per-variant pattern match. The pair is the enum-variant-
2025 /// unifying peer of the per-mesh-slot-atom scalar-accessor family
2026 /// on the M3 side ([`crate::WitContract::source`] /
2027 /// [`crate::WitContract::destination`] /
2028 /// [`crate::WitContract::world_ref`] closing `:contratos`;
2029 /// [`crate::Entrada::hostname`] / [`crate::Entrada::destination`]
2030 /// closing `:entrada`; [`crate::Membro::nome`] /
2031 /// [`crate::Membro::versao_requirement`] closing `:membros`) and
2032 /// on the M2 side ([`crate::UpgradeFromEntry::prior_versao`]
2033 /// closing per-entry `:from`; the [`crate::LimitsSpec`] /
2034 /// [`crate::BehaviorSpec`] closed families; the [`crate::ChildSpec`]
2035 /// closed OTP-shape supervisor family) — those peer accessors
2036 /// return a struct field verbatim; this pair unifies enum-
2037 /// variant-carried scalars into one accessor per typed axis.
2038 ///
2039 /// Byte-for-byte from the typed variant's own `String` storage;
2040 /// no cloning, no re-parsing. A future extension of the axis (an
2041 /// M4 typed sub-slot the module string is derived from, an
2042 /// operator-side pre-parsed caixa-name cache the accessor could
2043 /// materialize behind the same `&str` return contract, a fifth
2044 /// module-bearing OTP-appup variant the enum grows) migrates as
2045 /// a single caixa-core edit rather than a coordinated rewrite
2046 /// of every downstream module-axis consumer (currently
2047 /// [`Self::validate`]'s DNS-1123-label gate through
2048 /// [`validate_module`]; extensible to future consumers on the
2049 /// same axis without further per-variant match sites).
2050 #[must_use]
2051 pub const fn declared_module(&self) -> Option<&str> {
2052 match self {
2053 Self::LoadModule { module } | Self::SoftPurge { module } | Self::Purge { module } => {
2054 Some(module.as_str())
2055 }
2056 Self::StateChange { .. } | Self::Restart => None,
2057 }
2058 }
2059
2060 /// If the instruction references an on-disk path, return it —
2061 /// used by the layout checker to verify the path resolves.
2062 ///
2063 /// Sibling on the `PathBuf`-carrying axis to [`Self::declared_module`]
2064 /// on the `String`-carrying axis: `declared_path` closes the
2065 /// `StateChange` arm's `:script`; `declared_module` closes the
2066 /// `LoadModule` / `SoftPurge` / `Purge` arms' `:module`. Together
2067 /// they route every scalar this enum carries through one of two
2068 /// `Option<&…>` accessors, so [`Self::validate`]'s value-shape
2069 /// gates dispatch on the accessor return rather than a per-variant
2070 /// pattern match on the enum shape itself.
2071 ///
2072 /// Four per-`UpgradeInstruction` consumers now key off this
2073 /// accessor's `PathBuf`-carrying axis:
2074 /// [`Self::validate`]'s per-`StateChange` sandbox-path fan-out,
2075 /// [`crate::layout::StandardLayout::verify`]'s per-`StateChange`
2076 /// script-existence fan-out at `caixa-core/src/layout.rs:1058`, the
2077 /// within-entry
2078 /// [`UpgradeFromEntry::validate_state_change_singularity`] (2bf3ce5)
2079 /// per-`StateChange` script-projection fan-out, and the cross-slot
2080 /// [`validate_upgrade_from_against_behavior`] `:upgrade-from ↔
2081 /// :behavior` composition gate's per-`StateChange` detection loop
2082 /// — every downstream consumer of the `PathBuf`-carrying axis
2083 /// reaches through this one dispatch, so a future accessor
2084 /// extension (an M4 typed sub-slot the script path is derived from,
2085 /// an operator-side pre-resolved-path cache the accessor
2086 /// materializes behind the same `Option<&PathBuf>` return contract,
2087 /// a fifth `PathBuf`-bearing OTP-appup variant the enum grows)
2088 /// migrates as a single caixa-core edit rather than a coordinated
2089 /// rewrite of four call sites.
2090 #[must_use]
2091 pub const fn declared_path(&self) -> Option<&PathBuf> {
2092 match self {
2093 Self::StateChange { script } => Some(script),
2094 _ => None,
2095 }
2096 }
2097
2098 /// Substrate-canonical per-`UpgradeInstruction` OTP-appup cleanup-
2099 /// family arm-discriminator predicate every within-entry cross-
2100 /// instruction cleanup-facing gate keys off — true iff `self` is
2101 /// [`Self::SoftPurge`] (`code:soft_purge/1` analog: drain the
2102 /// named module until no process is running it, then GC) or
2103 /// [`Self::Purge`] (`code:purge/1` analog: discard the named
2104 /// module immediately, without waiting for drain), the two OTP
2105 /// two-phase-code-load cleanup arms the closed-set enum's
2106 /// non-terminal / non-migration / non-load variants exhaust.
2107 /// Every non-cleanup arm ([`Self::LoadModule`] on the paired
2108 /// two-phase-load half, [`Self::StateChange`] on the
2109 /// `gen_server:code_change/3`-analog migration axis,
2110 /// [`Self::Restart`] on the OTP terminal-fallback shape)
2111 /// returns `false`.
2112 ///
2113 /// Prior to this lift the `Self::SoftPurge { module } |
2114 /// Self::Purge { module }` two-arm cleanup-family pattern-
2115 /// match sat inline at three within-entry cross-instruction
2116 /// gate sites, each hand-rolling its own copy of the union
2117 /// with no compile-time link back to the substrate primitive's
2118 /// closed-set arm-family: [`UpgradeFromEntry::validate_purge_ordering`]
2119 /// at caixa-core/src/upgrade.rs:570 (guarded arm firing
2120 /// [`UpgradeError::PurgeWithoutPriorLoad`] on any cleanup
2121 /// arriving before a preceding [`Self::LoadModule`]),
2122 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]
2123 /// at caixa-core/src/upgrade.rs:689 (sticky-once latch
2124 /// recording the first-encountered cleanup so a subsequent
2125 /// [`Self::StateChange`] fires [`UpgradeError::StateChangeAfterCleanup`]),
2126 /// and [`UpgradeFromEntry::validate_cleanup_singularity`] at
2127 /// caixa-core/src/upgrade.rs:800 (per-module cleanup-target
2128 /// dedup ejecting [`UpgradeError::DuplicateCleanup`] on the
2129 /// second cleanup targeting the same `:module`). Three open-
2130 /// coded per-arm-union pattern-matches that expressed no
2131 /// compile-time link back to the substrate primitive. A future
2132 /// fifth cleanup-shaped variant (a `Discard` variant the
2133 /// `code:delete/1` peer inspires that folds under the same
2134 /// two-phase-load cleanup partition, an M4 `SoftPurge` split
2135 /// into `SoftPurgeCoop` / `SoftPurgeForce` peers as the drain-
2136 /// cool-down policy grows a two-arm shape, an operator-side
2137 /// pre-resolved cleanup-decision cache the predicate could
2138 /// route through the same `bool` return contract) would have
2139 /// had to be threaded through every open-coded per-arm-union
2140 /// pattern-match in lockstep or one gate would silently
2141 /// classify the new arm outside the cleanup family while the
2142 /// peer gates classified it in (or vice versa) — a
2143 /// classification split across the three within-entry cross-
2144 /// instruction gates at build time that lands far from the
2145 /// source [`UpgradeInstruction`] declaration with no field
2146 /// naming which gate carries the drifted arm-set. Lifting the
2147 /// resolution to a typed predicate on the substrate primitive
2148 /// means every downstream cleanup-facing consumer of the
2149 /// [`UpgradeInstruction`] closed-set enum reaches for exactly
2150 /// one typed dispatch — the resolver's arm-set migrates as a
2151 /// unit on any future arm addition composing under this
2152 /// predicate's `||` chain.
2153 ///
2154 /// Sibling in shape to the peer [`gen_platform::IsVariant`]-
2155 /// derive-generated [`Self::is_restart`] terminal-fallback
2156 /// arm-discriminator predicate on the same closed-set
2157 /// [`UpgradeInstruction`] enum (each names an OTP-appup arm-
2158 /// family partition as one typed dispatch on the substrate
2159 /// primitive; `is_restart` on the single-arm terminal-
2160 /// fallback family, `is_cleanup` on the two-arm cleanup
2161 /// family), extended here from the single-arm case onto the
2162 /// two-arm arm-family union case. Composes through the
2163 /// [`gen_platform::IsVariant`]-derive-generated
2164 /// [`Self::is_soft_purge`] / [`Self::is_purge`] per-variant
2165 /// predicates rather than an open-coded raw `matches!`
2166 /// pattern-match, so a future rebrand on either underlying
2167 /// per-arm classifier flows through this predicate's one
2168 /// body without a coordinated per-consumer rewrite across
2169 /// the three within-entry cross-instruction gates that route
2170 /// through it. Peer of the sibling per-`:contratos`
2171 /// shape-family union predicates [`crate::WitContract::is_http`] /
2172 /// [`crate::WitContract::is_pubsub`] / [`crate::WitContract::is_store`]
2173 /// on the M3 mesh-slot per-`:wit` world-ref axis (each unions a
2174 /// per-shape WIT-prefix rule the substrate primitive's arm-
2175 /// family partition names as one typed dispatch) — the same
2176 /// "one typed dispatch on the substrate primitive, thin
2177 /// projections at each consumer" discipline extended onto the
2178 /// M2 `:upgrade-from :instructions` per-`UpgradeInstruction`
2179 /// cleanup-family axis.
2180 ///
2181 /// The name `is_cleanup` maps directly onto the canonical
2182 /// OTP-appup vocabulary (INSPIRATIONS §II.4 verbatim: "2.
2183 /// `code:soft_purge/1` — wait until no process is running v1,
2184 /// then discard. (`code:purge/1` kills v1 immediately if you
2185 /// don't care.)" — the two `code:*_purge/1` operations are
2186 /// the two-phase-load contract's cleanup half, paired under
2187 /// one concept), and the peer [`Self::validate_cleanup_singularity`]
2188 /// / [`UpgradeError::DuplicateCleanup`] / [`UpgradeError::PurgeWithoutPriorLoad`]
2189 /// / [`UpgradeError::StateChangeAfterCleanup`] surface already
2190 /// reaches for the same "cleanup" vocabulary in identifier +
2191 /// diagnostic form.
2192 #[must_use]
2193 pub const fn is_cleanup(&self) -> bool {
2194 self.is_soft_purge() || self.is_purge()
2195 }
2196}
2197
2198/// [`std::fmt::Display`] routed through [`UpgradeInstruction::as_str`],
2199/// so the pretty-printed byte-string every consumer that formats the
2200/// per-`:upgrade-from :instructions` entry's OTP-appup tag as user-
2201/// facing text lands on (the future wasm-operator's
2202/// `install_release/1` per-instruction dispatch log line, the future
2203/// `feira lint --upgrade-from` per-entry annotation, an M4
2204/// `mesh.pleme.io/v1alpha1/Caixa` CR admission-webhook rejection body
2205/// naming the offending instruction's kind, an LSP hover projecting
2206/// the instruction kind onto a text-document diagnostic) reaches for
2207/// the same wire byte-string the un-`rename`d
2208/// `#[serde(tag = "kind", rename_all = "kebab-case")]` derive emits
2209/// under the paired [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
2210/// tag key.
2211///
2212/// Peer of the sibling closed-set typed enums' `Display` route through
2213/// their `as_str` accessor: [`crate::CaixaKind`] (2aa6d23),
2214/// [`crate::supervisor::RestartStrategy`] (supervisor.rs),
2215/// [`crate::supervisor::RestartPolicy`] (supervisor.rs), and
2216/// [`crate::aplicacao::PlacementStrategy`] (aplicacao.rs) — the last
2217/// M2 OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2218/// surface to converge onto the `Display`-through-`as_str` discipline.
2219///
2220/// Deliberately routes through the wire-aligned
2221/// [`UpgradeInstruction::as_str`] axis (kebab-case, no `:` prefix),
2222/// not the tatara-lisp author-surface [`UpgradeInstruction::lisp_form`]
2223/// axis (kebab-case, with `:` prefix): the two axes carry different
2224/// bytes by design, and Rust convention pairs [`std::fmt::Display`]
2225/// with the wire byte-string every serde-carried CR / structured-log /
2226/// catalog identity reaches. The two-axis split is preserved
2227/// structurally by the pin
2228/// [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
2229/// so a future accidental collapse (routing `Display` through
2230/// [`Self::lisp_form`] via a mistaken match-arm re-inlining) trips at
2231/// caixa-core test time rather than silently merging the two axes at
2232/// some future consumer's per-instruction dispatch step.
2233///
2234/// Discards the per-variant scalar data (`module: String` on
2235/// `LoadModule` / `SoftPurge` / `Purge`; `script: PathBuf` on
2236/// `StateChange`) by design — the `Display` axis is the *tag*
2237/// projection, not a full value dump; consumers wanting the field
2238/// scalar reach for [`Self::declared_module`] /
2239/// [`Self::declared_path`] on the sibling scalar-accessor family. The
2240/// `{:?}` [`std::fmt::Debug`] derive stays untouched for callers that
2241/// want the full variant + field rendering.
2242impl std::fmt::Display for UpgradeInstruction {
2243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2244 f.write_str(self.as_str())
2245 }
2246}
2247
2248/// Substrate-canonical [`AsRef<str>`] projection on the M2 OTP-appup
2249/// per-instruction [`UpgradeInstruction`] closed-set typed enum —
2250/// routes through the same [`UpgradeInstruction::as_str`]
2251/// `pub const fn` scalar accessor the paired [`std::fmt::Display`]
2252/// impl and the un-`rename`d [`serde::Serialize`] derive already key
2253/// off, so any future consumer that binds an [`UpgradeInstruction`]
2254/// through the standard-library `impl AsRef<str>` bound (a deferred
2255/// wasm-operator per-instruction structured-log recorder that accepts
2256/// `impl AsRef<str>` at the `tracing::field::Value` `Str`-arm, a
2257/// [`std::collections::HashMap`] lookup keyed on the instruction wire
2258/// byte through `map.get::<str>(instr.as_ref())` on a future
2259/// per-instruction dispatch table an M4 admission webhook composes,
2260/// a [`std::process::Command::arg`] shell-out threading the instruction
2261/// tag through a deferred `feira upgrade-from --dry-run <kind>` verb)
2262/// reaches the same kebab-case wire byte-string the
2263/// [`Self::as_str`] accessor returns through one substrate-primitive
2264/// dispatch rather than an open-coded `.as_str()` projection at
2265/// every wire-up.
2266///
2267/// Peer of the sibling [`std::fmt::Display`] impl on the same
2268/// primitive — both delegate to the shared
2269/// [`UpgradeInstruction::as_str`] `pub const fn` accessor, so
2270/// `format!("{v}")`, `v.as_str()`, and
2271/// `<UpgradeInstruction as AsRef<str>>::as_ref(&v)` resolve to the
2272/// same byte-string per instance by construction. A future variant
2273/// rename or `#[serde(rename_all = "…")]` attribute-drift on the enum
2274/// reaches every one of the three paths (plus the wire-format
2275/// `Serialize` derive that already routes through the same kebab
2276/// vocabulary and the [`gen_platform::Discriminant`]-derived
2277/// [`Self::discriminant`] catalog identity) through exactly one
2278/// caixa-core edit — the [`Self::as_str`] match arms.
2279///
2280/// Same "route the trait impl through the substrate-primitive
2281/// accessor" discipline the sibling
2282/// [`crate::supervisor::RestartStrategy`] [`AsRef<str>`] impl
2283/// (63eb1a4), [`crate::supervisor::RestartPolicy`] [`AsRef<str>`]
2284/// impl (419ea81), [`crate::aplicacao::PlacementStrategy`]
2285/// [`AsRef<str>`] impl (d86edd2), [`crate::CaixaKind`]
2286/// [`AsRef<str>`] impl (cd2091f), [`crate::aplicacao::RateLimitUnit`]
2287/// [`AsRef<str>`] impl (d8136db), and [`crate::CaixaVersion`]
2288/// [`AsRef<str>`] impl (16d5c7e) carry — closes the substrate
2289/// primitive's [`AsRef<str>`] projection axis onto the last M2
2290/// OTP-shape closed-set typed enum on the caixa `:upgrade-from`
2291/// surface, so every closed-set typed enum on the caixa typed
2292/// surface now carries the paired [`AsRef<str>`] +
2293/// [`fmt::Display`] + `as_str` triple.
2294///
2295/// Pinned load-bearing by
2296/// [`tests::upgrade_instruction_as_ref_str_routes_through_as_str_accessor`]
2297/// — any future silent detour that routes the impl through a
2298/// divergent projection (a per-arm inline `match self { … }`
2299/// re-inlining that opens a compile-time link to the un-lifted arm-
2300/// literal, a swap onto the [`Self::lisp_form`] tatara-lisp axis
2301/// that would collide the wire axis with the author-surface axis)
2302/// trips at caixa-core test time under `assert_eq!` rather than at a
2303/// downstream `impl AsRef<str>`-bound consumer's silent split.
2304impl AsRef<str> for UpgradeInstruction {
2305 fn as_ref(&self) -> &str {
2306 self.as_str()
2307 }
2308}
2309
2310/// Reject upgrade instruction `:module` values that aren't K8s
2311/// DNS-1123 labels. Thin wrapper around
2312/// [`crate::render::is_dns_1123_label`] that maps the shared
2313/// parser-shaped reason into the kind-tagged
2314/// [`UpgradeError::ModuleEmpty`] / [`UpgradeError::ModuleInvalid`]
2315/// diagnostics, so the author can grep their caixa.lisp for the
2316/// offending `(:<kind> <module>)` form and fix it in one edit.
2317///
2318/// The contract — the same DNS-1123 label rule the K8s apiserver
2319/// enforces on every `metadata.name` / Service name / label value the
2320/// module name lands in. Each upgrade instruction's `:module` is a
2321/// reference to a caixa name (the wasm-engine resolves it through the
2322/// same `ComputeUnit` registry the operator manages), so the value must
2323/// match every downstream apiserver-side schema: the per-Servico
2324/// `wasm.pleme.io/v1alpha1/ComputeUnit.metadata.name` the operator
2325/// creates, the `LABEL_PROGRAM` label value the wasm-engine matches
2326/// against the loaded-module table at hot-upgrade dispatch, and the
2327/// future `:upgrade-from`-driven `app-operator` rolling-load CR's
2328/// per-module reference axis. Same trajectory as `:children :caixa`
2329/// (31bfa43), `:membros :caixa` (3f9d7a0), and `:placement :clusters`
2330/// (6cbb900) onto the fourth DNS-1123-label-shaped identifier axis —
2331/// appup's `LoadModule | SoftPurge | Purge` `:module` references.
2332///
2333/// Empty input is rejected via the narrower [`UpgradeError::ModuleEmpty`]
2334/// variant before this predicate is consulted, mirroring
2335/// `validate_membro_caixa`'s empty-first cascade.
2336fn validate_module(kind: &'static str, module: &str) -> Result<(), UpgradeError> {
2337 // Routes through the shared
2338 // [`crate::render::require_valid_dns_1123_label`] gate the peer
2339 // name axes each land on. The `kind: &'static str` field flows
2340 // through both error variants so the diagnostic names which
2341 // per-instruction slot (`LoadModule` / `SoftPurge` / `Purge`) the
2342 // offending value came from.
2343 crate::render::require_valid_dns_1123_label(
2344 module,
2345 || UpgradeError::module_empty(kind),
2346 |reason| UpgradeError::module_invalid(kind, module, reason),
2347 )
2348}
2349
2350#[derive(Debug, Error, PartialEq, Eq)]
2351pub enum UpgradeError {
2352 #[error(
2353 ":upgrade-from :from {from:?} is not a valid SemVer-2 version: {reason} (the substrate \
2354 consumes this string as `semver::Version` — three-part `MAJOR.MINOR.PATCH` with optional \
2355 `-prerelease` and `+build`, the same shape every top-level `:versao` carries — across \
2356 every artifact derived from `:from`: the wasm-operator's `:from`-match dispatch loads \
2357 the running version through `semver::Version::parse` and matches it against each entry's \
2358 `:from`, so a malformed `:from` is structurally unreachable at dispatch time; use a \
2359 SemVer-2 literal like `\"0.1.0\"`, `\"0.2.0-rc.1\"`, or `\"1.0.0+build.42\"` — not a \
2360 git-tag-shape like `\"v0.1.0\"`, a docker-tag-shape like `\"latest\"`, a \
2361 requirement-shape like `\"^0.1\"`, or a four-part `\"0.1.0.0\"`)"
2362 )]
2363 FromInvalid { from: String, reason: String },
2364 #[error(
2365 "upgrade instruction `{kind}` :module is empty (every appup module reference \
2366 must name a caixa; use a non-empty caixa name like `\"hello-rio\"` or omit \
2367 the instruction entirely)"
2368 )]
2369 ModuleEmpty { kind: &'static str },
2370 #[error(
2371 "upgrade instruction `{kind}` :module {module:?} is not a valid DNS-1123 label: \
2372 {reason} (every appup module reference resolves to a caixa name, which lands \
2373 verbatim as a K8s `metadata.name` on the per-Servico ComputeUnit the operator \
2374 creates, the `LABEL_PROGRAM` label value the wasm-engine matches at hot-upgrade \
2375 dispatch, and every future `app-operator` rolling-load CR's per-module reference \
2376 axis; use a lowercase alphanumeric + hyphen identifier like `\"hello-rio\"` or \
2377 `\"cache-v2\"`)"
2378 )]
2379 ModuleInvalid {
2380 kind: &'static str,
2381 module: String,
2382 reason: String,
2383 },
2384 #[error("instruction's :script is empty")]
2385 EmptyScript,
2386 #[error(
2387 "instruction's :script {} is absolute — upgrade scripts must be relative to the caixa \
2388 root (Path::join would otherwise escape the project sandbox)",
2389 script.display()
2390 )]
2391 AbsoluteScript { script: PathBuf },
2392 #[error(
2393 "instruction's :script {} contains a `..` component — upgrade scripts must not traverse \
2394 above the caixa root",
2395 script.display()
2396 )]
2397 ParentEscapeScript { script: PathBuf },
2398 #[error(
2399 ":upgrade-from (:state-change {}) does not terminate in the `.lisp` extension — the M2.5 \
2400 wasm-engine instantiator reads every migration script as tatara-lisp source through \
2401 `tatara_lisp::read` at hot-upgrade migration time (the same downstream consumer the \
2402 peer `:behavior :on-*` axis routes through at instance-start time, c97815a), so any \
2403 other extension (`.txt`, `.rs`, `.lisp.bak`) or no-extension shape is structurally a \
2404 parser error far from the source caixa.lisp, with no field naming the offending \
2405 `(:state-change …)` instruction. Pin a relative path under the caixa root whose \
2406 terminating extension is lowercase-`.lisp` (e.g. `\"lib/migrations.lisp\"`, \
2407 `\"lib/migrations/v01-to-v02.lisp\"`).",
2408 script.display()
2409 )]
2410 NonLispExtensionScript { script: PathBuf },
2411 #[error(
2412 ":upgrade-from carries more than one `(:from {from:?})` entry — OTP appup picks at most \
2413 one matching block per running version (`release_handler:install_release/1` dispatches \
2414 on the loaded `:from` against the currently-running release), so two entries with the \
2415 same parsed semver are an ambiguous edge in the typed upgrade graph (the operator would \
2416 pick either set non-deterministically). Author one path per prior version; if two \
2417 distinct instruction sequences are needed, fold them into one ordered list under the \
2418 single matching `(:from {from:?} :instructions (…))` block."
2419 )]
2420 DuplicateFrom { from: String },
2421 #[error(
2422 ":upgrade-from `(:from {from:?})` is not strictly less than the caixa's current \
2423 `:versao {versao:?}` under SemVer-2 precedence — an upgrade block whose `:from` is \
2424 greater than or equal to the caixa's own version is structurally unreachable \
2425 (the wasm-operator's `:from`-match dispatch loads the current `:versao` and matches \
2426 the running version against each entry's `:from`; an entry whose `:from >= :versao` \
2427 is never reached because the operator never runs a version greater than or equal to \
2428 the current one that it could then upgrade *to* the current one). Bump the caixa's \
2429 `:versao` past {from:?} (the typical fix — you added the entry intending to upgrade \
2430 *to* a new version but forgot to bump `:versao`), drop the entry (if it's a stale \
2431 reference left over from a reverted `:versao` bump), or correct `:from` to a prior \
2432 version (if it's a typo). Pre-release values like `\"0.2.0-rc.1\"` are strictly less \
2433 than the corresponding release `\"0.2.0\"` under SemVer §11 precedence; build-metadata \
2434 values like `\"0.2.0+build.1\"` are equal to `\"0.2.0\"` under precedence and rejected \
2435 here as a self-upgrade no-op."
2436 )]
2437 FromNotBeforeVersao { from: String, versao: String },
2438 #[error(
2439 ":upgrade-from `(:from {from:?})` :instructions list violates the `(:restart)` \
2440 exclusivity invariant — an entry containing `(:restart)` must contain exactly one \
2441 `(:restart)` and nothing else (found {restart_count} `(:restart)` plus other \
2442 instruction(s): {other_kinds:?}). Per the UpgradeInstruction::Restart doc comment, \
2443 `(:restart)` is the fallback for an entry whose typed upgrade is impossible (wasm \
2444 component-model world incompatibility, irreversible state shape change), and the \
2445 fallback is terminal by construction (the operator restarts the pod and the new \
2446 version comes up fresh). Mixing the fallback with the typed sequence is dead code \
2447 in both directions: if the typed instructions would succeed, `(:restart)` is \
2448 unreached; if they wouldn't, the typed instructions are dead because the operator \
2449 restarts anyway. Author *either* a typed sequence (`(:load-module …) \
2450 (:state-change …) (:soft-purge …)`) *or* a single `((:restart))` — never both, \
2451 never repeated. If two distinct upgrade strategies are needed for the same prior \
2452 version, that is itself a typed-graph ambiguity (the operator's `:from`-match \
2453 dispatch picks exactly one block per running version) — keep the typed sequence; \
2454 the fallback restart is what the operator does on any typed-sequence failure \
2455 already."
2456 )]
2457 RestartNotExclusive {
2458 from: String,
2459 restart_count: usize,
2460 other_kinds: Vec<&'static str>,
2461 },
2462 #[error(
2463 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` before any \
2464 `(:load-module …)` in its :instructions list — a state migration is the \
2465 gen_server:code_change/3 analog and must run in the context of the newly-loaded \
2466 code, but the operator executes instructions in declared order, so this migration \
2467 runs while the only resident version is still the prior one (which expects the \
2468 pre-migration state shape). Load the new module first: author the canonical \
2469 `(:load-module …) (:state-change {}) (:soft-purge …)` order so the new code is \
2470 resident before its state migration runs.",
2471 script.display(),
2472 script.display()
2473 )]
2474 StateChangeWithoutPriorLoad { from: String, script: PathBuf },
2475 #[error(
2476 ":upgrade-from `(:from {from:?})` runs `({kind} {module:?})` before any \
2477 `(:load-module …)` in its :instructions list — `:soft-purge` and `:purge` are the \
2478 code:soft_purge/1 / code:purge/1 analogs and must run after the new code is \
2479 resident alongside the old (OTP's two-phase code load: `code:load_module/1` \
2480 then `code:soft_purge/1`), but the operator executes instructions in declared \
2481 order, so this cleanup runs while the only resident version is still the same \
2482 old code (`:soft-purge` drains it to nothing; `:purge` discards it outright \
2483 mid-request), leaving no replacement to route in-flight or future requests \
2484 to. Load the new module first: author the canonical `(:load-module …) \
2485 (:state-change …) ({kind} {module:?})` order so the new code is resident \
2486 before the old code is drained or discarded."
2487 )]
2488 PurgeWithoutPriorLoad {
2489 from: String,
2490 kind: &'static str,
2491 module: String,
2492 },
2493 #[error(
2494 ":upgrade-from `(:from {from:?})` :instructions list targets module {module:?} with \
2495 more than one cleanup instruction ({kinds:?}) — `:soft-purge` and `:purge` are the \
2496 code:soft_purge/1 / code:purge/1 analogs (INSPIRATIONS §II.4: \"`code:soft_purge/1` — \
2497 wait until no process is running v1, then discard. (`code:purge/1` kills v1 immediately \
2498 if you don't care.)\"), and each module's old version is cleaned up by exactly one of \
2499 them: either drain-then-discard (`:soft-purge`) or immediate-discard (`:purge`), never \
2500 both, never repeated. systools-generated `.relup` files emit at most one purge per \
2501 module for this reason. A second cleanup on the same module is at best redundant (the \
2502 module is already gone after the first cleanup, so the second is a no-op or undefined \
2503 depending on the operator's handling of a non-resident-module purge request) and at \
2504 worst incoherent (mixing drain and discard semantics on one module suggests the author \
2505 wanted a fallback, but the operator runs declared instructions unconditionally — \
2506 fallback on cleanup failure is the operator's job, not authored into the entry). \
2507 Author one cleanup per module: prefer `(:soft-purge {module:?})` (waits for in-flight \
2508 callers to drain before GC); fall back to `(:purge {module:?})` only when the drain \
2509 can't complete (cron / oneShot / stuck callers). If two distinct old versions need \
2510 cleanup, name them distinctly (e.g. `(:soft-purge {module:?}) (:soft-purge \"…-older\")`)."
2511 )]
2512 DuplicateCleanup {
2513 from: String,
2514 module: String,
2515 kinds: Vec<&'static str>,
2516 },
2517 #[error(
2518 ":upgrade-from `(:from {from:?})` :instructions list loads module {module:?} more than \
2519 once — `:load-module` is the code:load_module/1 analog (INSPIRATIONS §II.4: \"1. \
2520 `code:load_module/1` — load v2 alongside v1; new code is 'current', old code is \
2521 'old'.\"), and the instruction binds the named wasm component once: the operator's \
2522 dispatch table reads the module name and brings up the corresponding component \
2523 alongside the running version. systools-generated `.relup` files emit at most one \
2524 `load_module` per module per upgrade step for this reason. A second `(:load-module \
2525 {module:?})` instruction has no observable semantic relative to the first (the \
2526 component is already resident) — either dead code (copy-pasted load line) or a typo \
2527 masking a distinct module the author intended to load alongside (renamed both to \
2528 {module:?} by mistake), leaving the second module silently absent from the entry. \
2529 Author one `(:load-module {module:?})` per old module per entry; if two distinct old \
2530 versions need loading alongside the running one, name them distinctly (e.g. \
2531 `(:load-module {module:?}) (:load-module \"…-v2\")`)."
2532 )]
2533 DuplicateLoadModule { from: String, module: String },
2534 #[error(
2535 ":upgrade-from `(:from {from:?})` :instructions list runs state migration {} more than \
2536 once — `:state-change` is the gen_server:code_change/3 analog (INSPIRATIONS §II.4: \
2537 \"State migration uses gen_server:code_change/3\"), and the script folds the prior-version \
2538 state shape into the current-version shape: a one-shot transition, not a step that \
2539 composes with itself. systools-generated `.relup` files emit at most one `code_change` \
2540 per gen_server per upgrade step for this reason; OTP's release_handler invokes the \
2541 callback exactly once. A second `(:state-change {})` instruction re-runs the same fold on \
2542 the already-migrated state — at best a no-op (idempotent script masking a typo where the \
2543 author intended two distinct migration scripts) and at worst silent state corruption \
2544 (non-idempotent fold double-applied: an `add column` that runs twice, an `increment \
2545 counter` that double-bumps, a `rename field` that renames-then-fails the second time). \
2546 Author one `(:state-change {})` per migration script per entry; if two distinct state \
2547 transitions are needed (e.g. one module's schema *and* another module's projection), \
2548 name them distinctly (e.g. `(:state-change {}) (:state-change \"lib/migrations/v01-to-v02-projection.lisp\")`).",
2549 script.display(),
2550 script.display(),
2551 script.display(),
2552 script.display()
2553 )]
2554 DuplicateStateChange { from: String, script: PathBuf },
2555 #[error(
2556 ":upgrade-from `(:from {from:?})` runs `(:state-change {})` after `({prior_cleanup_kind} \
2557 {prior_cleanup_module:?})` in its :instructions list — `:state-change` is the \
2558 gen_server:code_change/3 analog and folds the prior-version state shape into the \
2559 current shape, but the prior version's state only exists while the prior code is \
2560 still resident; `:soft-purge` and `:purge` are the code:soft_purge/1 / code:purge/1 \
2561 analogs and drain or discard that prior code. The operator executes instructions in \
2562 declared order, so a cleanup ahead of a state-change has already drained the prior \
2563 module to nothing (`:soft-purge`) or discarded it mid-request (`:purge`) by the time \
2564 the migration script runs, leaving the script either no-op (no prior-version state \
2565 left to fold) or crashing (`code_change/3` invoked on an unloaded version). The OTP \
2566 canonical sequence is `code:load_module/1` → `gen_server:code_change/3` → \
2567 `code:soft_purge/1`; the appup cookbook's recommended pattern is `[{{load_module, m}}, \
2568 {{update, m, soft}}, {{soft_purge, m}}]` with the migration-triggering `update` \
2569 strictly between load and cleanup. Author the canonical `(:load-module …) \
2570 (:state-change {}) ({prior_cleanup_kind} {prior_cleanup_module:?})` order so the \
2571 migration runs against the prior-version state before the cleanup drains it.",
2572 script.display(),
2573 script.display()
2574 )]
2575 StateChangeAfterCleanup {
2576 from: String,
2577 script: PathBuf,
2578 prior_cleanup_kind: &'static str,
2579 prior_cleanup_module: String,
2580 },
2581 #[error(
2582 ":upgrade-from `(:from {from:?})` declares `(:state-change {})` but the caixa does not \
2583 declare `:behavior :on-state-change` — the per-version migration script is the \
2584 gen_server:code_change/3 analog and the runtime hook it is delivered through during \
2585 hot upgrade is the `:on-state-change` callback. OTP's release_handler:install_release/1 \
2586 realizes the composition by invoking the running gen_server's code_change/3 callback \
2587 during the appup's `code_change` / `update, m, soft` step; caixa decomposes the same \
2588 composition into two typed slots, the per-version migration logic in this \
2589 `(:state-change …)` instruction's `:script` and the runtime dispatch hook in the \
2590 `:behavior :on-state-change` callback (the upgrade.rs module doc pins the composition \
2591 verbatim: \"Composes with the `:behavior :on-state-change` callback to deliver state \
2592 migration during hot upgrades\"). The missing callback leaves the per-version script \
2593 with no runtime delivery path: the operator's hot-upgrade dispatch reaches for the \
2594 callback at the migration step, finds it absent, and either fails the upgrade \
2595 mid-flight (the transactional rollback the module doc names — \"On any failure, the \
2596 current version stays load-bearing\") or silently skips the migration leaving the \
2597 new code running against unmigrated prior-version state. Add the callback: \
2598 `(:behavior ((:on-state-change \"lib/migrations.lisp\") …))` (the runtime delivery \
2599 path) alongside the existing `(:state-change {})` instruction (the per-version \
2600 script). If the upgrade truly carries no state migration, drop the `(:state-change \
2601 …)` instruction from the entry (a metadata-only upgrade — load + cleanup, no \
2602 migration — is the canonical shape).",
2603 script.display(),
2604 script.display()
2605 )]
2606 StateChangeWithoutOnStateChangeCallback { from: String, script: PathBuf },
2607}
2608
2609// Fold the three `UpgradeError::{StateChangeWithoutPriorLoad,
2610// DuplicateStateChange, StateChangeWithoutOnStateChangeCallback}
2611// { from: <prior-versao>.to_string(), script: <script>.to_path_buf() }`
2612// two-slot struct-variant wire-up sites at
2613// [`UpgradeFromEntry::validate_state_change_ordering`] (`self.prior_versao()`
2614// / `script` from `instr.declared_path()`),
2615// [`UpgradeFromEntry::validate_state_change_uniqueness`]
2616// (`self.prior_versao()` / `script.as_path()` from
2617// `instr.declared_path()`), and
2618// [`validate_state_change_on_state_change_callback`] (`entry.prior_versao()`
2619// / `script` from `instr.declared_path()`) onto one substrate primitive
2620// per typed variant — the paired `{ from: String, script: PathBuf }`
2621// two-slot sibling on [`UpgradeError`] of the peer
2622// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2623// variants on `{ caixa: String }`) on the sibling `SupervisorError`
2624// envelope, the peer [`crate::aplicacao::contrato_empty_pair_ctors!`]
2625// (8580068, 4 variants on `{ de, para }`),
2626// [`crate::aplicacao::contrato_target_ctors!`] (14b81d5, 2 variants on
2627// `{ de, para, wit, expected }`),
2628// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2629// variants on `{ <field>: String, reason: String }`), and
2630// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2631// variants on `{ de, para, <field>: String, reason: String }`) on the
2632// sibling `AplicacaoError` envelopes, and the peer
2633// [`crate::layout::layout_violation_ctors!`] (131ca0d, 16 variants on
2634// `{ caixa, issue }`), [`crate::layout::layout_slot_kind_ctors!`]
2635// (0419438, 4 variants on `{ caixa, kind, slots }`),
2636// [`crate::LayoutError::missing_entry`] (1b09f9d, one variant on
2637// `{ kind, path }`), and [`crate::layout::layout_nome_only_ctors!`]
2638// (3fe3dd7, 6 variants on `<Variant>(String)`) on the sibling
2639// `LayoutError` envelopes, plus the peer
2640// [`crate::limits::limits_codec_value_only_ctors!`] /
2641// [`crate::limits::limits_codec_value_byte_ctors!`] /
2642// [`crate::limits::limits_codec_value_char_ctors!`] (81c856c, 12 codec
2643// wire-ups) on the sibling `LimitsError` envelopes.
2644//
2645// Each of the three wire-up sites on this shape opens the identical
2646// `UpgradeError::<Variant> { from: <prior-versao>.to_string(),
2647// script: <script>.to_path_buf() }` struct-literal against a local
2648// `prior_versao()` and `declared_path()` accessor pair — the exact
2649// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
2650// names as a bug, on the same altitude the peer `SupervisorError` /
2651// `AplicacaoError` / `LayoutError` / `LimitsError` families each
2652// closed on their sibling envelopes. The three variants share one
2653// `{ from: String, script: PathBuf }` shape, so the fold routes each
2654// wire-up site through one dispatch per typed variant.
2655//
2656// The macro below generates one `#[must_use]` inherent constructor per
2657// variant of shape `fn <ctor>(from: &str, script: &std::path::Path) ->
2658// Self`, so every wire-up site collapses onto one dispatch:
2659// `UpgradeError::<ctor>(<prior-versao>, <script>)`, byte-equal to the
2660// pre-lift struct-literal on the same `(&str, &Path)` fixture. The
2661// uniform two-field construction (`from.to_string()` /
2662// `script.to_path_buf()`) is spelled once — inside the macro — rather
2663// than at every wire-up site. The `&Path` parameter accepts both
2664// `&Path` (from `script.as_path()` at the uniqueness gate) and
2665// `&PathBuf` (from `instr.declared_path()` at the ordering /
2666// callback-declaration gates, via Deref coercion), so every existing
2667// wire-up threads through the ctor without a pre-conversion.
2668//
2669// Every future consumer that wants to construct one of these three
2670// variants outside the three in-crate `UpgradeFromEntry` /
2671// `validate_state_change_on_state_change_callback` gates (a deferred
2672// wasm-operator's `install_release/1` per-entry ordering / uniqueness
2673// re-checker at hot-upgrade dispatch time, a future
2674// `feira validate --upgrade-from` per-caixa admission verb re-checking
2675// the three axes, a per-`Caixa` overlay resolver rejecting an
2676// ordering / uniqueness / callback-declaration invariant against a
2677// cluster-local snapshot) now reaches each variant through one call
2678// rather than re-inlining the three-line struct-literal in lockstep
2679// with the three in-crate wire-up sites.
2680macro_rules! upgrade_from_script_ctors {
2681 ($($ctor:ident => $variant:ident),* $(,)?) => {
2682 impl UpgradeError {
2683 $(
2684 #[doc = concat!(
2685 "Construct an [`UpgradeError::",
2686 stringify!($variant),
2687 "`] naming the offending `(:from <prior-versao>)` and ",
2688 "`(:state-change <script>)` pair. Folds the uniform ",
2689 "`Self::",
2690 stringify!($variant),
2691 " { from: from.to_string(), script: script.to_path_buf() }` ",
2692 "two-field struct-literal onto one substrate primitive so ",
2693 "every wire-up on this variant reads through one dispatch ",
2694 "rather than the pre-lift three-line open-coded block. The ",
2695 "`from` string threads verbatim from ",
2696 "[`UpgradeFromEntry::prior_versao`] and the `script` path ",
2697 "from [`UpgradeInstruction::declared_path`] at the call site."
2698 )]
2699 #[must_use]
2700 pub fn $ctor(from: &str, script: &std::path::Path) -> Self {
2701 Self::$variant {
2702 from: from.to_string(),
2703 script: script.to_path_buf(),
2704 }
2705 }
2706 )*
2707 }
2708 };
2709}
2710
2711upgrade_from_script_ctors! {
2712 state_change_without_prior_load => StateChangeWithoutPriorLoad,
2713 duplicate_state_change => DuplicateStateChange,
2714 state_change_without_on_state_change_callback => StateChangeWithoutOnStateChangeCallback,
2715}
2716
2717// Fold the three `UpgradeError::{AbsoluteScript, ParentEscapeScript,
2718// NonLispExtensionScript} { script: <script>.clone() }` single-slot
2719// struct-variant wire-up sites at [`UpgradeInstruction::validate`]'s
2720// three closures passed to [`crate::render::require_sandboxed_lisp_path`]
2721// onto one substrate primitive per typed variant — the paired
2722// `{ script: PathBuf }` single-slot sibling on [`UpgradeError`] of the
2723// sibling [`upgrade_from_script_ctors!`] (8e67041, 3 variants on
2724// `{ from: String, script: PathBuf }`) two-slot family on the same
2725// envelope, and of the peer
2726// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2727// variants on `{ caixa: String }`) and
2728// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2729// `{ nome: String }`) single-slot families on the sibling
2730// `SupervisorError` / `DepError` envelopes, and of the peer
2731// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2732// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2733// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2734// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2735// variants on `{ <field>: String, reason: String }`), and
2736// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2737// variants on `{ de, para, <field>: String, reason: String }`) on the
2738// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2739// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2740// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2741// [`crate::LayoutError::missing_entry`] 1b09f9d;
2742// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2743// `LimitsError` codec families (81c856c), and the sibling
2744// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2745// `{ nome, caminho }`) two-slot family.
2746//
2747// The three wire-up sites this fold closes are the three closures
2748// (`|| UpgradeError::AbsoluteScript { script: script.clone() }`,
2749// `|| UpgradeError::ParentEscapeScript { script: script.clone() }`,
2750// `|| UpgradeError::NonLispExtensionScript { script: script.clone() }`)
2751// passed to [`crate::render::require_sandboxed_lisp_path`] at
2752// [`UpgradeInstruction::validate`] — each opens the identical
2753// `UpgradeError::<Variant> { script: script.clone() }` three-line
2754// struct-literal against the same `script: &PathBuf` local threaded
2755// from [`UpgradeInstruction::declared_path`], the exact "same block
2756// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2757// bug. The three variants share one `{ script: PathBuf }` shape, so
2758// the fold routes each closure through one dispatch per typed variant.
2759// The sibling `EmptyScript` unit-variant on the same envelope stays on
2760// its pre-lift open-coded shape — it carries no `script` field (the
2761// offending `:script` value *is* the empty path this variant catches),
2762// so the uniform `fn(script: &Path) -> Self` signature this macro
2763// promises does not apply, and the peer helper's `|| Self::EmptyScript`
2764// closure is already a one-liner. This is the second fold family on
2765// the `UpgradeError` envelope (sibling of the [`upgrade_from_script_ctors!`]
2766// two-slot family established in 8e67041, which explicitly named this
2767// `{ script: PathBuf }` single-slot family as the next fold to land
2768// on the envelope; per that commit's coverage roster, both of the two
2769// most-populated shapes on `UpgradeError` — the two-slot
2770// `{ from, script }` and the one-slot `{ script }` — are now closed.)
2771//
2772// The macro below generates one `#[must_use]` inherent constructor per
2773// variant of shape `fn <ctor>(script: &std::path::Path) -> Self`, so
2774// every closure collapses onto one dispatch:
2775// `UpgradeError::<ctor>(script)`, byte-equal to the pre-lift
2776// struct-literal on the same `&Path` fixture. The uniform one-field
2777// construction (`script.to_path_buf()`) is spelled once — inside the
2778// macro — rather than at every wire-up site. The `&Path` parameter
2779// accepts both `&Path` (direct `Path::new(…)`) and `&PathBuf` (from
2780// `instr.declared_path()` at the three closures, via Deref coercion),
2781// so every existing closure threads through the ctor without a
2782// pre-conversion.
2783//
2784// Every future consumer that wants to construct one of these three
2785// variants outside the three in-crate closures (a deferred
2786// wasm-operator's `install_release/1` per-instruction script-shape
2787// re-checker at hot-upgrade dispatch time, a future
2788// `feira validate --upgrade-from` per-caixa admission verb re-checking
2789// the same script-shape axis, a per-`Caixa` overlay resolver rejecting
2790// an author-supplied `:state-change :script` against a cluster-local
2791// snapshot) now reaches each variant through one call rather than
2792// re-inlining the three-line struct-literal in lockstep with the three
2793// in-crate closure sites.
2794macro_rules! upgrade_script_only_ctors {
2795 ($($ctor:ident => $variant:ident),* $(,)?) => {
2796 impl UpgradeError {
2797 $(
2798 #[doc = concat!(
2799 "Construct an [`UpgradeError::",
2800 stringify!($variant),
2801 "`] naming the offending `(:state-change <script>)`. ",
2802 "Folds the uniform `Self::",
2803 stringify!($variant),
2804 " { script: script.to_path_buf() }` one-field ",
2805 "struct-literal onto one substrate primitive so every ",
2806 "closure passed to ",
2807 "[`crate::render::require_sandboxed_lisp_path`] at ",
2808 "[`UpgradeInstruction::validate`] on this variant reads ",
2809 "through one dispatch rather than the pre-lift three-line ",
2810 "open-coded block. The `script` path threads verbatim ",
2811 "from [`UpgradeInstruction::declared_path`] at the call ",
2812 "site."
2813 )]
2814 #[must_use]
2815 pub fn $ctor(script: &std::path::Path) -> Self {
2816 Self::$variant {
2817 script: script.to_path_buf(),
2818 }
2819 }
2820 )*
2821 }
2822 };
2823}
2824
2825upgrade_script_only_ctors! {
2826 absolute_script => AbsoluteScript,
2827 parent_escape_script => ParentEscapeScript,
2828 non_lisp_extension_script => NonLispExtensionScript,
2829}
2830
2831// Fold the three `UpgradeError::{FromInvalid, FromNotBeforeVersao,
2832// DuplicateLoadModule} { from: <from>.to_string(), <axis>:
2833// <value>.to_string() }` two-slot struct-variant wire-up sites at
2834// [`UpgradeFromEntry::validate`]'s per-`:from` SemVer-2 parse gate
2835// (`Version::parse(self.prior_versao()).map_err(|e| … FromInvalid
2836// { from: self.prior_versao().to_string(), reason: e.to_string() })`),
2837// [`UpgradeFromEntry::validate_load_singularity`]'s per-module
2838// dedup gate (`return Err(UpgradeError::DuplicateLoadModule { from:
2839// self.prior_versao().to_string(), module: module.to_string() });`),
2840// and [`validate_upgrade_from_against_versao`]'s per-`:from >= :versao`
2841// self-upgrade gate (`return Err(UpgradeError::FromNotBeforeVersao
2842// { from: entry.prior_versao().to_string(), versao: versao.to_string()
2843// });`) onto one substrate-primitive family per typed variant — the
2844// missing paired two-slot rung on the `UpgradeError`-side four-family
2845// ladder ([`upgrade_script_only_ctors!`] (7468ca9) one-slot
2846// `{ script: PathBuf }` → this two-slot `{ from: String, <axis>: String }`
2847// → [`upgrade_from_script_ctors!`] (8e67041) two-slot `{ from: String,
2848// script: PathBuf }`), and mirror-symmetric sibling of the peer
2849// [`crate::dep::dep_nome_axis_ctors!`] (7f7c950) two-slot `{ nome:
2850// String, <axis>: String }` fold on the `DepError` envelope — same
2851// `<axis>: <value>.to_string()` owned-forward payload shape, `nome`
2852// axis renamed `from` at the per-`:upgrade-from :from`-owned altitude
2853// the `UpgradeError` envelope keys off (every `UpgradeError` variant
2854// carries the offending prior-version `:from` verbatim so the author
2855// can grep their caixa.lisp for the offending `(:from "<value>")` /
2856// `(:load-module …)` / `:versao` block in one edit). The three
2857// variants share the same `{ from: String, <axis>: String }` two-slot
2858// shape: the `from` field names the offending per-`:upgrade-from` block's
2859// prior-version tag the diagnostic points the author back at, and the
2860// middle `<axis>: String` field carries the offending per-envelope axis
2861// value verbatim (`reason` on `FromInvalid` carries the wrapped
2862// `semver::Version::parse` error message that pinpoints why the tag
2863// failed SemVer-2; `versao` on `FromNotBeforeVersao` carries the caixa's
2864// own current-`:versao` the entry's `:from` failed to precede; `module`
2865// on `DuplicateLoadModule` carries the caixa name the second
2866// `(:load-module …)` instruction re-loaded within the same entry).
2867// The middle axis-field name differs across variants (`reason` /
2868// `versao` / `module`) so the ctor family below takes the axis field
2869// name as a macro parameter (`$axis:ident`) alongside the ctor +
2870// variant names, generating one `pub fn $ctor(from: &str, $axis: &str)
2871// -> Self` inherent constructor per typed variant that spells the
2872// uniform two-field construction (`from.to_string()` /
2873// `<axis>.to_string()`) exactly once.
2874//
2875// Peer of the sibling [`upgrade_from_script_ctors!`] (8e67041, 3
2876// variants on `{ from: String, script: PathBuf }`) two-slot family on
2877// the same envelope — both key off the same `from: String` axis at the
2878// same per-`:upgrade-from :from`-owned altitude; this family carries the
2879// owned-`String` second axis (per-`reason` / per-`versao` / per-`module`
2880// carrier) where the script-slot family carries the owned-`PathBuf`
2881// second axis. Peer also of the sibling [`upgrade_script_only_ctors!`]
2882// (7468ca9, 3 variants on `{ script: PathBuf }`) one-slot family on the
2883// same envelope, of the sibling
2884// [`crate::supervisor::supervisor_caixa_only_ctors!`] (db09650, 3
2885// variants on `{ caixa: String }`) and
2886// [`crate::dep::dep_nome_only_ctors!`] (792aa92, 5 variants on
2887// `{ nome: String }`) single-slot families on the sibling
2888// `SupervisorError` / `DepError` envelopes, and of the peer
2889// [`crate::aplicacao::contrato_empty_pair_ctors!`] (8580068, 4 variants
2890// on `{ de, para }`), [`crate::aplicacao::contrato_target_ctors!`]
2891// (14b81d5, 2 variants on `{ de, para, wit, expected }`),
2892// [`crate::aplicacao::aplicacao_field_reason_ctors!`] (981060b, 7
2893// variants on `{ <field>: String, reason: String }`),
2894// [`crate::aplicacao::aplicacao_caixa_only_ctors!`] (d9f6867, 5
2895// variants on `{ caixa: String }`),
2896// [`crate::aplicacao::aplicacao_path_only_ctors!`] (3ba8de6, 3 variants
2897// on `{ path: String }`), and
2898// [`crate::aplicacao::contrato_pair_value_reason_ctors!`] (14e13f1, 3
2899// variants on `{ de, para, <field>: String, reason: String }`) on the
2900// sibling `AplicacaoError` envelopes, plus the peer four `LayoutError`
2901// families ([`crate::layout::layout_violation_ctors!`] 131ca0d;
2902// [`crate::layout::layout_slot_kind_ctors!`] 0419438;
2903// [`crate::LayoutError::missing_entry`] 1b09f9d;
2904// [`crate::layout::layout_nome_only_ctors!`] 3fe3dd7), the three
2905// `LimitsError` codec families (81c856c), the sibling
2906// [`crate::dep::fonte_caminho_ctors!`] (f85f145, 11 variants on
2907// `{ nome, caminho }`), [`crate::dep::fonte_caminho_byte_ctors!`]
2908// (0e35793, 12 variants on `{ nome, caminho, byte }`),
2909// [`crate::dep::dep_nome_list_ctors!`] (6f5e0cd, 4 variants on
2910// `{ nome, list: &'static str }`), and
2911// [`crate::dep::dep_nome_axis_reason_ctors!`] (5621f8a, 3 variants on
2912// `{ nome, <axis>: String, reason: String }`) families.
2913//
2914// Each of the three wire-up sites on this shape opens the identical
2915// `UpgradeError::<Variant> { from: <from>.to_string(), <axis>:
2916// <value>.to_string() }` four-line struct-literal against a local
2917// `(prior_versao(), <axis-value>)` pair threaded from
2918// [`UpgradeFromEntry::prior_versao`] (or, at the
2919// [`validate_upgrade_from_against_versao`] site, directly from the
2920// caller-supplied `versao: &str` argument) — the exact "same block
2921// re-inlined at every consumer" shape the PRIME DIRECTIVE names as a
2922// bug, on the same altitude the peer sibling `upgrade_from_script_ctors!`
2923// / `upgrade_script_only_ctors!` families closed on the sibling
2924// `{ from, script }` / `{ script }` shape-envelopes. The three variant /
2925// axis-field discriminators are the only things that vary between them;
2926// the rest of the struct-literal is a byte-for-byte re-inline.
2927//
2928// The macro below generates one `#[must_use]` inherent constructor per
2929// variant of shape `fn <ctor>(from: &str, <axis>: &str) -> Self`, so
2930// every wire-up site collapses onto one dispatch:
2931// `UpgradeError::<ctor>(<from>, <axis-value>)`, byte-equal to the
2932// pre-lift struct-literal on the same `(&str, &str)` fixture. Both
2933// parameters accept `&str` literals and `&String` (via Deref coercion)
2934// so every existing wire-up threads through the ctor without a
2935// pre-conversion.
2936//
2937// Every future consumer that wants to construct one of these three
2938// variants outside the three in-crate `UpgradeFromEntry::validate` /
2939// `validate_load_singularity` / `validate_upgrade_from_against_versao`
2940// gates (a deferred wasm-operator's `install_release/1` per-entry
2941// `:from`-parse / per-`:load-module` singularity / per-entry
2942// `:from < :versao` re-checker at hot-upgrade dispatch time, a future
2943// `feira validate --upgrade-from` per-caixa admission verb re-checking
2944// the three axes, a per-`Caixa` overlay resolver rejecting a
2945// `:from`-shape / `:load-module`-singularity / `:from < :versao`
2946// invariant against a cluster-local snapshot) now reaches each variant
2947// through one call rather than re-inlining the four-line struct-literal
2948// in lockstep with the three in-crate wire-up sites.
2949macro_rules! upgrade_from_axis_ctors {
2950 ($($ctor:ident => $variant:ident { $axis:ident }),* $(,)?) => {
2951 impl UpgradeError {
2952 $(
2953 #[doc = concat!(
2954 "Construct an [`UpgradeError::",
2955 stringify!($variant),
2956 "`] naming the offending `(:from <prior-versao>)` and ",
2957 "the offending `:", stringify!($axis), "` axis value. ",
2958 "Folds the uniform `Self::",
2959 stringify!($variant),
2960 " { from: from.to_string(), ",
2961 stringify!($axis),
2962 ": ",
2963 stringify!($axis),
2964 ".to_string() }` two-field struct-literal onto one ",
2965 "substrate primitive so every in-crate wire-up on ",
2966 "this variant reads through one dispatch rather than ",
2967 "the pre-lift four-line open-coded block. Both `from: ",
2968 "&str` and `",
2969 stringify!($axis),
2970 ": &str` parameters accept `&str` literals and ",
2971 "`&String` (via Deref coercion) so every existing ",
2972 "wire-up threads through the ctor without a pre-",
2973 "conversion."
2974 )]
2975 #[must_use]
2976 pub fn $ctor(from: &str, $axis: &str) -> Self {
2977 Self::$variant {
2978 from: from.to_string(),
2979 $axis: $axis.to_string(),
2980 }
2981 }
2982 )*
2983 }
2984 };
2985}
2986
2987upgrade_from_axis_ctors! {
2988 from_invalid => FromInvalid { reason },
2989 from_not_before_versao => FromNotBeforeVersao { versao },
2990 duplicate_load_module => DuplicateLoadModule { module },
2991}
2992
2993// Fold the last open-coded `UpgradeError::DuplicateFrom { from:
2994// entry.prior_versao().to_string() }` one-slot struct-literal inside
2995// [`validate_upgrade_from`]'s cross-entry `:from`-duplicate gate onto
2996// one substrate primitive on the [`UpgradeError`] envelope, projecting
2997// through the paired [`UpgradeFromEntry::prior_versao`] scalar accessor
2998// on the substrate primitive. The `DuplicateFrom` variant is the last
2999// unlifted single-slot `{ from: String }` envelope on `UpgradeError` —
3000// every peer envelope shape (`{ script: PathBuf }` one-slot via
3001// [`upgrade_script_only_ctors!`] 7468ca9; `{ from: String, <axis>:
3002// String }` two-slot via [`upgrade_from_axis_ctors!`] 41d08db; `{ from:
3003// String, script: PathBuf }` two-slot via [`upgrade_from_script_ctors!`]
3004// 8e67041) already reads through one substrate-primitive dispatch, so
3005// this fold closes the last one-off single-slot on the envelope.
3006//
3007// Peer of the sibling standalone-ctor `AplicacaoError::contrato_self_loop`
3008// (b30edfe) on the paired [`WitContract`] projection — same
3009// `pub fn <ctor>(primitive: &<Primitive>) -> Self` shape, projecting
3010// through the substrate primitive's own scalar accessor rather than
3011// re-inlining the `.to_string()` at the call site. Extended here onto
3012// the sibling [`UpgradeFromEntry`] scalar-accessor family the closed
3013// M2 companion of the M3 mesh-slot accessors (see
3014// [`UpgradeFromEntry::prior_versao`] doc — sibling in shape to
3015// [`crate::Membro::versao_requirement`] a40b0e3, [`crate::Membro::nome`]
3016// 4a32abf, and the [`crate::WitContract::{source, destination,
3017// world_ref}`] 7f0fd43 / 0804823 / [`crate::Entrada::{hostname,
3018// destination}`] 11f3dfe / 6db982c `&str` accessors) established.
3019//
3020// The one wire-up site this fold closes opens the identical
3021// `UpgradeError::DuplicateFrom { from: entry.prior_versao().to_string() }`
3022// three-line struct-literal against the `entry: &UpgradeFromEntry` local
3023// threaded from [`validate_upgrade_from`]'s per-entry loop — the exact
3024// "same block re-inlined at every consumer" shape the PRIME DIRECTIVE
3025// names as a bug, on the same altitude the peer `contrato_self_loop`
3026// closed on the sibling `{ caixa: String, wit: String }` two-slot
3027// envelope inside `impl AplicacaoSpec`. The `entry: &UpgradeFromEntry`
3028// parameter accepts the borrowed entry verbatim so the wire-up site
3029// threads through the ctor without a pre-projection — the ctor body
3030// spells the paired `prior_versao().to_string()` projection once.
3031//
3032// Every future consumer that wants to construct this variant outside
3033// `validate_upgrade_from`'s cross-entry duplicate gate — a deferred
3034// wasm-operator's `install_release/1` cross-entry `:from`-duplicate
3035// re-checker at hot-upgrade dispatch time rejecting a second entry
3036// with the same prior-versao tag, a future `feira validate --upgrade-
3037// from` per-caixa admission verb re-running the cross-entry duplicate
3038// pass on demand, a per-`Caixa` overlay resolver rejecting an author-
3039// supplied duplicate `(:from "<value>")` against a cluster-local
3040// snapshot — now reaches the variant through one call rather than
3041// re-inlining the three-line struct-literal in lockstep with the one
3042// in-crate wire-up site.
3043impl UpgradeError {
3044 /// Construct an [`UpgradeError::DuplicateFrom`] naming the offending
3045 /// duplicate `(:from <prior-versao>)` entry, projecting through the
3046 /// paired [`UpgradeFromEntry::prior_versao`] scalar accessor on the
3047 /// substrate primitive. Folds the uniform `Self::DuplicateFrom {
3048 /// from: entry.prior_versao().to_string() }` one-field struct-literal
3049 /// onto one substrate primitive so every wire-up on this variant
3050 /// reads through one dispatch, matching the sibling
3051 /// [`crate::AplicacaoError::contrato_self_loop`] (b30edfe)
3052 /// substrate-primitive-projection ctor's shape on the peer
3053 /// [`AplicacaoError`] envelope. The `entry: &UpgradeFromEntry`
3054 /// parameter accepts the borrowed entry verbatim so the paired
3055 /// `prior_versao().to_string()` projection is spelled once — inside
3056 /// the ctor body — rather than at every wire-up site.
3057 #[must_use]
3058 pub fn duplicate_from(entry: &UpgradeFromEntry) -> Self {
3059 Self::DuplicateFrom {
3060 from: entry.prior_versao().to_string(),
3061 }
3062 }
3063
3064 /// Construct an [`UpgradeError::PurgeWithoutPriorLoad`] naming the
3065 /// offending `(:from <prior-versao>)` entry, the offending cleanup
3066 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`), and
3067 /// its `:module` target. Folds the uniform
3068 /// `Self::PurgeWithoutPriorLoad { from: from.to_string(), kind,
3069 /// module: module.to_string() }` three-field struct-literal onto one
3070 /// substrate primitive so every wire-up on this sole-variant
3071 /// cleanup-family load-before-cleanup ordering-refusal envelope reads
3072 /// through one dispatch rather than the pre-lift seven-line
3073 /// open-coded block.
3074 ///
3075 /// The `from: &str` parameter accepts `&str` literals and `&String`
3076 /// via Deref coercion so the sole in-crate wire-up site threads
3077 /// [`UpgradeFromEntry::prior_versao`] verbatim without a
3078 /// pre-conversion. The `kind: &'static str` parameter accepts the
3079 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3080 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3081 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3082 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3083 /// re-projection at the ctor path. The `module: &str` parameter
3084 /// takes the `&str` [`UpgradeInstruction::declared_module`] returns
3085 /// via `.expect("is_cleanup() implies declared_module() is Some")`
3086 /// at the caller — the `is_cleanup`-implies-`declared_module`-is-
3087 /// `Some` composition pin at
3088 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3089 /// makes the `.expect(…)` structurally infallible at build time.
3090 ///
3091 /// Peer of the sibling one-off standalone-ctor
3092 /// [`UpgradeError::duplicate_from`] on the paired one-slot `{ from:
3093 /// String }` envelope on the same `UpgradeError` envelope, and of
3094 /// the sibling `AplicacaoError::contrato_endpoint_not_absolute`
3095 /// (cdf1a2c) three-slot `{ de, para, endpoint: String }` sole-
3096 /// variant standalone ctor on the peer `AplicacaoError` envelope.
3097 /// Closes the last unlifted `{ from: String, kind: &'static str,
3098 /// module: String }` three-slot open-coded struct-literal wire-up
3099 /// on the OTP-appup load-before-cleanup ordering axis, sibling of
3100 /// the peer sub-family generated by [`upgrade_from_axis_ctors!`]
3101 /// (41d08db, three variants on `{ from: String, <axis>: String }`)
3102 /// on the paired ordering / uniqueness / callback-declaration axes,
3103 /// and of the peer standalone [`UpgradeError::duplicate_from`]
3104 /// (7e52aec) one-slot ctor on the sibling cross-entry duplicate-
3105 /// `:from` gate. Every future consumer that raises this refusal
3106 /// outside `UpgradeFromEntry::validate_purge_ordering` — a deferred
3107 /// wasm-operator's `install_release/1` per-entry load-before-cleanup
3108 /// re-checker at hot-upgrade dispatch time, a future
3109 /// `feira validate --upgrade-from` per-caixa admission verb
3110 /// re-running the load-before-cleanup gate on demand, a per-`Caixa`
3111 /// overlay resolver rejecting a cluster-local `:soft-purge` /
3112 /// `:purge` overlay lacking a preceding `:load-module` — reaches
3113 /// the variant through one call rather than re-inlining the
3114 /// seven-line struct-literal in lockstep with the sole in-crate
3115 /// wire-up site.
3116 #[must_use]
3117 pub fn purge_without_prior_load(from: &str, kind: &'static str, module: &str) -> Self {
3118 Self::PurgeWithoutPriorLoad {
3119 from: from.to_string(),
3120 kind,
3121 module: module.to_string(),
3122 }
3123 }
3124
3125 /// Construct an [`UpgradeError::StateChangeAfterCleanup`] naming the
3126 /// offending `(:from <prior-versao>)` entry, the offending
3127 /// `(:state-change …)` `:script` path, and the prior cleanup
3128 /// instruction's `:kind` lisp-form (`:soft-purge` / `:purge`) +
3129 /// `:module` target. Folds the uniform
3130 /// `Self::StateChangeAfterCleanup { from: from.to_string(), script:
3131 /// script.to_path_buf(), prior_cleanup_kind, prior_cleanup_module:
3132 /// prior_cleanup_module.to_string() }` four-field struct-literal
3133 /// onto one substrate primitive so every wire-up on this sole-
3134 /// variant migrate-after-cleanup ordering-refusal envelope reads
3135 /// through one dispatch rather than the pre-lift seven-line open-
3136 /// coded block. Closes the last unlifted `{ from: String, script:
3137 /// PathBuf, prior_cleanup_kind: &'static str, prior_cleanup_module:
3138 /// String }` four-slot open-coded struct-literal wire-up on the
3139 /// OTP-appup migrate-before-cleanup ordering axis, filling the
3140 /// missing four-slot rung on the `UpgradeError`-side ctor-family
3141 /// ladder alongside the sibling one-slot
3142 /// [`UpgradeError::duplicate_from`] (7e52aec) and three-slot
3143 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3144 /// ctors, the two-slot [`upgrade_from_axis_ctors!`] (41d08db) /
3145 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated
3146 /// families, and the one-slot [`upgrade_script_only_ctors!`]
3147 /// (7468ca9) family. Sole in-crate wire-up site is inside
3148 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
3149 /// migrate-family sticky-latch dispatch — the third of three
3150 /// within-entry cross-instruction OTP-appup ordering gates the
3151 /// module doc pins (`validate_state_change_ordering` on the load →
3152 /// migrate boundary via [`upgrade_from_script_ctors!`]-generated
3153 /// `state_change_without_prior_load`; `validate_purge_ordering` on
3154 /// the load → cleanup boundary via `purge_without_prior_load`;
3155 /// `validate_state_change_before_cleanup` on the migrate → cleanup
3156 /// boundary via this ctor — now).
3157 ///
3158 /// The `from: &str` parameter accepts `&str` literals and `&String`
3159 /// via Deref coercion so the sole in-crate wire-up site threads
3160 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3161 /// without a pre-conversion. The `script: &std::path::Path`
3162 /// parameter accepts `&Path` (direct `Path::new(…)`) and `&PathBuf`
3163 /// (from [`UpgradeInstruction::declared_path`]'s `Option<&PathBuf>`
3164 /// via Deref coercion) so the wire-up threads the sticky-latch
3165 /// script projection through the ctor without a pre-conversion; the
3166 /// uniform `script.to_path_buf()` one-field construction is spelled
3167 /// once — inside the ctor body — rather than at every wire-up site.
3168 /// The `prior_cleanup_kind: &'static str` parameter accepts the
3169 /// lisp-form `&'static str` [`UpgradeInstruction::lisp_form`]
3170 /// returns for the two [`UpgradeInstruction::is_cleanup`] arms —
3171 /// `M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3172 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE` — verbatim without a per-arm
3173 /// re-projection at the ctor path. The `prior_cleanup_module: &str`
3174 /// parameter takes the `&str` [`UpgradeInstruction::declared_module`]
3175 /// returns via `.expect("is_cleanup() implies declared_module() is
3176 /// Some")` at the caller — the `is_cleanup`-implies-`declared_module`-
3177 /// is-`Some` composition pin at
3178 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3179 /// makes the `.expect(…)` structurally infallible at build time.
3180 ///
3181 /// Every future consumer that raises this refusal outside
3182 /// [`UpgradeFromEntry::validate_state_change_before_cleanup`] — a
3183 /// deferred wasm-operator's `install_release/1` per-entry
3184 /// migrate-before-cleanup re-checker at hot-upgrade dispatch time,
3185 /// a future `feira validate --upgrade-from` per-caixa admission verb
3186 /// re-running the migrate-before-cleanup gate on demand, a
3187 /// per-`Caixa` overlay resolver rejecting a cluster-local
3188 /// `:state-change` overlay authored after a `:soft-purge` /
3189 /// `:purge`, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission
3190 /// webhook re-checking a per-`:upgrade-from`-patched candidate
3191 /// before the migrate-before-cleanup gate re-fires — reaches the
3192 /// variant through one call rather than re-inlining the seven-line
3193 /// struct-literal in lockstep with the sole in-crate wire-up site.
3194 #[must_use]
3195 pub fn state_change_after_cleanup(
3196 from: &str,
3197 script: &std::path::Path,
3198 prior_cleanup_kind: &'static str,
3199 prior_cleanup_module: &str,
3200 ) -> Self {
3201 Self::StateChangeAfterCleanup {
3202 from: from.to_string(),
3203 script: script.to_path_buf(),
3204 prior_cleanup_kind,
3205 prior_cleanup_module: prior_cleanup_module.to_string(),
3206 }
3207 }
3208
3209 /// Construct an [`UpgradeError::DuplicateCleanup`] naming the
3210 /// offending `(:from <prior-versao>)` entry, the colliding `:module`
3211 /// target, and the ordered pair of colliding cleanup `:kind` lisp-
3212 /// forms (`:soft-purge` / `:purge`). Folds the uniform
3213 /// `Self::DuplicateCleanup { from: from.to_string(), module:
3214 /// module.to_string(), kinds }` three-field struct-literal onto one
3215 /// substrate primitive so every wire-up on this sole-variant within-
3216 /// entry per-module cleanup-singularity refusal envelope reads
3217 /// through one dispatch rather than the pre-lift five-line open-coded
3218 /// block. Closes the last unlifted `{ from: String, module: String,
3219 /// kinds: Vec<&'static str> }` three-slot open-coded struct-literal
3220 /// wire-up on the OTP-appup per-module cleanup-singularity axis,
3221 /// filling a peer three-slot rung on the `UpgradeError`-side ctor-
3222 /// family ladder alongside the sibling three-slot
3223 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3224 /// ctor on the paired within-entry load → cleanup ordering axis, the
3225 /// one-slot [`UpgradeError::duplicate_from`] (7e52aec) standalone
3226 /// ctor on the sibling cross-entry duplicate-`:from` gate, the four-
3227 /// slot [`UpgradeError::state_change_after_cleanup`] (be68237)
3228 /// standalone ctor on the migrate → cleanup boundary, the two-slot
3229 /// [`upgrade_from_axis_ctors!`] (41d08db) /
3230 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3231 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3232 /// Sole in-crate wire-up site is inside
3233 /// [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
3234 /// cleanup-family dedup arm.
3235 ///
3236 /// The `from: &str` parameter accepts `&str` literals and `&String`
3237 /// via Deref coercion so the sole in-crate wire-up threads
3238 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3239 /// without a pre-conversion. The `module: &str` parameter takes the
3240 /// `&str` [`UpgradeInstruction::declared_module`] returns via
3241 /// `.expect("is_cleanup() implies declared_module() is Some")` at the
3242 /// caller — the `is_cleanup`-implies-`declared_module`-is-`Some`
3243 /// composition pin at
3244 /// [`tests::upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
3245 /// makes the `.expect(…)` structurally infallible at build time. The
3246 /// `kinds: Vec<&'static str>` parameter takes the ordered pair
3247 /// `vec![prior_kind, kind]` built at the caller from the two
3248 /// [`UpgradeInstruction::lisp_form`] `&'static str` returns
3249 /// (`M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE` /
3250 /// `M2_UPGRADE_INSTRUCTION_KIND_PURGE`) — the same substrate-
3251 /// primitive `&'static str` projection the paired three-slot
3252 /// [`UpgradeError::purge_without_prior_load`] ctor threads on the
3253 /// sibling load → cleanup ordering axis.
3254 ///
3255 /// Every future consumer that raises this refusal outside
3256 /// [`UpgradeFromEntry::validate_cleanup_singularity`] — a deferred
3257 /// wasm-operator's `install_release/1` per-entry per-module
3258 /// cleanup-singularity re-checker at hot-upgrade dispatch time, a
3259 /// future `feira validate --upgrade-from` per-caixa admission verb
3260 /// re-running the singularity pass on demand, a per-`Caixa` overlay
3261 /// resolver rejecting a cluster-local `:soft-purge` / `:purge`
3262 /// overlay that collides with a base-entry cleanup on the same
3263 /// module, the M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3264 /// re-checking a per-`:upgrade-from`-patched candidate before the
3265 /// singularity gate re-fires — reaches the variant through one call
3266 /// rather than re-inlining the five-line struct-literal in lockstep
3267 /// with the sole in-crate wire-up site.
3268 #[must_use]
3269 pub fn duplicate_cleanup(from: &str, module: &str, kinds: Vec<&'static str>) -> Self {
3270 Self::DuplicateCleanup {
3271 from: from.to_string(),
3272 module: module.to_string(),
3273 kinds,
3274 }
3275 }
3276
3277 /// Construct an [`UpgradeError::RestartNotExclusive`] naming the
3278 /// offending `(:from <prior-versao>)` entry, the observed `(:restart)`
3279 /// instruction count, and the ordered list of non-`:restart`
3280 /// instruction lisp-forms the entry mixed with the terminal fallback.
3281 /// Folds the uniform `Self::RestartNotExclusive { from: from.to_string(),
3282 /// restart_count, other_kinds }` three-field struct-literal onto one
3283 /// substrate primitive so every wire-up on this sole-variant within-
3284 /// entry `(:restart)`-exclusivity refusal envelope reads through one
3285 /// dispatch rather than the pre-lift five-line open-coded block. Closes
3286 /// the last unlifted `{ from: String, restart_count: usize, other_kinds:
3287 /// Vec<&'static str> }` three-slot open-coded struct-literal wire-up on
3288 /// the OTP-appup within-entry `(:restart)`-fallback-exclusivity axis —
3289 /// the last-remaining open-coded emission site the sibling
3290 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) commit body pinned as
3291 /// the natural next lift on the `UpgradeError` envelope. Fills a peer
3292 /// three-slot rung on the `UpgradeError`-side ctor-family ladder
3293 /// alongside the sibling three-slot
3294 /// [`UpgradeError::purge_without_prior_load`] (9752da1) standalone
3295 /// ctor on the paired within-entry load → cleanup ordering axis and
3296 /// [`UpgradeError::duplicate_cleanup`] (10a5b48) standalone ctor on
3297 /// the per-module cleanup-singularity axis, the one-slot
3298 /// [`UpgradeError::duplicate_from`] (7e52aec) standalone ctor on the
3299 /// cross-entry duplicate-`:from` gate, the four-slot
3300 /// [`UpgradeError::state_change_after_cleanup`] (be68237) standalone
3301 /// ctor on the migrate → cleanup boundary, the two-slot
3302 /// [`upgrade_from_axis_ctors!`] (41d08db) /
3303 /// [`upgrade_from_script_ctors!`] (8e67041) macro-generated families,
3304 /// and the one-slot [`upgrade_script_only_ctors!`] (7468ca9) family.
3305 /// Sole in-crate wire-up site is inside
3306 /// [`UpgradeFromEntry::validate_restart_exclusive`]'s mixed-`(:restart)`
3307 /// arm.
3308 ///
3309 /// The `from: &str` parameter accepts `&str` literals and `&String`
3310 /// via Deref coercion so the sole in-crate wire-up threads
3311 /// [`UpgradeFromEntry::prior_versao`] (a `&str` accessor) verbatim
3312 /// without a pre-conversion. The `restart_count: usize` parameter
3313 /// takes the observed `(:restart)` occurrence count built at the
3314 /// caller from `instructions.iter().filter(|i| i.is_restart()).count()`
3315 /// — the same `IsVariant`-derived arm-discriminator dispatch the
3316 /// paired `other_kinds` projection routes through — so the diagnostic
3317 /// surfaces the duplication mode unambiguously even when `other_kinds`
3318 /// is empty (the `((:restart) (:restart))` shape the sibling
3319 /// `validate_rejects_restart_duplicated` test pins with
3320 /// `restart_count: 2, other_kinds: vec![]`). The `other_kinds:
3321 /// Vec<&'static str>` parameter takes the ordered list of non-
3322 /// `:restart` instruction lisp-forms built at the caller from
3323 /// `instructions.iter().filter(|i| !i.is_restart()).map(
3324 /// UpgradeInstruction::lisp_form).collect()` — the same substrate-
3325 /// primitive `&'static str` projection the peer three-slot
3326 /// [`UpgradeError::purge_without_prior_load`] /
3327 /// [`UpgradeError::duplicate_cleanup`] ctors thread on the sibling
3328 /// within-entry cleanup axes.
3329 ///
3330 /// Every future consumer that raises this refusal outside
3331 /// [`UpgradeFromEntry::validate_restart_exclusive`] — a deferred
3332 /// wasm-operator's `install_release/1` per-entry `(:restart)`-
3333 /// exclusivity re-checker at hot-upgrade dispatch time, a future
3334 /// `feira validate --upgrade-from` per-caixa admission verb re-running
3335 /// the exclusivity pass on demand, a per-`Caixa` overlay resolver
3336 /// rejecting a cluster-local `(:restart)` overlay that mixes with a
3337 /// base-entry typed sequence, the M4 `mesh.pleme.io/v1alpha1/Caixa`
3338 /// CR admission webhook re-checking a per-`:upgrade-from`-patched
3339 /// candidate before the exclusivity gate re-fires — reaches the
3340 /// variant through one call rather than re-inlining the five-line
3341 /// struct-literal in lockstep with the sole in-crate wire-up site.
3342 #[must_use]
3343 pub fn restart_not_exclusive(
3344 from: &str,
3345 restart_count: usize,
3346 other_kinds: Vec<&'static str>,
3347 ) -> Self {
3348 Self::RestartNotExclusive {
3349 from: from.to_string(),
3350 restart_count,
3351 other_kinds,
3352 }
3353 }
3354
3355 /// Construct an [`UpgradeError::ModuleInvalid`] naming the offending
3356 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3357 /// `:purge`), the malformed `:module` value, and the parser-shaped
3358 /// `reason` from
3359 /// [`crate::render::is_dns_1123_label`]. Folds the uniform
3360 /// `Self::ModuleInvalid { kind, module: module.to_string(), reason }`
3361 /// three-field struct-literal onto one substrate primitive so every
3362 /// wire-up on this variant reads through one dispatch rather than the
3363 /// pre-lift open-coded closure block inside [`validate_module`]'s
3364 /// [`crate::render::require_valid_dns_1123_label`] shape-arm.
3365 ///
3366 /// The `kind: &'static str` parameter accepts the lisp-form
3367 /// [`UpgradeInstruction::lisp_form`] returns for the three
3368 /// [`UpgradeInstruction::declared_module`]-bearing arms —
3369 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3370 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3371 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] — verbatim
3372 /// without a per-arm re-projection at the ctor path. The `module: &str`
3373 /// parameter threads the offending author-supplied `:module` value
3374 /// verbatim from [`UpgradeInstruction::declared_module`]. The
3375 /// `reason: impl Into<String>` bound accepts both `&str` literals and
3376 /// the `String` [`crate::render::is_dns_1123_label`] returns via
3377 /// `.into()`, matching the peer
3378 /// [`crate::AplicacaoError::contrato_caixa_invalid`] /
3379 /// [`crate::SupervisorError::child_caixa_invalid`] /
3380 /// [`crate::DepError::nome_invalid`] `{ *, reason: String }`
3381 /// three-slot invalid-arm ctor discipline on the sibling
3382 /// DNS-1123-label per-envelope shape.
3383 ///
3384 /// Peer of the sibling standalone-ctor
3385 /// [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) on the
3386 /// paired [`crate::AplicacaoError`] envelope's `:contratos` per-edge
3387 /// caixa-reference axis — same `pub fn <ctor>(kind, module: &str,
3388 /// reason: impl Into<String>) -> Self` shape closing the invalid-arm
3389 /// side of a `require_valid_dns_1123_label` two-closure cascade, so
3390 /// [`validate_module`]'s cascade now reads through one substrate
3391 /// primitive on the invalid-arm rather than an open-coded four-line
3392 /// struct-literal in lockstep with the sole in-crate wire-up site.
3393 ///
3394 /// Every future consumer that raises this refusal outside
3395 /// [`validate_module`] — a deferred wasm-operator's
3396 /// `install_release/1` per-instruction `:module` re-validator at
3397 /// hot-upgrade dispatch time re-running the same DNS-1123-label
3398 /// floor against a candidate module reference, a future
3399 /// `feira validate --upgrade-from` per-caixa admission verb
3400 /// re-running the module-shape gate on demand, an M4
3401 /// `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook re-checking a
3402 /// per-`:upgrade-from`-patched candidate before the module-shape
3403 /// gate re-fires, a per-`Caixa` overlay resolver rejecting a
3404 /// cluster-local `(:load-module|:soft-purge|:purge <bad-module>)`
3405 /// overlay against a cluster-local snapshot — now reaches this
3406 /// variant through one call rather than re-inlining the four-line
3407 /// struct-literal in lockstep with the [`validate_module`]
3408 /// closure-form wire-up.
3409 #[must_use]
3410 pub fn module_invalid(kind: &'static str, module: &str, reason: impl Into<String>) -> Self {
3411 Self::ModuleInvalid {
3412 kind,
3413 module: module.to_string(),
3414 reason: reason.into(),
3415 }
3416 }
3417
3418 /// Construct an [`UpgradeError::ModuleEmpty`] naming the offending
3419 /// instruction's `:kind` lisp-form (`:load-module` / `:soft-purge` /
3420 /// `:purge`) at which the appup module reference is the empty
3421 /// string. Folds the uniform `Self::ModuleEmpty { kind }` one-slot
3422 /// struct-literal onto one substrate primitive so the sole in-crate
3423 /// closure passed to [`crate::render::require_valid_dns_1123_label`]
3424 /// at [`validate_module`] on this variant reads through one dispatch
3425 /// rather than the pre-lift open-coded block. The `kind` label
3426 /// threads verbatim from the caller-side
3427 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
3428 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
3429 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] `const`
3430 /// roster the wire-up feeds through [`validate_module`]'s
3431 /// `kind: &'static str` parameter.
3432 ///
3433 /// Sibling of the paired three-slot [`Self::module_invalid`]
3434 /// (3d0d64a) substrate primitive on the same
3435 /// [`crate::render::require_valid_dns_1123_label`] two-closure
3436 /// cascade at [`validate_module`] — the empty-arm and invalid-arm
3437 /// now both reach the `UpgradeError` envelope through one substrate
3438 /// primitive per typed variant, closing the pair on the OTP-appup
3439 /// per-instruction `:module` caixa-reference axis. Same shape
3440 /// discipline as the peer
3441 /// [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87)
3442 /// one-slot `{ slot: &'static str }` sibling that closed the peer
3443 /// pair on the `AplicacaoError` envelope's two-arm DNS-1123-label
3444 /// cascade at the `:contratos <slot>` per-edge axis
3445 /// ([`crate::aplicacao::validate_contrato_caixa`]) — the same
3446 /// "one substrate primitive per typed arm on both sides of a
3447 /// `require_valid_dns_1123_label` two-closure cascade, projecting
3448 /// through the caller-supplied axis-tag" discipline now extended
3449 /// onto the M2 (`:upgrade-from :instructions <kind> :module`) side
3450 /// of the pair the M3 (`:contratos <slot>`) side already carries.
3451 ///
3452 /// `kind` stays `&'static str` (not `&str`) — every `:upgrade-from
3453 /// :instructions <kind>` tag comes from the
3454 /// [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `const` roster
3455 /// carrying program-lifetime storage, matching the enum-field type
3456 /// and the [`validate_module`] wire-up's per-arm dispatch. A
3457 /// runtime-borrowed `&str` would silently downgrade the label
3458 /// lifetime and let a caller stash a non-`'static` borrow into the
3459 /// returned error. `#[must_use]` fires a compile warning at any
3460 /// wire-up that mistakenly discards the constructed error rather
3461 /// than routing it through `return Err(…)` / `.map_err(…)` / a
3462 /// closure return. `pub const fn` matches the peer per-envelope
3463 /// one-slot `Copy`-scalar ctor family discipline
3464 /// (`aplicacao_placement_scalar_ctors!`, `layout_nome_only_ctors!`,
3465 /// `dep_nome_only_ctors!`, [`Self::contrato_caixa_empty`]) so the
3466 /// ctor is usable in `const` position at every wire-up site.
3467 ///
3468 /// Every future consumer that constructs `ModuleEmpty` outside
3469 /// [`validate_module`]'s `require_valid_dns_1123_label` empty-arm
3470 /// closure — a deferred wasm-operator's `install_release/1`
3471 /// per-instruction `:module` re-validator at hot-upgrade dispatch
3472 /// time re-running the same empty-arm floor against a candidate
3473 /// module reference, a future `feira validate --upgrade-from`
3474 /// per-caixa admission verb re-running the empty-module gate on
3475 /// demand, an M4 `mesh.pleme.io/v1alpha1/Caixa` CR admission webhook
3476 /// re-checking a per-`:upgrade-from`-patched candidate before the
3477 /// empty-module gate re-fires, a per-`Caixa` overlay resolver
3478 /// rejecting a cluster-local `(:load-module|:soft-purge|:purge "")`
3479 /// overlay against a cluster-local snapshot — now reaches this
3480 /// variant through one call rather than re-inlining the one-line
3481 /// struct-literal in lockstep with the sole in-crate wire-up site.
3482 #[must_use]
3483 pub const fn module_empty(kind: &'static str) -> Self {
3484 Self::ModuleEmpty { kind }
3485 }
3486}
3487
3488#[cfg(test)]
3489mod tests {
3490 use std::path::Path;
3491
3492 use super::*;
3493
3494 fn entry(from: &str, instrs: Vec<UpgradeInstruction>) -> UpgradeFromEntry {
3495 UpgradeFromEntry {
3496 from: from.into(),
3497 instructions: instrs,
3498 }
3499 }
3500
3501 #[test]
3502 fn upgrade_from_entry_prior_versao_accessor_is_const_fn() {
3503 // Fail-before-pass-after pin on
3504 // [`UpgradeFromEntry::prior_versao`]'s `const`-eval-surface
3505 // posture. The accessor projects the per-`:upgrade-from :from`
3506 // [`String`] storage through the `pub const fn`
3507 // [`String::as_str`] (const-stable since Rust 1.87, well within
3508 // the workspace MSRV) — any future accidental downgrade to
3509 // non-`const` fails `prior_versao_via_const_fn` at caixa-core
3510 // build time with E0015 (`cannot call non-const method`),
3511 // strictly stronger than a runtime `assert!`. Sibling of the
3512 // peer M2/M3 slot family pins on the sibling `const`-eval-
3513 // surface passes ([`crate::Caixa::nome`] /
3514 // [`crate::Caixa::versao`], [`crate::CaixaVersion::as_str`],
3515 // [`crate::aplicacao::Membro::nome`] /
3516 // [`crate::aplicacao::Membro::versao_requirement`],
3517 // [`crate::aplicacao::Entrada::hostname`] /
3518 // [`crate::aplicacao::Entrada::destination`],
3519 // [`crate::supervisor::ChildSpec::nome`] /
3520 // [`crate::supervisor::ChildSpec::versao_requirement`],
3521 // [`crate::dep::Dep::nome`] /
3522 // [`crate::dep::Dep::versao_requirement`], and the
3523 // per-`:contratos`
3524 // [`crate::aplicacao::WitContract::source`] /
3525 // [`crate::aplicacao::WitContract::destination`] /
3526 // [`crate::aplicacao::WitContract::world_ref`] trio the
3527 // sibling pin at 279823b already anchors).
3528 const fn prior_versao_via_const_fn(e: &UpgradeFromEntry) -> &str {
3529 e.prior_versao()
3530 }
3531 for from in ["0.1.0", "1.2.3-alpha.1", "0.0.0"] {
3532 let e = entry(from, vec![]);
3533 assert_eq!(prior_versao_via_const_fn(&e), e.prior_versao());
3534 assert_eq!(e.prior_versao(), from);
3535 }
3536 }
3537
3538 #[test]
3539 fn upgrade_from_entry_instructions_slice_return_accessor_is_const_fn() {
3540 // Fail-before-pass-after pin on
3541 // [`UpgradeFromEntry::instructions`]'s `const`-eval-surface
3542 // posture. The accessor destructures the per-`:upgrade-from
3543 // :instructions` `Vec<UpgradeInstruction>` storage through the
3544 // `pub const fn` [`Vec::as_slice`] (const-stable since Rust
3545 // 1.66, well within the workspace MSRV) — any future
3546 // accidental downgrade to non-`const` fails
3547 // `instructions_via_const_fn` at caixa-core build time with
3548 // E0015 (`cannot call non-const method`), strictly stronger
3549 // than a runtime `assert!`. Sibling of the peer per-M3-mesh-
3550 // slot `Vec → &[T]` slice-return accessor family pin
3551 // [`crate::aplicacao::tests::m3_reference_return_accessor_family_is_const_fn`]
3552 // on the M3 mesh-slot per-`:clusters` / per-`:paths` /
3553 // per-`:membros` / per-`:contratos` slice-return axes, and of
3554 // the peer M2 supervisor-tree axis pin
3555 // [`crate::supervisor::tests::supervisor_children_slice_return_accessor_is_const_fn`]
3556 // on the per-`:children` slice-return axis.
3557 const fn instructions_via_const_fn(e: &UpgradeFromEntry) -> &[UpgradeInstruction] {
3558 e.instructions()
3559 }
3560 // Sweep both the empty-instructions arm (author-declared
3561 // per-`:from` entry with no migration steps — the degenerate
3562 // shape the appup `restart`-only path folds through) and the
3563 // populated-instructions arm (the canonical OTP-appup shape
3564 // carrying a `LoadModule` + `StateChange` + `SoftPurge`
3565 // chain) so the accessor carries a const-dispatch pin on
3566 // both arms.
3567 let e_empty = entry("0.1.0", vec![]);
3568 assert!(instructions_via_const_fn(&e_empty).is_empty());
3569 assert_eq!(instructions_via_const_fn(&e_empty), e_empty.instructions());
3570 let e_full = entry(
3571 "0.1.0",
3572 vec![
3573 UpgradeInstruction::LoadModule {
3574 module: "hello-rio".into(),
3575 },
3576 UpgradeInstruction::StateChange {
3577 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3578 },
3579 UpgradeInstruction::SoftPurge {
3580 module: "hello-rio-old".into(),
3581 },
3582 ],
3583 );
3584 assert_eq!(instructions_via_const_fn(&e_full).len(), 3);
3585 assert_eq!(instructions_via_const_fn(&e_full), e_full.instructions());
3586 }
3587
3588 #[test]
3589 fn round_trip_load_module() {
3590 let i = UpgradeInstruction::LoadModule {
3591 module: "hello-rio".into(),
3592 };
3593 let json = serde_json::to_string(&i).unwrap();
3594 assert!(json.contains("\"kind\":\"load-module\""));
3595 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3596 assert_eq!(i, back);
3597 }
3598
3599 #[test]
3600 fn round_trip_all_variants() {
3601 let cases = vec![
3602 UpgradeInstruction::LoadModule { module: "x".into() },
3603 UpgradeInstruction::StateChange {
3604 script: PathBuf::from("lib/migrations.lisp"),
3605 },
3606 UpgradeInstruction::SoftPurge {
3607 module: "x-old".into(),
3608 },
3609 UpgradeInstruction::Purge {
3610 module: "x-old".into(),
3611 },
3612 UpgradeInstruction::Restart,
3613 ];
3614 for c in cases {
3615 let json = serde_json::to_string(&c).unwrap();
3616 let back: UpgradeInstruction = serde_json::from_str(&json).unwrap();
3617 assert_eq!(c, back);
3618 }
3619 }
3620
3621 #[test]
3622 fn validate_accepts_well_formed() {
3623 let e = entry(
3624 "0.1.0",
3625 vec![
3626 UpgradeInstruction::LoadModule {
3627 module: "hello-rio".into(),
3628 },
3629 UpgradeInstruction::StateChange {
3630 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3631 },
3632 UpgradeInstruction::SoftPurge {
3633 module: "hello-rio-old".into(),
3634 },
3635 ],
3636 );
3637 e.validate().unwrap();
3638 }
3639
3640 #[test]
3641 fn validate_rejects_non_semver_from() {
3642 let e = entry("not-a-semver", vec![]);
3643 let err = e.validate().unwrap_err();
3644 assert!(
3645 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver")
3646 );
3647 }
3648
3649 #[test]
3650 fn from_invalid_diagnostic_carries_offending_from_and_reason() {
3651 // Diagnostic-shape pin: the error names the offending
3652 // `:upgrade-from :from` verbatim with a non-empty parser-shaped
3653 // reason, so a `feira lint` run can render the diagnostic
3654 // without re-parsing — the author can grep their caixa.lisp for
3655 // `:from "<value>"` and fix it in one edit. Mirrors the peer
3656 // `versao_invalid_diagnostic_carries_offending_versao` pin on
3657 // the sibling SemVer-2 axis (the top-level `:versao`), the
3658 // peer `membro_versao_invalid_diagnostic_carries_offending_value`
3659 // pin on `:membros :versao`, and the peer
3660 // `deps_invalid_diagnostic_carries_offending_value` pin on
3661 // `:deps :versao` — every SemVer-2-parsing slot's invalid
3662 // diagnostic is now structurally equivalent.
3663 let e = entry("v0.1.0", vec![]);
3664 let err = e.validate().unwrap_err();
3665 let UpgradeError::FromInvalid { from, reason } = err else {
3666 panic!("expected FromInvalid variant, got {err:?}");
3667 };
3668 assert_eq!(from, "v0.1.0");
3669 assert!(
3670 !reason.is_empty(),
3671 "FromInvalid `reason` must carry the parser's wording verbatim"
3672 );
3673 }
3674
3675 #[test]
3676 fn prior_versao_returns_from_byte_equal_across_permutations() {
3677 // Byte-identity pin on the lifted `UpgradeFromEntry::prior_versao`
3678 // accessor across the SemVer-2 shape lattice every consumer
3679 // reaches through it — the numeric-triad canonical shape, a
3680 // pre-release build with a dotted identifier chain, a full-
3681 // metadata build, a large-magnitude triad, and the empty
3682 // string (which reaches this accessor unchanged before any
3683 // validate gate rejects it). Sibling to the peer
3684 // `membro_versao_requirement_returns_versao_byte_equal_across_permutations`
3685 // (a40b0e3) / `membro_nome_returns_caixa_byte_equal_across_permutations`
3686 // (4a32abf) pins on the sibling M3 mesh-slot scalar-accessor
3687 // family — extended here onto the first M2 slot scalar-value
3688 // axis. Any silent detour on the accessor (a `.to_string()`
3689 // + retained ownership shape, a canonicalization pass, a
3690 // trim-whitespace on the return path) surfaces as a byte-
3691 // inequality failure here rather than as a downstream error-
3692 // diagnostic drift.
3693 let cases = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30", ""];
3694 for from in cases {
3695 let e = entry(from, vec![]);
3696 assert_eq!(
3697 e.prior_versao(),
3698 from,
3699 "prior_versao() must return the `:from` field byte-for-byte for {from:?}",
3700 );
3701 assert_eq!(
3702 e.prior_versao().len(),
3703 from.len(),
3704 "prior_versao() byte-length must equal the `:from` field's for {from:?}",
3705 );
3706 }
3707 }
3708
3709 #[test]
3710 fn prior_versao_borrows_from_from_storage() {
3711 // Same-address pin: `UpgradeFromEntry::prior_versao` returns
3712 // a borrow into `self.from`'s heap allocation, never a fresh
3713 // owned copy. Guards against a future silent detour where
3714 // the accessor materializes a `Cow<'_, str>` / `String` /
3715 // `Rc<str>` intermediate — the return path stays zero-cost
3716 // even under a refactor that reshapes the storage. Sibling
3717 // to the peer `membro_versao_requirement_borrows_from_versao_storage`
3718 // (a40b0e3) / `membro_nome_borrows_from_caixa_storage`
3719 // (4a32abf) pins — extended onto the M2 slot's first
3720 // scalar-value axis.
3721 let e = entry("0.1.0", vec![]);
3722 assert!(
3723 std::ptr::eq(e.prior_versao().as_ptr(), e.from.as_ptr()),
3724 "prior_versao() must borrow from `self.from`'s storage, not allocate a fresh copy",
3725 );
3726 }
3727
3728 #[test]
3729 fn validate_parses_prior_versao_through_lifted_accessor() {
3730 // Coherence pin between the accessor and the SemVer-2 parse
3731 // gate: every `:upgrade-from :from` value the validator
3732 // accepts (resp. rejects) must be identical to what
3733 // `Version::parse(entry.prior_versao())` accepts (resp.
3734 // rejects) — the two must remain in lockstep across the
3735 // shape lattice so `validate_upgrade_from`'s
3736 // `Version::parse(entry.prior_versao()).expect(...)` re-parse
3737 // assertion holds by construction. If a future extension of
3738 // `prior_versao` reshapes the return (a canonicalization
3739 // pass, a leading/trailing whitespace trim, an empty-to-
3740 // "0.0.0" fallback) it would either loosen the validator
3741 // (silently accepting shapes the parser rejects) or
3742 // tighten the parser's re-parse (silently panicking on
3743 // shapes the validator accepts) — this pin catches either
3744 // shift at caixa-core build time.
3745 let accepted = ["0.1.0", "0.2.0-rc.1", "1.0.0+build.42", "10.20.30"];
3746 for from in accepted {
3747 let e = entry(from, vec![]);
3748 e.validate().unwrap_or_else(|err| {
3749 panic!("validate() must accept {from:?} that Version::parse accepts, got {err:?}");
3750 });
3751 semver::Version::parse(e.prior_versao()).unwrap_or_else(|err| {
3752 panic!(
3753 "Version::parse(prior_versao()) must accept {from:?} that validate() accepts, \
3754 got {err:?}",
3755 );
3756 });
3757 }
3758 let rejected = ["", "v0.1.0", "0.1", "not-a-semver", "0.1.0.0"];
3759 for from in rejected {
3760 let e = entry(from, vec![]);
3761 assert!(
3762 matches!(e.validate(), Err(UpgradeError::FromInvalid { .. })),
3763 "validate() must reject {from:?} that Version::parse rejects",
3764 );
3765 assert!(
3766 semver::Version::parse(e.prior_versao()).is_err(),
3767 "Version::parse(prior_versao()) must reject {from:?} that validate() rejects",
3768 );
3769 }
3770 }
3771
3772 #[test]
3773 fn validate_rejects_empty_module() {
3774 // Per-arm coverage: every Module-bearing variant surfaces the
3775 // kind-tagged `ModuleEmpty` diagnostic naming its lisp-form,
3776 // so the author can grep their caixa.lisp for `(:load-module
3777 // …)` / `(:soft-purge …)` / `(:purge …)` and fix it in one
3778 // edit — same self-locating shape `BehaviorError::EmptyPath`
3779 // (b0c8389) carries on the peer M2 typed slot.
3780 let cases: &[(UpgradeInstruction, &'static str)] = &[
3781 (
3782 UpgradeInstruction::LoadModule {
3783 module: String::new(),
3784 },
3785 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3786 ),
3787 (
3788 UpgradeInstruction::SoftPurge {
3789 module: String::new(),
3790 },
3791 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3792 ),
3793 (
3794 UpgradeInstruction::Purge {
3795 module: String::new(),
3796 },
3797 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3798 ),
3799 ];
3800 for (instr, expected_kind) in cases {
3801 assert_eq!(
3802 instr.validate().unwrap_err(),
3803 UpgradeError::ModuleEmpty {
3804 kind: expected_kind
3805 },
3806 "empty :module on {instr:?} must surface as ModuleEmpty {{ kind: {expected_kind:?} }}"
3807 );
3808 }
3809 }
3810
3811 #[test]
3812 fn validate_rejects_non_dns_1123_module() {
3813 // Every appup `:module` reference is a caixa name (the
3814 // wasm-engine resolves it through the same ComputeUnit
3815 // registry the operator manages), so the value-shape gate
3816 // matches the K8s apiserver-side DNS-1123 label rule. Sweep
3817 // the canonical authoring footguns — uppercase letters, `_`
3818 // separator, embedded `.`, leading/trailing `-`, an embedded
3819 // whitespace byte, the >63-byte UUID-shaped slug — across
3820 // every Module-bearing variant; each must surface as
3821 // `ModuleInvalid { kind, module, reason }` carrying the
3822 // offending value verbatim and the parser-shaped reason.
3823 type Build = fn(String) -> UpgradeInstruction;
3824 let footguns: &[&str] = &[
3825 "Hello-Rio",
3826 "hello_rio",
3827 "hello.rio",
3828 "-hello",
3829 "hello-",
3830 "hello rio",
3831 &"x".repeat(crate::render::DNS_1123_LABEL_MAX_LEN + 1),
3832 ];
3833 let variants: &[(Build, &'static str)] = &[
3834 (
3835 |m| UpgradeInstruction::LoadModule { module: m },
3836 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
3837 ),
3838 (
3839 |m| UpgradeInstruction::SoftPurge { module: m },
3840 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
3841 ),
3842 (
3843 |m| UpgradeInstruction::Purge { module: m },
3844 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
3845 ),
3846 ];
3847 for (build, expected_kind) in variants {
3848 for module in footguns {
3849 let instr = build((*module).to_string());
3850 let err = instr.validate().unwrap_err();
3851 match err {
3852 UpgradeError::ModuleInvalid {
3853 kind,
3854 module: m,
3855 reason,
3856 } => {
3857 assert_eq!(
3858 kind, *expected_kind,
3859 ":module footgun on {instr:?} must tag the lisp-form"
3860 );
3861 assert_eq!(
3862 m, *module,
3863 "ModuleInvalid must carry the offending value verbatim"
3864 );
3865 assert!(
3866 !reason.is_empty(),
3867 "ModuleInvalid reason must name the specific violation \
3868 (the predicate's parser-shaped wording from \
3869 `is_dns_1123_label`), got empty"
3870 );
3871 }
3872 other => panic!("expected ModuleInvalid on {instr:?}, got {other:?}"),
3873 }
3874 }
3875 }
3876 }
3877
3878 #[test]
3879 fn validate_accepts_canonical_module_names() {
3880 // Positive control: every documented authoring shape — bare
3881 // identifier, with hyphens, with digits, the
3882 // suffix-versioned alias `<nome>-old` `SoftPurge` typically
3883 // references — passes the gate. Drift here = a future
3884 // tighten that rejects any of these surfaces as a
3885 // test-failure at the predicate boundary, not piecemeal
3886 // across per-instruction call sites.
3887 let canonical: &[&str] = &[
3888 "hello-rio",
3889 "hello-rio-old",
3890 "cache",
3891 "cache-v2",
3892 "x",
3893 "a1",
3894 "0a",
3895 "abc-123-def",
3896 ];
3897 for module in canonical {
3898 UpgradeInstruction::LoadModule {
3899 module: (*module).to_string(),
3900 }
3901 .validate()
3902 .unwrap_or_else(|e| panic!("LoadModule {module:?} must pass, got {e:?}"));
3903 UpgradeInstruction::SoftPurge {
3904 module: (*module).to_string(),
3905 }
3906 .validate()
3907 .unwrap_or_else(|e| panic!("SoftPurge {module:?} must pass, got {e:?}"));
3908 UpgradeInstruction::Purge {
3909 module: (*module).to_string(),
3910 }
3911 .validate()
3912 .unwrap_or_else(|e| panic!("Purge {module:?} must pass, got {e:?}"));
3913 }
3914 }
3915
3916 #[test]
3917 fn validate_empty_takes_precedence_over_invalid() {
3918 // Empty input is rejected via the narrower `ModuleEmpty`
3919 // diagnostic before the DNS-1123 predicate is consulted, so
3920 // a future tighten that adds another stage between the two
3921 // doesn't accidentally reorder the diagnostic precedence.
3922 // Mirrors the empty-first cascade on every peer DNS-1123
3923 // gate (`validate_membro_caixa`, `validate_placement_cluster`,
3924 // `SupervisorSpec::validate`'s child-name arm).
3925 let err = UpgradeInstruction::LoadModule {
3926 module: String::new(),
3927 }
3928 .validate()
3929 .unwrap_err();
3930 assert_eq!(
3931 err,
3932 UpgradeError::ModuleEmpty {
3933 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
3934 }
3935 );
3936 }
3937
3938 #[test]
3939 fn validate_rejects_empty_script() {
3940 let i = UpgradeInstruction::StateChange {
3941 script: PathBuf::new(),
3942 };
3943 assert_eq!(i.validate().unwrap_err(), UpgradeError::EmptyScript);
3944 }
3945
3946 #[test]
3947 fn validate_rejects_absolute_script() {
3948 let i = UpgradeInstruction::StateChange {
3949 script: PathBuf::from("/etc/migrations.lisp"),
3950 };
3951 assert!(matches!(
3952 i.validate().unwrap_err(),
3953 UpgradeError::AbsoluteScript { .. }
3954 ));
3955 }
3956
3957 #[test]
3958 fn validate_rejects_parent_escape_script() {
3959 let i = UpgradeInstruction::StateChange {
3960 script: PathBuf::from("../sibling/migrations.lisp"),
3961 };
3962 assert!(matches!(
3963 i.validate().unwrap_err(),
3964 UpgradeError::ParentEscapeScript { .. }
3965 ));
3966 // mid-path `..` is also caught
3967 let i2 = UpgradeInstruction::StateChange {
3968 script: PathBuf::from("lib/../../escaped.lisp"),
3969 };
3970 assert!(matches!(
3971 i2.validate().unwrap_err(),
3972 UpgradeError::ParentEscapeScript { .. }
3973 ));
3974 }
3975
3976 // ── :upgrade-from :state-change :script `.lisp` extension gate ─
3977 // Mirrors the c97815a `BehaviorError::NonLispExtension` arm on
3978 // the peer `:behavior :on-*` tatara-lisp-source-path axis. Both
3979 // axes route through the same M2.5 wasm-engine `tatara_lisp::read`
3980 // consumer; the file-type contract is identical, so the per-axis
3981 // test grid is mirrored leg-for-leg.
3982
3983 #[test]
3984 fn validate_rejects_no_extension_script() {
3985 // Fail-before-pass-after: the canonical "I declared the
3986 // migration script but forgot the `.lisp` extension"
3987 // authoring footgun (e.g. `(:state-change "lib/migrations")`).
3988 // The wasm-engine's `tatara_lisp::read` consumer needs a
3989 // file-type contract beyond the structural-shape gate; a
3990 // no-extension path past `is_sandboxed_relative_path` would
3991 // surface a parser-shaped diagnostic at hot-upgrade migration
3992 // time far from the source caixa.lisp.
3993 for relpath in ["lib/migrations", "migrations", "lib/handlers/migrate"] {
3994 let i = UpgradeInstruction::StateChange {
3995 script: PathBuf::from(relpath),
3996 };
3997 let err = i.validate().unwrap_err();
3998 assert!(
3999 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4000 if s == Path::new(relpath)),
4001 "no-extension script {relpath:?} must surface as NonLispExtensionScript \
4002 carrying the offending path verbatim, got {err:?}"
4003 );
4004 }
4005 }
4006
4007 #[test]
4008 fn validate_rejects_non_lisp_extension_script() {
4009 // Wrong-extension sweep across common authoring footguns: the
4010 // `.txt` / `.md` / `.json` / `.yaml` shapes an author might
4011 // drag in from the workspace tree, the `.rs` shape that an
4012 // IDE auto-complete might propose, the `.lisp.bak` shape an
4013 // editor might leave behind, and the `.lispx` near-miss that
4014 // a typo would produce. Each must surface as
4015 // `NonLispExtensionScript` carrying the offending path
4016 // verbatim — the wasm-engine's `tatara_lisp::read` consumer
4017 // rejects all of these at hot-upgrade migration time, and
4018 // the gate lifts that contract to validate time. Mirrors the
4019 // peer `BehaviorError::NonLispExtension` sweep (c97815a) on
4020 // the `:behavior :on-*` axis leg-for-leg — same downstream
4021 // consumer, same accepted set, same per-axis test grid.
4022 let footguns: &[&str] = &[
4023 "lib/migrations.rs",
4024 "lib/migrations.txt",
4025 "lib/migrations.md",
4026 "lib/migrations.json",
4027 "lib/migrations.yaml",
4028 "lib/migrations.toml",
4029 "lib/migrations.lisp.bak",
4030 "lib/migrations.lispx",
4031 "lib/migrations.lis",
4032 ];
4033 for relpath in footguns {
4034 let i = UpgradeInstruction::StateChange {
4035 script: PathBuf::from(relpath),
4036 };
4037 let err = i.validate().unwrap_err();
4038 assert!(
4039 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4040 if s == Path::new(relpath)),
4041 "wrong-extension script {relpath:?} must surface as NonLispExtensionScript \
4042 carrying the offending path verbatim, got {err:?}"
4043 );
4044 }
4045 }
4046
4047 #[test]
4048 fn validate_rejects_uppercase_lisp_extension_script() {
4049 // Strict lowercase: `.LISP` / `.Lisp` / `.LiSp` are
4050 // case-folded shapes a case-insensitive volume's existence
4051 // check would match the on-disk file — but the
4052 // canonical-form codec emits lowercase `.lisp` verbatim, so
4053 // a case-folded shape mismatches the round-trip-stable
4054 // canonical form (THEORY.md §V.2.7 render-determinism).
4055 // Same case-sensitive discipline the byte-size / duration
4056 // codecs use on unit suffixes (`MiB`, `ms`, `s`, `m`, `h`)
4057 // and every other shape-gate predicate in `render.rs` (label
4058 // / scheme / unit boundaries). Mirrors the peer
4059 // `BehaviorError::NonLispExtension` case-fold sweep (c97815a).
4060 for relpath in [
4061 "lib/migrations.LISP",
4062 "lib/migrations.Lisp",
4063 "lib/migrations.LiSp",
4064 "lib/migrations.lISP",
4065 ] {
4066 let i = UpgradeInstruction::StateChange {
4067 script: PathBuf::from(relpath),
4068 };
4069 let err = i.validate().unwrap_err();
4070 assert!(
4071 matches!(&err, UpgradeError::NonLispExtensionScript { script: s }
4072 if s == Path::new(relpath)),
4073 "case-folded `.lisp` extension {relpath:?} must surface as \
4074 NonLispExtensionScript (strict lowercase, canonical-form \
4075 round-trip pin), got {err:?}"
4076 );
4077 }
4078 }
4079
4080 #[test]
4081 fn validate_accepts_canonical_lisp_extension_scripts() {
4082 // Positive-control sweep across every canonical in-tree
4083 // authoring shape: bare filename, standard `lib/`
4084 // subdirectory, deeply-nested migrations subdirectory,
4085 // explicit current-dir-relative prefix, mid-path `./`
4086 // segment, multi-dot stem (the version-suffix shape
4087 // `lib/migrations/v.0.1.lisp` an author might use to encode
4088 // the migration's `:from` version into the filename). Drift
4089 // here = a future tightening that rejects any of these
4090 // surfaces as a test-failure at the per-axis validator
4091 // boundary, not piecemeal across renderer / layout-checker
4092 // call sites. Mirrors the peer `BehaviorSpec` positive-set
4093 // sweep (c97815a).
4094 let canonical: &[&str] = &[
4095 "lib/migrations.lisp",
4096 "lib/migrations/v01-to-v02.lisp",
4097 "migrations.lisp",
4098 "a.lisp",
4099 "./lib/migrations.lisp",
4100 "lib/./migrations.lisp",
4101 "lib/migrations/v.0.1.lisp",
4102 ];
4103 for relpath in canonical {
4104 UpgradeInstruction::StateChange {
4105 script: PathBuf::from(relpath),
4106 }
4107 .validate()
4108 .unwrap_or_else(|e| {
4109 panic!("canonical `.lisp` script {relpath:?} must pass, got {e:?}")
4110 });
4111 }
4112 }
4113
4114 #[test]
4115 fn validate_sandbox_shape_takes_precedence_over_lisp_extension() {
4116 // Cross-arm precedence pin: a script that is *both*
4117 // sandbox-escaping (Empty / Absolute / ParentEscape) and
4118 // non-`.lisp` must surface the more-fundamental
4119 // sandbox-shape diagnostic first — the canonical fix
4120 // collapses both into "pin a relative `.lisp` path under the
4121 // caixa root", and the `.lisp` remediation would be
4122 // misleading when the offending path can never resolve under
4123 // the caixa root anyway. Mirrors the peer
4124 // `BehaviorError` cross-arm precedence (c97815a) and the
4125 // sibling `LimitsError`
4126 // (`MemoryZero` → `MemoryBelowWasm32Page` →
4127 // `MemoryExceedsWasm32Cap` → `MemoryNotPageMultiple`)
4128 // smallest-scope-arm-fires-last posture.
4129 let i_empty = UpgradeInstruction::StateChange {
4130 script: PathBuf::new(),
4131 };
4132 assert_eq!(i_empty.validate().unwrap_err(), UpgradeError::EmptyScript);
4133 let i_abs = UpgradeInstruction::StateChange {
4134 script: PathBuf::from("/etc/migrations.txt"),
4135 };
4136 assert!(
4137 matches!(
4138 i_abs.validate().unwrap_err(),
4139 UpgradeError::AbsoluteScript { .. }
4140 ),
4141 "absolute + non-`.lisp` must surface AbsoluteScript first"
4142 );
4143 let i_esc = UpgradeInstruction::StateChange {
4144 script: PathBuf::from("../sibling/migrations.rs"),
4145 };
4146 assert!(
4147 matches!(
4148 i_esc.validate().unwrap_err(),
4149 UpgradeError::ParentEscapeScript { .. }
4150 ),
4151 "parent-escape + non-`.lisp` must surface ParentEscapeScript first"
4152 );
4153 }
4154
4155 #[test]
4156 fn non_lisp_extension_script_diagnostic_carries_offending_path() {
4157 // Diagnostic-shape pin: the surfaced error message names the
4158 // offending path verbatim (so the author can grep their
4159 // caixa.lisp for the literal value), the `.lisp` extension
4160 // is named in the remediation, and the downstream consumer
4161 // (`tatara_lisp::read` at hot-upgrade migration time) is
4162 // named so the author can trace the contract back to its
4163 // source. Same self-locating shape every per-axis variant
4164 // carries (`BehaviorError::NonLispExtension`, c97815a;
4165 // `LimitsError::MemoryNotPageMultiple`, ec266d8).
4166 let bad = PathBuf::from("lib/migrations.txt");
4167 let err = UpgradeInstruction::StateChange {
4168 script: bad.clone(),
4169 }
4170 .validate()
4171 .unwrap_err();
4172 let msg = err.to_string();
4173 assert!(
4174 msg.contains("lib/migrations.txt"),
4175 "diagnostic must name the offending path verbatim, got {msg:?}"
4176 );
4177 assert!(
4178 msg.contains(".lisp"),
4179 "diagnostic must name the expected `.lisp` extension, got {msg:?}"
4180 );
4181 assert!(
4182 msg.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE),
4183 "diagnostic must name the offending `:state-change` instruction, got {msg:?}"
4184 );
4185 match err {
4186 UpgradeError::NonLispExtensionScript { script } => {
4187 assert_eq!(
4188 script, bad,
4189 "variant must carry the offending path verbatim"
4190 );
4191 }
4192 other => panic!("expected NonLispExtensionScript, got {other:?}"),
4193 }
4194 }
4195
4196 #[test]
4197 fn declared_path_only_for_state_change() {
4198 let load = UpgradeInstruction::LoadModule { module: "x".into() };
4199 assert!(load.declared_path().is_none());
4200 let mig = UpgradeInstruction::StateChange {
4201 script: PathBuf::from("lib/m.lisp"),
4202 };
4203 assert_eq!(mig.declared_path(), Some(&PathBuf::from("lib/m.lisp")));
4204 }
4205
4206 #[test]
4207 fn upgrade_instruction_is_restart_predicate_partitions_the_arm_set() {
4208 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4209 // derive's [`UpgradeInstruction::is_restart`] arm-discriminator
4210 // predicate: [`UpgradeInstruction::Restart`] is the only variant
4211 // that satisfies `.is_restart()`; every module-bearing arm
4212 // (`LoadModule` / `SoftPurge` / `Purge`) and the script-carrying
4213 // `StateChange` arm all return `false`. This pin makes the
4214 // partition invariant load-bearing at caixa-core test time so a
4215 // future derive regression (a hole that returns `false` for
4216 // `Restart` too, or a byte-collision that flips a second variant
4217 // to `true`) trips here rather than laundering the arm at
4218 // [`Self::validate_restart_exclusive`]'s paired positive /
4219 // negated filter sites (a hole flips restart-count to 0 →
4220 // vacuous OK; a collision flips restart-count > 1 → false
4221 // `RestartNotExclusive` on an entry the author declared without
4222 // any `(:restart)`). Peer of the sibling
4223 // [`crate::kind::tests::caixa_kind_is_variant_predicates_partition_the_arm_set`]
4224 // pin on the M0 `CaixaKind` axis.
4225 let cases: &[(UpgradeInstruction, bool)] = &[
4226 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4227 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4228 (UpgradeInstruction::Purge { module: "c".into() }, false),
4229 (
4230 UpgradeInstruction::StateChange {
4231 script: PathBuf::from("lib/m.lisp"),
4232 },
4233 false,
4234 ),
4235 (UpgradeInstruction::Restart, true),
4236 ];
4237 for (variant, expected) in cases {
4238 assert_eq!(
4239 variant.is_restart(),
4240 *expected,
4241 "UpgradeInstruction::{variant:?}.is_restart() must \
4242 return {expected} (partition invariant on the \
4243 IsVariant-derived arm-discriminator predicate)"
4244 );
4245 }
4246 }
4247
4248 #[test]
4249 fn validate_restart_exclusive_routes_through_is_restart_predicate() {
4250 // Byte-identity pin on the paired positive / negated
4251 // `.is_restart()` filters at
4252 // [`Self::validate_restart_exclusive`] against the pre-lift
4253 // `matches!(i, UpgradeInstruction::Restart)` /
4254 // `!matches!(i, UpgradeInstruction::Restart)` predicates every
4255 // consumer of the gate previously coupled to inline. Asserts
4256 // the two projections agree byte-for-byte on every arm of the
4257 // enum, so a future derive regression that flipped either
4258 // predicate's arm-set would surface here at caixa-core test
4259 // time rather than at
4260 // [`Self::validate_restart_exclusive`]'s per-entry restart-
4261 // count / other-kinds tabulation far from the derive site.
4262 // Same peer-shape pin every sibling
4263 // `IsVariant`-derive-routed gate carries on the substrate's
4264 // closed-set typed-enum surface.
4265 let cases: Vec<UpgradeInstruction> = vec![
4266 UpgradeInstruction::LoadModule { module: "a".into() },
4267 UpgradeInstruction::SoftPurge { module: "b".into() },
4268 UpgradeInstruction::Purge { module: "c".into() },
4269 UpgradeInstruction::StateChange {
4270 script: PathBuf::from("lib/m.lisp"),
4271 },
4272 UpgradeInstruction::Restart,
4273 ];
4274 for instr in &cases {
4275 let via_predicate = instr.is_restart();
4276 let via_matches = matches!(instr, UpgradeInstruction::Restart);
4277 assert_eq!(
4278 via_predicate, via_matches,
4279 "UpgradeInstruction::{instr:?}: is_restart() must \
4280 byte-equal matches!(_, UpgradeInstruction::Restart) — \
4281 the pre-lift open-coded pattern and the \
4282 IsVariant-derived predicate are the same axis, \
4283 one typed dispatch"
4284 );
4285 }
4286 }
4287
4288 #[test]
4289 fn upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set() {
4290 // The fail-before-pass-after pin on the lifted
4291 // [`UpgradeInstruction::is_cleanup`] two-arm cleanup-family
4292 // arm-discriminator predicate:
4293 // [`UpgradeInstruction::SoftPurge`] and
4294 // [`UpgradeInstruction::Purge`] are the two OTP-appup two-
4295 // phase-code-load cleanup arms that satisfy `.is_cleanup()`;
4296 // every non-cleanup arm ([`UpgradeInstruction::LoadModule`]
4297 // on the paired two-phase-load half,
4298 // [`UpgradeInstruction::StateChange`] on the
4299 // `gen_server:code_change/3`-analog migration axis,
4300 // [`UpgradeInstruction::Restart`] on the OTP terminal-
4301 // fallback shape) returns `false`. This pin makes the
4302 // partition invariant load-bearing at caixa-core test time
4303 // so a future accessor regression (a hole that returns
4304 // `false` for `SoftPurge` or `Purge`, or a byte-collision
4305 // that flips `LoadModule` / `StateChange` / `Restart` to
4306 // `true`) trips here rather than laundering the arm at the
4307 // three within-entry cross-instruction cleanup-facing gates
4308 // ([`UpgradeFromEntry::validate_purge_ordering`],
4309 // [`UpgradeFromEntry::validate_state_change_before_cleanup`],
4310 // [`UpgradeFromEntry::validate_cleanup_singularity`]) — a
4311 // hole would silently accept a cleanup-shaped entry the
4312 // three gates should refuse; a collision would fire a
4313 // `PurgeWithoutPriorLoad` / `StateChangeAfterCleanup` /
4314 // `DuplicateCleanup` refusal on a well-shaped
4315 // [`UpgradeInstruction::LoadModule`] / `StateChange` /
4316 // `Restart` arm the three gates should pass through. Peer
4317 // of the sibling
4318 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4319 // pin on the single-arm terminal-fallback partition —
4320 // extended here from the single-arm case onto the two-arm
4321 // cleanup-family union case.
4322 let cases: &[(UpgradeInstruction, bool)] = &[
4323 (UpgradeInstruction::LoadModule { module: "a".into() }, false),
4324 (UpgradeInstruction::SoftPurge { module: "b".into() }, true),
4325 (UpgradeInstruction::Purge { module: "c".into() }, true),
4326 (
4327 UpgradeInstruction::StateChange {
4328 script: PathBuf::from("lib/m.lisp"),
4329 },
4330 false,
4331 ),
4332 (UpgradeInstruction::Restart, false),
4333 ];
4334 for (variant, expected) in cases {
4335 assert_eq!(
4336 variant.is_cleanup(),
4337 *expected,
4338 "UpgradeInstruction::{variant:?}.is_cleanup() must \
4339 return {expected} (partition invariant on the \
4340 lifted OTP-appup two-arm cleanup-family arm-\
4341 discriminator predicate)"
4342 );
4343 }
4344 }
4345
4346 #[test]
4347 fn upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge() {
4348 // Byte-identity pin on the [`UpgradeInstruction::is_cleanup`]
4349 // composition against the two [`gen_platform::IsVariant`]-
4350 // derive-generated per-variant classifiers it routes through
4351 // — the accessor's one body must byte-equal
4352 // `self.is_soft_purge() || self.is_purge()` across every arm
4353 // of the closed-set enum, so a future silent detour that
4354 // reintroduced a raw `matches!` pattern or that stopped
4355 // composing through the derive-generated per-variant
4356 // predicates (an accidental `self.is_soft_purge()` on its
4357 // own — silently dropping the `Purge` arm; an accidental
4358 // `self.is_purge() || self.is_state_change()` — silently
4359 // folding the migration arm into the cleanup family; a
4360 // typo `&&` for the union `||` — silently classifying no
4361 // arm as cleanup) trips here at caixa-core test time
4362 // rather than laundering the arm at the three within-entry
4363 // cross-instruction cleanup-facing gates. Same peer-shape
4364 // pin the sibling
4365 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4366 // carries on the paired terminal-fallback axis.
4367 let cases: Vec<UpgradeInstruction> = vec![
4368 UpgradeInstruction::LoadModule { module: "a".into() },
4369 UpgradeInstruction::SoftPurge { module: "b".into() },
4370 UpgradeInstruction::Purge { module: "c".into() },
4371 UpgradeInstruction::StateChange {
4372 script: PathBuf::from("lib/m.lisp"),
4373 },
4374 UpgradeInstruction::Restart,
4375 ];
4376 for instr in &cases {
4377 let via_predicate = instr.is_cleanup();
4378 let via_composition = instr.is_soft_purge() || instr.is_purge();
4379 assert_eq!(
4380 via_predicate, via_composition,
4381 "UpgradeInstruction::{instr:?}: is_cleanup() must \
4382 byte-equal is_soft_purge() || is_purge() — the \
4383 lifted union predicate and its per-variant \
4384 composition are the same axis, one typed dispatch"
4385 );
4386 }
4387 }
4388
4389 #[test]
4390 fn upgrade_instruction_is_cleanup_implies_declared_module_is_some() {
4391 // Composition-pin the load-bearing invariant every consumer
4392 // that routes through `is_cleanup()` + `declared_module()`
4393 // relies on: any [`UpgradeInstruction`] value whose
4394 // `.is_cleanup()` returns `true` must have a `Some(_)`
4395 // `.declared_module()`. This makes the three within-entry
4396 // cross-instruction cleanup-facing gates' `.expect("is_cleanup()
4397 // implies declared_module() is Some")` structurally
4398 // infallible at build time — a future refactor that added
4399 // a cleanup-shaped variant carrying no `:module` would trip
4400 // here rather than panic at
4401 // [`UpgradeFromEntry::validate_purge_ordering`] /
4402 // [`UpgradeFromEntry::validate_state_change_before_cleanup`] /
4403 // [`UpgradeFromEntry::validate_cleanup_singularity`] at
4404 // runtime on the offending author's caixa.lisp.
4405 let cases: Vec<UpgradeInstruction> = vec![
4406 UpgradeInstruction::LoadModule { module: "a".into() },
4407 UpgradeInstruction::SoftPurge { module: "b".into() },
4408 UpgradeInstruction::Purge { module: "c".into() },
4409 UpgradeInstruction::StateChange {
4410 script: PathBuf::from("lib/m.lisp"),
4411 },
4412 UpgradeInstruction::Restart,
4413 ];
4414 for instr in &cases {
4415 if instr.is_cleanup() {
4416 assert!(
4417 instr.declared_module().is_some(),
4418 "UpgradeInstruction::{instr:?}: is_cleanup() \
4419 must imply declared_module().is_some() — the \
4420 three within-entry cross-instruction cleanup-\
4421 facing gates rely on this invariant to route \
4422 the cleanup-target :module scalar through the \
4423 sibling declared_module accessor without a \
4424 pattern-bound `module` binding"
4425 );
4426 }
4427 }
4428 }
4429
4430 #[test]
4431 fn upgrade_instruction_is_load_module_implies_declared_module_is_some() {
4432 // Composition-pin the load-bearing invariant
4433 // [`UpgradeFromEntry::validate_load_singularity`] relies on
4434 // when routing the per-instruction load-family arm-discriminator
4435 // through the sibling
4436 // [`UpgradeInstruction::is_load_module`] +
4437 // [`UpgradeInstruction::declared_module`] accessor pair: any
4438 // [`UpgradeInstruction`] value whose `.is_load_module()`
4439 // returns `true` must have a `Some(_)` `.declared_module()`.
4440 // This makes the gate's `.expect("is_load_module() implies
4441 // declared_module() is Some")` structurally infallible at
4442 // build time — a future refactor that added a load-shaped
4443 // variant carrying no `:module` would trip here rather than
4444 // panic at [`UpgradeFromEntry::validate_load_singularity`]
4445 // at runtime on the offending author's caixa.lisp. Sibling
4446 // of the peer
4447 // [`upgrade_instruction_is_cleanup_implies_declared_module_is_some`]
4448 // composition pin on the two-arm cleanup-family axis — same
4449 // "predicate implies accessor" discipline extended onto the
4450 // single-arm load-family axis, closes the load-vs-cleanup
4451 // pair on the substrate primitive's typed dispatch discipline.
4452 let cases: Vec<UpgradeInstruction> = vec![
4453 UpgradeInstruction::LoadModule { module: "a".into() },
4454 UpgradeInstruction::SoftPurge { module: "b".into() },
4455 UpgradeInstruction::Purge { module: "c".into() },
4456 UpgradeInstruction::StateChange {
4457 script: PathBuf::from("lib/m.lisp"),
4458 },
4459 UpgradeInstruction::Restart,
4460 ];
4461 for instr in &cases {
4462 if instr.is_load_module() {
4463 assert!(
4464 instr.declared_module().is_some(),
4465 "UpgradeInstruction::{instr:?}: is_load_module() \
4466 must imply declared_module().is_some() — the \
4467 within-entry load-singularity gate relies on this \
4468 invariant to route the load-target :module scalar \
4469 through the sibling declared_module accessor \
4470 without a pattern-bound `module` binding"
4471 );
4472 }
4473 }
4474 }
4475
4476 #[test]
4477 fn validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors()
4478 {
4479 // Byte-identity pin on the
4480 // [`UpgradeFromEntry::validate_load_singularity`] load-family
4481 // dispatch against the pre-lift
4482 // `match instr { UpgradeInstruction::LoadModule { module } =>
4483 // module.as_str(), _ => continue }` open-coded pattern-match
4484 // the site previously carried. Asserts the two projections
4485 // agree byte-for-byte on every arm of the enum — the
4486 // arm-discriminator via `is_load_module()` and the `:module`
4487 // scalar via `declared_module()` — so a future derive
4488 // regression that flipped the predicate's arm-set (a hole
4489 // returning `false` for [`UpgradeInstruction::LoadModule`], a
4490 // byte-collision flipping a second variant to `true`) or an
4491 // accessor extension that promoted an additional variant onto
4492 // the `String`-carrying axis would trip here at caixa-core
4493 // test time rather than laundering the arm at the gate's
4494 // per-entry load-singularity scan far from the derive site.
4495 // Peer of the sibling
4496 // [`validate_purge_ordering_routes_through_is_load_module_predicate`]
4497 // byte-identity pin on the paired ordering-side load-family
4498 // sticky-latch dispatch (both consumers now agree on one
4499 // typed dispatch for the load-family axis) and the peer
4500 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
4501 // pin on the migration-family script-projection axis — the
4502 // three within-entry per-instruction-class singularity gates
4503 // now share one byte-identity pin apiece against their
4504 // respective substrate-primitive typed dispatches.
4505 //
4506 // Three-arm projective coverage:
4507 // (a) `LoadModule` modules project through
4508 // `declared_module()` byte-equal to the raw
4509 // `module.as_str()` field access;
4510 // (b) a duplicate-`LoadModule` input trips the gate on the
4511 // second occurrence with `DuplicateLoadModule` carrying
4512 // the offending module verbatim;
4513 // (c) a non-`LoadModule`-only input (`SoftPurge` / `Purge` /
4514 // `StateChange` / `Restart`) leaves the gate vacuous
4515 // with `Ok(())` — the `!instr.is_load_module()`
4516 // `continue` fall-through pins.
4517 //
4518 // Fail-before-pass-after verified locally: swapping the
4519 // production `if !instr.is_load_module() { continue; } let
4520 // module = instr.declared_module().expect(…);` back to `let
4521 // module = match instr { UpgradeInstruction::LoadModule
4522 // { module } => module.as_str(), _ => continue, };` keeps
4523 // arms (a)-(c) passing but silently detaches the gate from
4524 // the accessor's typed dispatch — any future
4525 // `is_load_module` / `declared_module` extension (a hole in
4526 // either predicate, a promotion of an additional variant
4527 // onto the `String`-carrying axis, an operator-side
4528 // pre-parsed caixa-name cache the accessor materializes)
4529 // would then silently disagree between this gate's raw
4530 // pattern-match and the peer per-`UpgradeInstruction`
4531 // consumers that route through the accessor pair.
4532
4533 // (a) LoadModule projection byte-equal via
4534 // is_load_module() + declared_module().
4535 let lm = UpgradeInstruction::LoadModule {
4536 module: "hello-rio".into(),
4537 };
4538 assert!(
4539 lm.is_load_module(),
4540 "LoadModule must satisfy is_load_module() — the gate's \
4541 load-family arm-discriminator relies on this partition"
4542 );
4543 assert_eq!(
4544 lm.declared_module(),
4545 Some("hello-rio"),
4546 "declared_module() must project the LoadModule :module \
4547 byte-equal to the raw field access — accessor divergence \
4548 would silently detach the gate from the projection every \
4549 peer per-`UpgradeInstruction` consumer routes through"
4550 );
4551
4552 // (b) Duplicate-LoadModule input trips the gate.
4553 let dup = entry(
4554 "0.1.0",
4555 vec![
4556 UpgradeInstruction::LoadModule { module: "x".into() },
4557 UpgradeInstruction::LoadModule { module: "x".into() },
4558 ],
4559 );
4560 assert_eq!(
4561 dup.validate_load_singularity(),
4562 Err(UpgradeError::DuplicateLoadModule {
4563 from: "0.1.0".into(),
4564 module: "x".into(),
4565 }),
4566 "duplicate LoadModule modules within one entry must fire \
4567 DuplicateLoadModule byte-identical to the pre-lift \
4568 pattern-match shape"
4569 );
4570
4571 // (c) Non-LoadModule-only input leaves the gate vacuous.
4572 let no_load = entry(
4573 "0.1.0",
4574 vec![
4575 UpgradeInstruction::StateChange {
4576 script: PathBuf::from("lib/m.lisp"),
4577 },
4578 UpgradeInstruction::Restart,
4579 ],
4580 );
4581 assert_eq!(
4582 no_load.validate_load_singularity(),
4583 Ok(()),
4584 "non-LoadModule-only entries must leave the load-\
4585 singularity gate vacuous — the `!is_load_module()` \
4586 continue fall-through pins"
4587 );
4588 }
4589
4590 #[test]
4591 fn upgrade_instruction_is_load_module_predicate_partitions_the_arm_set() {
4592 // The fail-before-pass-after pin on the `gen_platform::IsVariant`
4593 // derive's [`UpgradeInstruction::is_load_module`] arm-discriminator
4594 // predicate: [`UpgradeInstruction::LoadModule`] is the only
4595 // variant that satisfies `.is_load_module()`; every cleanup arm
4596 // (`SoftPurge` / `Purge`), the migration arm (`StateChange`),
4597 // and the terminal-fallback arm (`Restart`) all return `false`.
4598 // This pin makes the partition invariant load-bearing at
4599 // caixa-core test time so a future derive regression (a hole
4600 // that returns `false` for `LoadModule` too, or a byte-collision
4601 // that flips a second variant to `true`) trips here rather than
4602 // laundering the arm at
4603 // [`Self::validate_purge_ordering`]'s load-family sticky-latch
4604 // dispatch — a hole would silently keep `loaded = false` through
4605 // a well-shaped [`UpgradeInstruction::LoadModule`] prefix and
4606 // false-fire `PurgeWithoutPriorLoad` on the trailing cleanup;
4607 // a collision would flip `loaded = true` on a well-shaped
4608 // cleanup-only entry and silently swallow the load-less
4609 // `PurgeWithoutPriorLoad` refusal. Peer of the sibling
4610 // [`upgrade_instruction_is_restart_predicate_partitions_the_arm_set`]
4611 // and
4612 // [`upgrade_instruction_is_cleanup_predicate_partitions_the_arm_set`]
4613 // pins on the paired terminal-fallback and cleanup-family
4614 // arm-discriminator axes — closes the last unlifted `matches!`-
4615 // based arm-discriminator axis on the OTP-appup closed-set
4616 // typed enum.
4617 let cases: &[(UpgradeInstruction, bool)] = &[
4618 (UpgradeInstruction::LoadModule { module: "a".into() }, true),
4619 (UpgradeInstruction::SoftPurge { module: "b".into() }, false),
4620 (UpgradeInstruction::Purge { module: "c".into() }, false),
4621 (
4622 UpgradeInstruction::StateChange {
4623 script: PathBuf::from("lib/m.lisp"),
4624 },
4625 false,
4626 ),
4627 (UpgradeInstruction::Restart, false),
4628 ];
4629 for (variant, expected) in cases {
4630 assert_eq!(
4631 variant.is_load_module(),
4632 *expected,
4633 "UpgradeInstruction::{variant:?}.is_load_module() must \
4634 return {expected} (partition invariant on the \
4635 IsVariant-derived arm-discriminator predicate)"
4636 );
4637 }
4638 }
4639
4640 #[test]
4641 fn validate_purge_ordering_routes_through_is_load_module_predicate() {
4642 // Byte-identity pin on the [`Self::validate_purge_ordering`]
4643 // load-family sticky-latch dispatch against the pre-lift
4644 // `matches!(instr, UpgradeInstruction::LoadModule { .. })`
4645 // predicate the site previously open-coded. Asserts the two
4646 // projections agree byte-for-byte on every arm of the enum, so
4647 // a future derive regression that flipped the predicate's
4648 // arm-set would surface here at caixa-core test time rather
4649 // than at [`Self::validate_purge_ordering`]'s per-entry
4650 // load-before-cleanup ordering scan far from the derive site.
4651 // Same peer-shape pin the sibling
4652 // [`validate_restart_exclusive_routes_through_is_restart_predicate`]
4653 // carries on the paired terminal-fallback axis and the
4654 // [`upgrade_instruction_is_cleanup_composes_through_is_soft_purge_or_is_purge`]
4655 // carries on the two-arm cleanup-family axis — the third and
4656 // final byte-identity pin closes the substrate primitive's
4657 // arm-discriminator dispatch discipline on the OTP-appup
4658 // closed-set typed enum.
4659 let cases: Vec<UpgradeInstruction> = vec![
4660 UpgradeInstruction::LoadModule { module: "a".into() },
4661 UpgradeInstruction::SoftPurge { module: "b".into() },
4662 UpgradeInstruction::Purge { module: "c".into() },
4663 UpgradeInstruction::StateChange {
4664 script: PathBuf::from("lib/m.lisp"),
4665 },
4666 UpgradeInstruction::Restart,
4667 ];
4668 for instr in &cases {
4669 let via_predicate = instr.is_load_module();
4670 let via_matches = matches!(instr, UpgradeInstruction::LoadModule { .. });
4671 assert_eq!(
4672 via_predicate, via_matches,
4673 "UpgradeInstruction::{instr:?}: is_load_module() must \
4674 byte-equal matches!(_, UpgradeInstruction::LoadModule \
4675 {{ .. }}) — the pre-lift open-coded pattern and the \
4676 IsVariant-derived predicate are the same axis, one \
4677 typed dispatch"
4678 );
4679 }
4680 }
4681
4682 #[test]
4683 fn declared_module_only_for_module_bearing_variants() {
4684 // Pinned partition of the `UpgradeInstruction` closed-set
4685 // variant space against the sibling of the peer
4686 // `declared_path` accessor: every OTP-appup module-bearing
4687 // variant (`LoadModule` / `SoftPurge` / `Purge`) surfaces its
4688 // `:module` string byte-for-byte through the lifted
4689 // `declared_module` accessor; every non-module-bearing variant
4690 // (`StateChange` on the peer `:script`-carrying axis;
4691 // `Restart` on the OTP terminal-fallback data-less axis)
4692 // returns `None`. Mirrors the peer
4693 // `declared_path_only_for_state_change` pin — the pair now
4694 // closes both scalar-carrying axes on the enum on one lifted
4695 // `Option<&…>` accessor apiece.
4696 let load = UpgradeInstruction::LoadModule {
4697 module: "hello-rio".into(),
4698 };
4699 assert_eq!(load.declared_module(), Some("hello-rio"));
4700 let soft = UpgradeInstruction::SoftPurge {
4701 module: "hello-rio-old".into(),
4702 };
4703 assert_eq!(soft.declared_module(), Some("hello-rio-old"));
4704 let hard = UpgradeInstruction::Purge {
4705 module: "hello-rio-ancient".into(),
4706 };
4707 assert_eq!(hard.declared_module(), Some("hello-rio-ancient"));
4708 let mig = UpgradeInstruction::StateChange {
4709 script: PathBuf::from("lib/m.lisp"),
4710 };
4711 assert!(mig.declared_module().is_none());
4712 assert!(UpgradeInstruction::Restart.declared_module().is_none());
4713 }
4714
4715 #[test]
4716 fn declared_module_and_declared_path_partition_the_enum_variant_space() {
4717 // Byte-identity pin on the two-accessor partition: every
4718 // `UpgradeInstruction` variant returns `Some` from *exactly
4719 // one* of {`declared_module`, `declared_path`} (the two
4720 // module-bearing / script-carrying axes) or from *neither*
4721 // (the OTP terminal-fallback `Restart` shape). No variant
4722 // returns `Some` from both — the two axes are disjoint by
4723 // construction, and this pin closes the disjointness at the
4724 // test surface so a future variant that leaks a scalar across
4725 // both axes fails at build time. Mirrors the peer
4726 // `declared_paths_iter_covers_each_declared_slot_exactly_once`
4727 // discipline on the `BehaviorSpec` per-slot family.
4728 let cases: Vec<UpgradeInstruction> = vec![
4729 UpgradeInstruction::LoadModule { module: "a".into() },
4730 UpgradeInstruction::SoftPurge { module: "b".into() },
4731 UpgradeInstruction::Purge { module: "c".into() },
4732 UpgradeInstruction::StateChange {
4733 script: PathBuf::from("lib/m.lisp"),
4734 },
4735 UpgradeInstruction::Restart,
4736 ];
4737 for instr in &cases {
4738 let has_module = instr.declared_module().is_some();
4739 let has_path = instr.declared_path().is_some();
4740 assert!(
4741 !(has_module && has_path),
4742 "no variant may declare both a module and a path — offending: {instr:?}"
4743 );
4744 match instr {
4745 UpgradeInstruction::LoadModule { .. }
4746 | UpgradeInstruction::SoftPurge { .. }
4747 | UpgradeInstruction::Purge { .. } => {
4748 assert!(has_module && !has_path, "module axis: {instr:?}");
4749 }
4750 UpgradeInstruction::StateChange { .. } => {
4751 assert!(!has_module && has_path, "script axis: {instr:?}");
4752 }
4753 UpgradeInstruction::Restart => {
4754 assert!(!has_module && !has_path, "data-less axis: {instr:?}");
4755 }
4756 }
4757 }
4758 }
4759
4760 #[test]
4761 fn entry_with_chain_of_versions() {
4762 // Middle entry pairs a `:load-module` with the trailing
4763 // `:soft-purge` so it satisfies the within-entry purge-ordering
4764 // gate (`PurgeWithoutPriorLoad` rejects `:soft-purge` without a
4765 // preceding `:load-module`, mirroring the state-change-ordering
4766 // gate's `StateChangeWithoutPriorLoad`). The chain shape under
4767 // test is *cross-entry* `:from` values; the within-entry shape
4768 // is incidental — keeping it canonical (`:load-module` before
4769 // `:soft-purge`) leaves the chain assertion load-bearing.
4770 let entries = vec![
4771 entry(
4772 "0.1.0",
4773 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4774 ),
4775 entry(
4776 "0.1.5",
4777 vec![
4778 UpgradeInstruction::LoadModule { module: "x".into() },
4779 UpgradeInstruction::SoftPurge {
4780 module: "x-old".into(),
4781 },
4782 ],
4783 ),
4784 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4785 ];
4786 for e in &entries {
4787 e.validate().unwrap();
4788 }
4789 let json = serde_json::to_string(&entries).unwrap();
4790 let back: Vec<UpgradeFromEntry> = serde_json::from_str(&json).unwrap();
4791 assert_eq!(entries, back);
4792 }
4793
4794 #[test]
4795 fn empty_instructions_list_is_valid() {
4796 let e = entry("0.1.0", vec![]);
4797 e.validate().unwrap();
4798 }
4799
4800 #[test]
4801 fn json_uses_kebab_case_kind_tags() {
4802 let i = UpgradeInstruction::SoftPurge {
4803 module: "x-old".into(),
4804 };
4805 let json = serde_json::to_string(&i).unwrap();
4806 assert!(json.contains("\"kind\":\"soft-purge\""));
4807 let i2 = UpgradeInstruction::StateChange {
4808 script: PathBuf::from("m.lisp"),
4809 };
4810 let json2 = serde_json::to_string(&i2).unwrap();
4811 assert!(json2.contains("\"kind\":\"state-change\""));
4812 }
4813
4814 // ── validate_upgrade_from: cross-entry graph-edge-set invariant ────
4815
4816 #[test]
4817 fn validate_upgrade_from_accepts_disjoint_versions() {
4818 // Positive control: the canonical "chain v0.1.0 → 0.1.5 →
4819 // 0.2.0-rc.1" authoring shape from ABSORPTION-ROADMAP §M2.3
4820 // (and `entry_with_chain_of_versions` above) passes the cross-
4821 // entry gate. Different `:from` per entry is the intended
4822 // shape; the gate must not regress this baseline. Middle entry
4823 // pairs `:load-module` with `:soft-purge` to satisfy the
4824 // within-entry purge-ordering gate (see
4825 // `entry_with_chain_of_versions` for the same shape).
4826 let entries = vec![
4827 entry(
4828 "0.1.0",
4829 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4830 ),
4831 entry(
4832 "0.1.5",
4833 vec![
4834 UpgradeInstruction::LoadModule { module: "x".into() },
4835 UpgradeInstruction::SoftPurge {
4836 module: "x-old".into(),
4837 },
4838 ],
4839 ),
4840 entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart]),
4841 ];
4842 validate_upgrade_from(&entries).unwrap();
4843 }
4844
4845 #[test]
4846 fn validate_upgrade_from_accepts_empty_list() {
4847 // Absent `:upgrade-from` (the bare `feira init` shape) — the
4848 // gate must trivially pass an empty list. Mirrors the per-axis
4849 // "empty list passes" positive control on every peer typed-
4850 // graph gate (`validate_membros` empty list, `validate_placement`
4851 // requires non-empty clusters but only after a `Placement`
4852 // exists, etc.).
4853 validate_upgrade_from(&[]).unwrap();
4854 }
4855
4856 #[test]
4857 fn validate_upgrade_from_rejects_duplicate_from() {
4858 // Fail-before-pass-after pin: two entries with the same parsed-
4859 // semver `:from` are an ambiguous edge in the typed upgrade
4860 // graph (OTP appup picks at most one matching block per running
4861 // version; with two matching blocks the operator picks either
4862 // set non-deterministically — author intent is one path per
4863 // prior version). Same set-not-multiset discipline as
4864 // `:children :caixa` (dbf50a9), `:membros :caixa` (4bb3f3d),
4865 // `:contratos` (5dbcfaf), `:placement :clusters` (c7c7799),
4866 // `:entrada :paths` (eb3456d) — now extended onto the fifth
4867 // typed-graph axis.
4868 let entries = vec![
4869 entry(
4870 "0.1.0",
4871 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
4872 ),
4873 entry(
4874 "0.1.0",
4875 vec![
4876 UpgradeInstruction::LoadModule { module: "x".into() },
4877 UpgradeInstruction::SoftPurge {
4878 module: "x-old".into(),
4879 },
4880 ],
4881 ),
4882 ];
4883 let err = validate_upgrade_from(&entries).unwrap_err();
4884 assert_eq!(
4885 err,
4886 UpgradeError::DuplicateFrom {
4887 from: "0.1.0".into()
4888 },
4889 "two entries with `:from \"0.1.0\"` must surface as DuplicateFrom carrying the \
4890 offending value verbatim"
4891 );
4892 }
4893
4894 #[test]
4895 fn validate_upgrade_from_treats_pre_release_as_distinct() {
4896 // Negative-of-positive: `1.0.0` and `1.0.0-rc.1` are *not*
4897 // equal under semver (pre-release version is part of the
4898 // identity), so they're distinct upgrade paths and must not
4899 // collide. A future tightening that collapses pre-release into
4900 // the release version surfaces here.
4901 let entries = vec![
4902 entry("1.0.0", vec![UpgradeInstruction::Restart]),
4903 entry("1.0.0-rc.1", vec![UpgradeInstruction::Restart]),
4904 ];
4905 validate_upgrade_from(&entries).unwrap();
4906 }
4907
4908 #[test]
4909 fn validate_upgrade_from_treats_build_metadata_as_distinct() {
4910 // Conservative-by-design: [`semver::Version`]'s `PartialEq`
4911 // compares build metadata (it derives equality across all
4912 // fields including `pre` + `build`), so `1.0.0+build1` and
4913 // `1.0.0+build2` are *not* duplicates from the gate's
4914 // perspective — the operator may treat the build-metadata
4915 // suffix as a tiebreaker even though the semver spec says
4916 // build metadata is ignored for precedence
4917 // (https://semver.org/#spec-item-10). Pin the conservative
4918 // behavior here so a future switch to a build-metadata-
4919 // stripping comparator surfaces as a test failure first; that
4920 // change would require coordinating with the wasm-operator's
4921 // `:from`-match dispatch step, which is the load-bearing
4922 // semantic we'd be mirroring.
4923 let entries = vec![
4924 entry("1.0.0+build1", vec![UpgradeInstruction::Restart]),
4925 entry("1.0.0+build2", vec![UpgradeInstruction::Restart]),
4926 ];
4927 validate_upgrade_from(&entries).unwrap();
4928 }
4929
4930 #[test]
4931 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate() {
4932 // Order pin: a malformed `:from` on the second entry surfaces
4933 // its `FromInvalid` diagnostic, not a (less-useful)
4934 // `DuplicateFrom`. The per-entry shape pass runs *inline*
4935 // before the duplicate-key insert — parallel to
4936 // `child_versao_invalid_fires_before_duplicate_check`
4937 // (b38ff3a) and `membro_versao_invalid_fires_before_duplicate_check`
4938 // (9888b13). Without this pin a future shortcut that runs the
4939 // cross-entry gate first would surface a duplicate diagnostic
4940 // on a string that isn't even parsable as a version.
4941 let entries = vec![
4942 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4943 entry("not-a-semver", vec![UpgradeInstruction::Restart]),
4944 ];
4945 let err = validate_upgrade_from(&entries).unwrap_err();
4946 assert!(
4947 matches!(err, UpgradeError::FromInvalid { ref from, .. } if from == "not-a-semver"),
4948 "malformed `:from` on a non-duplicate entry must surface as FromInvalid, got {err:?}"
4949 );
4950 }
4951
4952 #[test]
4953 fn validate_upgrade_from_per_entry_shape_fires_before_duplicate_on_first_entry() {
4954 // Symmetric arm: a malformed shape on the *first* entry of a
4955 // duplicate pair surfaces its per-entry diagnostic too (not
4956 // the duplicate diagnostic that would otherwise fire on the
4957 // second entry). Pinned separately so a future shortcut that
4958 // walks the duplicate-check ahead of the per-entry pass for the
4959 // first entry only — easy regression to introduce — surfaces
4960 // here.
4961 let entries = vec![
4962 entry(
4963 "0.1.0",
4964 vec![UpgradeInstruction::LoadModule {
4965 module: String::new(),
4966 }],
4967 ),
4968 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4969 ];
4970 let err = validate_upgrade_from(&entries).unwrap_err();
4971 assert_eq!(
4972 err,
4973 UpgradeError::ModuleEmpty {
4974 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
4975 },
4976 "malformed instruction on the first entry of a duplicate pair must surface its \
4977 per-entry diagnostic before the duplicate gate fires, got {err:?}"
4978 );
4979 }
4980
4981 #[test]
4982 fn validate_upgrade_from_duplicate_diagnostic_names_second_collision() {
4983 // Diagnostic-shape pin: when three entries carry the same
4984 // `:from`, the gate reports the *first* collision (the second
4985 // entry) and stops — the third entry's duplicate is masked by
4986 // the first surfaced one. Mirrors
4987 // `validate_duplicate_child_diagnostic_names_first_collision`
4988 // (dbf50a9) on the supervisor axis.
4989 let entries = vec![
4990 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4991 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4992 entry("0.1.0", vec![UpgradeInstruction::Restart]),
4993 ];
4994 let err = validate_upgrade_from(&entries).unwrap_err();
4995 assert_eq!(
4996 err,
4997 UpgradeError::DuplicateFrom {
4998 from: "0.1.0".into()
4999 }
5000 );
5001 }
5002
5003 #[test]
5004 fn validate_upgrade_from_single_entry_never_duplicates() {
5005 // Boundary control: a list of one entry can never produce a
5006 // duplicate, regardless of `:from` value (any single-element
5007 // set is trivially without duplicates). Pin this so a future
5008 // off-by-one in the seen-set insert doesn't accidentally flag
5009 // a single entry as duplicating itself.
5010 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
5011 validate_upgrade_from(&entries).unwrap();
5012 }
5013
5014 // ── validate_upgrade_from_against_versao: cross-slot precedence gate ─
5015
5016 #[test]
5017 fn versao_gate_accepts_strict_upgrade() {
5018 // Positive control: the canonical "chain prior versions →
5019 // current" authoring shape from ABSORPTION-ROADMAP §M2.3 — each
5020 // `:from` strictly less than the current `:versao` under
5021 // SemVer-2 precedence. The gate must not regress this baseline.
5022 let entries = vec![
5023 entry("0.1.0", vec![UpgradeInstruction::Restart]),
5024 entry("0.1.5", vec![UpgradeInstruction::Restart]),
5025 entry("0.1.9", vec![UpgradeInstruction::Restart]),
5026 ];
5027 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5028 }
5029
5030 #[test]
5031 fn versao_gate_accepts_empty_entries() {
5032 // Bare `feira init` shape (no `:upgrade-from`) trivially passes;
5033 // the gate is a no-op when the entries list is empty. Mirrors
5034 // `validate_upgrade_from_accepts_empty_list` on the peer gate.
5035 validate_upgrade_from_against_versao(&[], "0.1.0").unwrap();
5036 }
5037
5038 #[test]
5039 fn versao_gate_rejects_equal_from() {
5040 // Self-upgrade no-op: declaring `:from "0.2.0"` while
5041 // `:versao "0.2.0"` means "upgrade from myself to myself" —
5042 // the operator's dispatch either skips silently or
5043 // trivially "succeeds" with no observable state change.
5044 // Reject as the canonical "I forgot to bump :versao when
5045 // adding this entry" footgun.
5046 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
5047 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5048 assert_eq!(
5049 err,
5050 UpgradeError::FromNotBeforeVersao {
5051 from: "0.2.0".into(),
5052 versao: "0.2.0".into(),
5053 },
5054 ":from == :versao under precedence must surface as FromNotBeforeVersao naming both \
5055 values verbatim, got {err:?}"
5056 );
5057 }
5058
5059 #[test]
5060 fn versao_gate_rejects_downgrade_from() {
5061 // Downgrade-shaped: `:from "0.3.0"` while `:versao "0.2.0"`
5062 // means "upgrade nodes coming from 0.3.0 to 0.2.0", which
5063 // the operator's `:from`-match dispatch can never reach (it
5064 // never runs a version >= the current one). Reject as the
5065 // canonical "I copy-pasted from the next minor version and
5066 // forgot to bump :versao" footgun.
5067 let entries = vec![entry("0.3.0", vec![UpgradeInstruction::Restart])];
5068 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5069 assert_eq!(
5070 err,
5071 UpgradeError::FromNotBeforeVersao {
5072 from: "0.3.0".into(),
5073 versao: "0.2.0".into(),
5074 }
5075 );
5076 }
5077
5078 #[test]
5079 fn versao_gate_accepts_prerelease_before_release() {
5080 // SemVer §11 precedence: pre-release versions are *less than*
5081 // the corresponding release (`0.2.0-rc.1 < 0.2.0`). Upgrading
5082 // FROM an RC TO the GA release is the canonical authoring
5083 // shape — must pass. A regression that collapses pre-release
5084 // into the release version (treating them as equal) surfaces
5085 // here as a false-positive rejection.
5086 let entries = vec![entry("0.2.0-rc.1", vec![UpgradeInstruction::Restart])];
5087 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5088 }
5089
5090 #[test]
5091 fn versao_gate_rejects_release_after_prerelease() {
5092 // Symmetric arm: with `:versao "0.2.0-rc.1"` and
5093 // `:from "0.2.0"`, precedence says `0.2.0 > 0.2.0-rc.1` —
5094 // the typical "I'm on an RC of a release that already
5095 // shipped" footgun. The gate names both values verbatim
5096 // so the author can grep for either side and fix in one
5097 // edit.
5098 let entries = vec![entry("0.2.0", vec![UpgradeInstruction::Restart])];
5099 let err = validate_upgrade_from_against_versao(&entries, "0.2.0-rc.1").unwrap_err();
5100 assert_eq!(
5101 err,
5102 UpgradeError::FromNotBeforeVersao {
5103 from: "0.2.0".into(),
5104 versao: "0.2.0-rc.1".into(),
5105 }
5106 );
5107 }
5108
5109 #[test]
5110 fn versao_gate_rejects_build_metadata_only_difference() {
5111 // SemVer §11 explicitly excludes build metadata from
5112 // precedence comparison: `0.2.0+build.1` and `0.2.0` are
5113 // *equal* under [`semver::Version::cmp`]. From the
5114 // operator's `:from`-match dispatch perspective this is a
5115 // self-upgrade no-op (no semantic transition between the
5116 // two), so the gate rejects it — *unlike* the peer
5117 // duplicate-`:from` gate which uses derived `PartialEq` and
5118 // treats build-metadata variants as distinct dispatch keys.
5119 // The two gates' different equality notions are deliberate:
5120 // duplicate-check is conservative (preserves operator-side
5121 // tiebreaking surface), precedence-check is permissive
5122 // (matches operator-side dispatch semantic).
5123 let entries = vec![entry("0.2.0+build.1", vec![UpgradeInstruction::Restart])];
5124 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5125 assert_eq!(
5126 err,
5127 UpgradeError::FromNotBeforeVersao {
5128 from: "0.2.0+build.1".into(),
5129 versao: "0.2.0".into(),
5130 }
5131 );
5132 }
5133
5134 #[test]
5135 fn versao_gate_silently_passes_on_unparseable_versao() {
5136 // Defensive arm: a malformed `:versao` (gated by the
5137 // narrower `ManifestError::VersaoInvalid` surface at the
5138 // load-bearing call site) must not regress into a
5139 // `FromNotBeforeVersao` diagnostic from this gate. Surfacing
5140 // the precedence error over an unparseable `:versao` would
5141 // mask the more actionable root cause (the author meant to
5142 // type `"0.2.0"`, not `"v0.2.0"`).
5143 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
5144 validate_upgrade_from_against_versao(&entries, "not-a-semver").unwrap();
5145 }
5146
5147 #[test]
5148 fn versao_gate_silently_passes_on_unparseable_from() {
5149 // Symmetric defensive arm: a malformed `:from` is gated by
5150 // [`UpgradeFromEntry::validate`] / [`validate_upgrade_from`]
5151 // upstream at the LayoutInvariants call site. Surfacing the
5152 // precedence error over an unparseable `:from` from this
5153 // gate alone would mask the narrower `FromInvalid`
5154 // diagnostic that's expected to lead — same fall-through
5155 // posture as the unparseable-`:versao` arm above. The
5156 // wiring in `LayoutInvariants::verify` runs
5157 // `validate_upgrade_from` *before* this gate, so in practice
5158 // an unparseable `:from` surfaces as `FromInvalid` first
5159 // and this gate is never reached on that input.
5160 let entries = vec![entry("not-a-semver", vec![UpgradeInstruction::Restart])];
5161 validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap();
5162 }
5163
5164 #[test]
5165 fn versao_gate_reports_first_offending_entry() {
5166 // Determinism pin: with multiple offending entries the gate
5167 // surfaces the *first* one in declaration order — same
5168 // posture as `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
5169 // on the peer gate. Walks the entries in order; first
5170 // failing `:from >= :versao` short-circuits.
5171 let entries = vec![
5172 entry("0.1.0", vec![UpgradeInstruction::Restart]),
5173 entry("0.3.0", vec![UpgradeInstruction::Restart]),
5174 entry("0.4.0", vec![UpgradeInstruction::Restart]),
5175 ];
5176 let err = validate_upgrade_from_against_versao(&entries, "0.2.0").unwrap_err();
5177 assert_eq!(
5178 err,
5179 UpgradeError::FromNotBeforeVersao {
5180 from: "0.3.0".into(),
5181 versao: "0.2.0".into(),
5182 },
5183 "the first offending `:from` (0.3.0) must surface, not the later one (0.4.0)"
5184 );
5185 }
5186
5187 // ── UpgradeFromEntry::validate_restart_exclusive: within-entry gate ─
5188
5189 #[test]
5190 fn validate_rejects_restart_mixed_with_load_module() {
5191 // The "I'll try the typed path *then* restart anyway" footgun:
5192 // an instructions list with `(:restart)` plus `(:load-module …)`
5193 // is dead code in both directions (succeed → restart discards
5194 // the work that just succeeded, defeating the typed sequence's
5195 // whole point; fail → restart never reached because the entry
5196 // already failed). The gate names the offending entry's `:from`
5197 // verbatim plus the kebab-case lisp-form of every non-`:restart`
5198 // peer so the author can grep their caixa.lisp for either side
5199 // and fix in one edit.
5200 let e = entry(
5201 "0.1.0",
5202 vec![
5203 UpgradeInstruction::LoadModule {
5204 module: "hello-rio".into(),
5205 },
5206 UpgradeInstruction::Restart,
5207 ],
5208 );
5209 let err = e.validate().unwrap_err();
5210 assert_eq!(
5211 err,
5212 UpgradeError::RestartNotExclusive {
5213 from: "0.1.0".into(),
5214 restart_count: 1,
5215 other_kinds: vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
5216 },
5217 "restart + load-module mix must surface as RestartNotExclusive naming the \
5218 offending `:from` + the non-:restart kinds verbatim, got {err:?}"
5219 );
5220 }
5221
5222 #[test]
5223 fn validate_rejects_restart_mixed_with_full_typed_sequence() {
5224 // Sweep the typed-sequence universe — every non-`:restart`
5225 // variant alongside `:restart` — and assert every typed
5226 // instruction's lisp-form appears in `other_kinds` in
5227 // declaration order. The author should be able to grep for
5228 // each verbatim (`:load-module`, `:state-change`, `:soft-purge`,
5229 // `:purge`) and resolve in one pass. Drift in the `lisp_form`
5230 // mapping surfaces here.
5231 let e = entry(
5232 "0.1.0",
5233 vec![
5234 UpgradeInstruction::LoadModule {
5235 module: "hello-rio".into(),
5236 },
5237 UpgradeInstruction::StateChange {
5238 script: PathBuf::from("lib/m.lisp"),
5239 },
5240 UpgradeInstruction::SoftPurge {
5241 module: "hello-rio-old".into(),
5242 },
5243 UpgradeInstruction::Purge {
5244 module: "hello-rio-old".into(),
5245 },
5246 UpgradeInstruction::Restart,
5247 ],
5248 );
5249 let err = e.validate().unwrap_err();
5250 assert_eq!(
5251 err,
5252 UpgradeError::RestartNotExclusive {
5253 from: "0.1.0".into(),
5254 restart_count: 1,
5255 other_kinds: vec![
5256 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
5257 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
5258 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5259 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5260 ],
5261 },
5262 );
5263 }
5264
5265 #[test]
5266 fn validate_rejects_restart_duplicated() {
5267 // `((:restart) (:restart))` — multiple Restart variants in one
5268 // entry. The fallback is a single semantic (restart the pod;
5269 // the new version comes up fresh); repeating it is at best
5270 // redundant, at worst suggests the author thought the second
5271 // would re-trigger after the first. The gate reports
5272 // `restart_count: 2` so the diagnostic surfaces the duplication
5273 // mode unambiguously even when `other_kinds` is empty.
5274 let e = entry(
5275 "0.1.0",
5276 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5277 );
5278 let err = e.validate().unwrap_err();
5279 assert_eq!(
5280 err,
5281 UpgradeError::RestartNotExclusive {
5282 from: "0.1.0".into(),
5283 restart_count: 2,
5284 other_kinds: vec![],
5285 },
5286 );
5287 }
5288
5289 #[test]
5290 fn validate_accepts_sole_restart() {
5291 // Positive control: the canonical "this prior version's typed
5292 // upgrade is impossible — restart" authoring shape from the
5293 // UpgradeInstruction::Restart doc comment. `((:restart))` alone
5294 // is the entry's whole instructions list and the only valid
5295 // Restart-bearing shape.
5296 let e = entry("0.1.0", vec![UpgradeInstruction::Restart]);
5297 e.validate().unwrap();
5298 }
5299
5300 #[test]
5301 fn validate_accepts_typed_sequence_without_restart() {
5302 // Positive control: the canonical typed hot-upgrade authoring
5303 // shape from ABSORPTION-ROADMAP §M2.3 — `:load-module` →
5304 // `:state-change` → `:soft-purge`. Absent `:restart` is the
5305 // only shape that lets the sequence run to completion under
5306 // the wasm-operator's `:from`-match dispatch. Drift here =
5307 // a future tighten that rejects any canonical typed-only shape
5308 // surfaces as a regression at this gate.
5309 let e = entry(
5310 "0.1.0",
5311 vec![
5312 UpgradeInstruction::LoadModule {
5313 module: "hello-rio".into(),
5314 },
5315 UpgradeInstruction::StateChange {
5316 script: PathBuf::from("lib/m.lisp"),
5317 },
5318 UpgradeInstruction::SoftPurge {
5319 module: "hello-rio-old".into(),
5320 },
5321 ],
5322 );
5323 e.validate().unwrap();
5324 }
5325
5326 // ── within-entry state-change-ordering invariant ───────────────────
5327
5328 #[test]
5329 fn validate_rejects_state_change_without_load() {
5330 // Fail-before-pass-after pin: a `:state-change` migrates state
5331 // into the newly-loaded code (gen_server:code_change/3 analog),
5332 // so an entry that runs it with no preceding `:load-module`
5333 // migrates state into code that was never loaded. The operator
5334 // runs instructions in declared order, so this is a build error,
5335 // not a runtime surprise (CAIXA-SDLC §III).
5336 let e = entry(
5337 "0.1.0",
5338 vec![UpgradeInstruction::StateChange {
5339 script: PathBuf::from("lib/m.lisp"),
5340 }],
5341 );
5342 let err = e.validate().unwrap_err();
5343 assert_eq!(
5344 err,
5345 UpgradeError::StateChangeWithoutPriorLoad {
5346 from: "0.1.0".into(),
5347 script: PathBuf::from("lib/m.lisp"),
5348 },
5349 "a `:state-change` with no preceding `:load-module` must surface as \
5350 StateChangeWithoutPriorLoad naming the offending entry + script verbatim"
5351 );
5352 }
5353
5354 #[test]
5355 fn validate_rejects_state_change_before_load() {
5356 // Right-instructions-wrong-order: the load is present but runs
5357 // *after* the migration. Because the operator executes in
5358 // declared order, the migration runs before the new code is
5359 // resident — the same incoherence as the missing-load case.
5360 let e = entry(
5361 "0.1.0",
5362 vec![
5363 UpgradeInstruction::StateChange {
5364 script: PathBuf::from("lib/m.lisp"),
5365 },
5366 UpgradeInstruction::LoadModule {
5367 module: "hello-rio".into(),
5368 },
5369 ],
5370 );
5371 let err = e.validate().unwrap_err();
5372 assert!(
5373 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5374 "a `:state-change` ahead of its `:load-module` must surface as \
5375 StateChangeWithoutPriorLoad, got {err:?}"
5376 );
5377 }
5378
5379 #[test]
5380 fn validate_accepts_state_change_after_load() {
5381 // Positive control: the canonical `(:load-module …)
5382 // (:state-change …)` order validates. The load need not name
5383 // the same module the migration targets (StateChange carries a
5384 // script, not a module ref), so any preceding `:load-module`
5385 // satisfies "new code is resident before its migration runs".
5386 let e = entry(
5387 "0.1.0",
5388 vec![
5389 UpgradeInstruction::LoadModule {
5390 module: "hello-rio".into(),
5391 },
5392 UpgradeInstruction::StateChange {
5393 script: PathBuf::from("lib/m.lisp"),
5394 },
5395 ],
5396 );
5397 e.validate().unwrap();
5398 }
5399
5400 #[test]
5401 fn validate_accepts_multiple_state_changes_after_one_load() {
5402 // A single leading `:load-module` covers every subsequent
5403 // `:state-change` — the `loaded` latch stays set once the new
5404 // code is resident.
5405 let e = entry(
5406 "0.1.0",
5407 vec![
5408 UpgradeInstruction::LoadModule {
5409 module: "hello-rio".into(),
5410 },
5411 UpgradeInstruction::StateChange {
5412 script: PathBuf::from("lib/m1.lisp"),
5413 },
5414 UpgradeInstruction::StateChange {
5415 script: PathBuf::from("lib/m2.lisp"),
5416 },
5417 ],
5418 );
5419 e.validate().unwrap();
5420 }
5421
5422 #[test]
5423 fn validate_state_change_ordering_projects_scripts_through_is_load_module_and_declared_path_accessors()
5424 {
5425 // Byte-identity pin on the
5426 // [`UpgradeFromEntry::validate_state_change_ordering`] load →
5427 // migrate ordering dispatch against the pre-lift
5428 // `match instr { UpgradeInstruction::LoadModule { .. } =>
5429 // loaded = true, UpgradeInstruction::StateChange { script } if
5430 // !loaded => …, _ => {} }` open-coded pattern-match the site
5431 // previously carried. Asserts the two projections agree
5432 // byte-for-byte on every arm of the enum — the load-family
5433 // arm-discriminator via `is_load_module()` and the migration-
5434 // family `:script` scalar via `declared_path()` — so a future
5435 // derive regression that flipped the predicate's arm-set (a
5436 // hole returning `false` for [`UpgradeInstruction::LoadModule`],
5437 // a byte-collision flipping a second variant to `true`) or an
5438 // accessor extension that promoted an additional variant onto
5439 // the `PathBuf`-carrying axis would trip here at caixa-core
5440 // test time rather than laundering the arm at the gate's
5441 // per-entry ordering scan far from the derive site.
5442 //
5443 // Peer of the sibling
5444 // [`validate_load_singularity_projects_modules_through_is_load_module_and_declared_module_accessors`]
5445 // (c9ce91d) pin on the peer within-entry per-instruction-class
5446 // singularity gate's load-family + `String`-carrying dispatch,
5447 // the [`validate_purge_ordering_routes_through_is_load_module_predicate`]
5448 // (580d0f1) pin on the paired load → cleanup ordering gate's
5449 // load-family sticky-latch dispatch, and the
5450 // [`validate_state_change_singularity_projects_scripts_through_declared_path_accessor`]
5451 // pin on the peer within-entry per-instruction-class singularity
5452 // gate's migration-family script-projection dispatch — closes
5453 // the last unlifted `match`-shaped per-arm-hand-rolled load-
5454 // family arm-discriminator + migration-family script-projection
5455 // pair inside `impl UpgradeFromEntry`. The four within-entry
5456 // ordering / singularity gates now share one byte-identity pin
5457 // apiece against their respective substrate-primitive typed
5458 // dispatches on the OTP-appup closed-set enum.
5459 //
5460 // Three-arm projective coverage:
5461 // (a) `LoadModule` satisfies `is_load_module()`, so the
5462 // sticky-latch advances byte-equal to the pre-lift
5463 // `UpgradeInstruction::LoadModule { .. }` arm; every
5464 // other variant leaves the latch untouched;
5465 // (b) a `((:state-change …))`-only entry (no preceding load)
5466 // trips the gate on the first `StateChange` with
5467 // `StateChangeWithoutPriorLoad` carrying the offending
5468 // script verbatim — the migration-family script surfaces
5469 // through `declared_path()` byte-equal to the raw
5470 // `StateChange { script }` pattern-bound field;
5471 // (c) a `((:load-module …) (:state-change …))` entry leaves
5472 // the gate vacuous with `Ok(())` — the `loaded = true`
5473 // latch on the first arm satisfies the `!loaded` guard
5474 // negation on the second, so the `declared_path()`
5475 // `Some(script)` fall-through does not fire — and a
5476 // non-`StateChange`-non-`LoadModule` sequence
5477 // (`SoftPurge` / `Purge` / `Restart` alone) also leaves
5478 // the gate vacuous because `declared_path()` is `None`
5479 // on all three of those arms.
5480 //
5481 // Fail-before-pass-after verified locally: swapping the
5482 // production `if instr.is_load_module() { loaded = true; }
5483 // else if !loaded && let Some(script) = instr.declared_path()
5484 // { … }` back to `match instr { UpgradeInstruction::LoadModule
5485 // { .. } => loaded = true, UpgradeInstruction::StateChange
5486 // { script } if !loaded => …, _ => {} }` keeps arms (a)-(c)
5487 // passing but silently detaches the gate from the accessor's
5488 // typed dispatch — any future `is_load_module` / `declared_path`
5489 // extension (a hole in either predicate, a promotion of an
5490 // additional variant onto either axis, an operator-side
5491 // pre-resolved-path cache the accessor materializes) would
5492 // then silently disagree between this gate's raw pattern-match
5493 // and the peer per-`UpgradeInstruction` consumers that route
5494 // through the accessor pair.
5495
5496 // (a) is_load_module() partitions the arm-set byte-equal to
5497 // the pre-lift `matches!(_, UpgradeInstruction::LoadModule
5498 // { .. })` and declared_path() surfaces the StateChange
5499 // `:script` byte-equal to the raw field access.
5500 let lm = UpgradeInstruction::LoadModule {
5501 module: "hello-rio".into(),
5502 };
5503 assert!(
5504 lm.is_load_module(),
5505 "LoadModule must satisfy is_load_module() — the gate's \
5506 load-family sticky-latch relies on this partition"
5507 );
5508 assert!(
5509 lm.declared_path().is_none(),
5510 "LoadModule must not carry a declared_path — the gate's \
5511 else-if migration-family arm must not fire on load arms"
5512 );
5513 let sc = UpgradeInstruction::StateChange {
5514 script: PathBuf::from("lib/m.lisp"),
5515 };
5516 assert!(
5517 !sc.is_load_module(),
5518 "StateChange must not satisfy is_load_module() — the gate's \
5519 sticky-latch must not advance on migration arms"
5520 );
5521 assert_eq!(
5522 sc.declared_path().map(std::path::PathBuf::as_path),
5523 Some(PathBuf::from("lib/m.lisp").as_path()),
5524 "declared_path() must project the StateChange :script \
5525 byte-equal to the raw field access — accessor divergence \
5526 would silently detach the gate from the projection every \
5527 peer per-`UpgradeInstruction` consumer routes through"
5528 );
5529
5530 // (b) A `((:state-change …))`-only entry trips
5531 // StateChangeWithoutPriorLoad byte-identical to the
5532 // pre-lift match-pattern shape.
5533 let no_prior_load = entry(
5534 "0.1.0",
5535 vec![UpgradeInstruction::StateChange {
5536 script: PathBuf::from("lib/m.lisp"),
5537 }],
5538 );
5539 assert_eq!(
5540 no_prior_load.validate_state_change_ordering(),
5541 Err(UpgradeError::StateChangeWithoutPriorLoad {
5542 from: "0.1.0".into(),
5543 script: PathBuf::from("lib/m.lisp"),
5544 }),
5545 "a `:state-change` with no preceding `:load-module` must fire \
5546 StateChangeWithoutPriorLoad carrying the offending script \
5547 verbatim through the declared_path() accessor"
5548 );
5549
5550 // (c) `((:load-module …) (:state-change …))` leaves the gate
5551 // vacuous; so does a non-StateChange-non-LoadModule
5552 // sequence (SoftPurge / Purge / Restart alone).
5553 let load_before_migrate = entry(
5554 "0.1.0",
5555 vec![
5556 UpgradeInstruction::LoadModule {
5557 module: "hello-rio".into(),
5558 },
5559 UpgradeInstruction::StateChange {
5560 script: PathBuf::from("lib/m.lisp"),
5561 },
5562 ],
5563 );
5564 assert_eq!(
5565 load_before_migrate.validate_state_change_ordering(),
5566 Ok(()),
5567 "load-before-migrate entries must leave the ordering gate \
5568 vacuous — the `loaded = true` sticky-latch on the first arm \
5569 satisfies the `!loaded` guard negation on the else-if arm"
5570 );
5571 for instr in [
5572 UpgradeInstruction::SoftPurge {
5573 module: "x-old".into(),
5574 },
5575 UpgradeInstruction::Purge {
5576 module: "x-old".into(),
5577 },
5578 UpgradeInstruction::Restart,
5579 ] {
5580 let e = entry("0.1.0", vec![instr.clone()]);
5581 assert_eq!(
5582 e.validate_state_change_ordering(),
5583 Ok(()),
5584 "non-StateChange-non-LoadModule sequence ({instr:?}) must \
5585 leave the ordering gate vacuous — declared_path() is None \
5586 on every non-StateChange arm, so the else-if migration-\
5587 family arm never fires"
5588 );
5589 }
5590 }
5591
5592 #[test]
5593 fn validate_state_change_ordering_fires_after_restart_exclusive() {
5594 // Diagnostic-precedence pin: a `((:state-change …) (:restart))`
5595 // shape is *both* state-change-without-load and restart-mixed.
5596 // The more-fundamental `RestartNotExclusive` must win (a valid
5597 // `(:restart)` entry is `(:restart)` alone, so no Restart-bearing
5598 // entry should reach the ordering gate). Guards the call order
5599 // in `validate` against silent reordering.
5600 let e = entry(
5601 "0.1.0",
5602 vec![
5603 UpgradeInstruction::StateChange {
5604 script: PathBuf::from("lib/m.lisp"),
5605 },
5606 UpgradeInstruction::Restart,
5607 ],
5608 );
5609 let err = e.validate().unwrap_err();
5610 assert!(
5611 matches!(err, UpgradeError::RestartNotExclusive { .. }),
5612 "restart-mixed must surface before the ordering gate, got {err:?}"
5613 );
5614 }
5615
5616 // ── within-entry purge-ordering invariant ──────────────────────────
5617
5618 #[test]
5619 fn validate_rejects_soft_purge_without_load() {
5620 // Fail-before-pass-after pin: `:soft-purge` drains the *old*
5621 // module after the new one is resident (OTP's two-phase code
5622 // load — code:load_module/1 then code:soft_purge/1), so an
5623 // entry that runs it with no preceding `:load-module` drains
5624 // the live module with no replacement. The operator runs
5625 // instructions in declared order, so this is a build error,
5626 // not a runtime surprise (CAIXA-SDLC §III).
5627 let e = entry(
5628 "0.1.0",
5629 vec![UpgradeInstruction::SoftPurge {
5630 module: "x-old".into(),
5631 }],
5632 );
5633 let err = e.validate().unwrap_err();
5634 assert_eq!(
5635 err,
5636 UpgradeError::PurgeWithoutPriorLoad {
5637 from: "0.1.0".into(),
5638 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5639 module: "x-old".into(),
5640 },
5641 "a `:soft-purge` with no preceding `:load-module` must surface as \
5642 PurgeWithoutPriorLoad naming the offending entry + kind + module verbatim"
5643 );
5644 }
5645
5646 #[test]
5647 fn validate_rejects_purge_without_load() {
5648 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5649 // the more catastrophic peer of `:soft-purge`; same gate, same
5650 // shape, kind-tag differs so the author can grep their
5651 // caixa.lisp for the offending `(:purge …)` form.
5652 let e = entry(
5653 "0.1.0",
5654 vec![UpgradeInstruction::Purge {
5655 module: "x-old".into(),
5656 }],
5657 );
5658 let err = e.validate().unwrap_err();
5659 assert_eq!(
5660 err,
5661 UpgradeError::PurgeWithoutPriorLoad {
5662 from: "0.1.0".into(),
5663 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5664 module: "x-old".into(),
5665 },
5666 );
5667 }
5668
5669 #[test]
5670 fn validate_rejects_soft_purge_before_load() {
5671 // Right-instructions-wrong-order: the load is present but runs
5672 // *after* the purge. Because the operator executes in declared
5673 // order, the cleanup drains the old code before the new code
5674 // is resident — same incoherence as the missing-load case,
5675 // leaving a window during which neither version is available.
5676 let e = entry(
5677 "0.1.0",
5678 vec![
5679 UpgradeInstruction::SoftPurge {
5680 module: "x-old".into(),
5681 },
5682 UpgradeInstruction::LoadModule { module: "x".into() },
5683 ],
5684 );
5685 let err = e.validate().unwrap_err();
5686 assert!(
5687 matches!(
5688 err,
5689 UpgradeError::PurgeWithoutPriorLoad {
5690 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5691 ..
5692 }
5693 ),
5694 "a `:soft-purge` ahead of its `:load-module` must surface as \
5695 PurgeWithoutPriorLoad, got {err:?}"
5696 );
5697 }
5698
5699 #[test]
5700 fn validate_rejects_purge_before_load() {
5701 // Symmetric arm on the `:purge` variant — the kind tag
5702 // distinguishes the diagnostic so the author lands on the
5703 // offending form directly.
5704 let e = entry(
5705 "0.1.0",
5706 vec![
5707 UpgradeInstruction::Purge {
5708 module: "x-old".into(),
5709 },
5710 UpgradeInstruction::LoadModule { module: "x".into() },
5711 ],
5712 );
5713 let err = e.validate().unwrap_err();
5714 assert!(
5715 matches!(
5716 err,
5717 UpgradeError::PurgeWithoutPriorLoad {
5718 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5719 ..
5720 }
5721 ),
5722 "a `:purge` ahead of its `:load-module` must surface as \
5723 PurgeWithoutPriorLoad, got {err:?}"
5724 );
5725 }
5726
5727 #[test]
5728 fn validate_accepts_soft_purge_after_load() {
5729 // Positive control: the canonical `(:load-module …)
5730 // (:soft-purge …)` order validates. The load need not name the
5731 // same module the purge targets — the cleanup typically targets
5732 // the *old* module name (e.g. `"x-old"`) and the load brings up
5733 // the *new* one (`"x"`); the gate only requires that *some*
5734 // `:load-module` precedes the purge, so the new code is resident
5735 // before the old one is drained.
5736 let e = entry(
5737 "0.1.0",
5738 vec![
5739 UpgradeInstruction::LoadModule { module: "x".into() },
5740 UpgradeInstruction::SoftPurge {
5741 module: "x-old".into(),
5742 },
5743 ],
5744 );
5745 e.validate().unwrap();
5746 }
5747
5748 #[test]
5749 fn validate_accepts_multiple_purges_after_one_load() {
5750 // A single leading `:load-module` covers every subsequent
5751 // `:soft-purge` / `:purge` — the `loaded` latch stays set once
5752 // the new code is resident. Same shape as
5753 // `validate_accepts_multiple_state_changes_after_one_load` on
5754 // the peer ordering gate.
5755 let e = entry(
5756 "0.1.0",
5757 vec![
5758 UpgradeInstruction::LoadModule { module: "x".into() },
5759 UpgradeInstruction::SoftPurge {
5760 module: "x-old".into(),
5761 },
5762 UpgradeInstruction::Purge {
5763 module: "x-oldest".into(),
5764 },
5765 ],
5766 );
5767 e.validate().unwrap();
5768 }
5769
5770 #[test]
5771 fn validate_purge_ordering_fires_after_state_change_ordering() {
5772 // Diagnostic-precedence pin: an entry like `((:state-change …)
5773 // (:soft-purge …))` is *both* state-change-without-load and
5774 // purge-without-load. The state-change gate must win — it's
5775 // the load-bearing semantic on this ordering contract, and
5776 // surfacing the purge diagnostic first would mask the more-
5777 // fundamental migration-against-stale-code defect. Guards the
5778 // call order in `validate` against silent reordering.
5779 let e = entry(
5780 "0.1.0",
5781 vec![
5782 UpgradeInstruction::StateChange {
5783 script: PathBuf::from("lib/m.lisp"),
5784 },
5785 UpgradeInstruction::SoftPurge {
5786 module: "x-old".into(),
5787 },
5788 ],
5789 );
5790 let err = e.validate().unwrap_err();
5791 assert!(
5792 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5793 "state-change-without-load must surface before purge-without-load, got {err:?}"
5794 );
5795 }
5796
5797 #[test]
5798 fn validate_purge_ordering_fires_after_per_instr_shape() {
5799 // Order pin: a malformed `:module` value on a `:soft-purge` (an
5800 // empty string) surfaces its narrower kind-tagged `ModuleEmpty`
5801 // diagnostic *before* the within-entry purge-ordering gate fires.
5802 // The per-instruction shape pass walks the list inline before
5803 // the ordering checks, so the narrower self-locating diagnostic
5804 // surfaces first — mirrors the empty-first cascade on every peer
5805 // DNS-1123 gate and the `validate_restart_exclusive_fires_after_
5806 // per_instr_shape` pin on the sibling ordering gate.
5807 let e = entry(
5808 "0.1.0",
5809 vec![UpgradeInstruction::SoftPurge {
5810 module: String::new(),
5811 }],
5812 );
5813 let err = e.validate().unwrap_err();
5814 assert_eq!(
5815 err,
5816 UpgradeError::ModuleEmpty {
5817 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
5818 },
5819 "malformed instruction must surface its kind-tagged diagnostic before the \
5820 purge-ordering gate fires, got {err:?}"
5821 );
5822 }
5823
5824 #[test]
5825 fn validate_purge_ordering_threads_through_validate_upgrade_from() {
5826 // The whole-list entry-point surfaces the per-entry ordering
5827 // error (mirrors
5828 // `validate_state_change_ordering_threads_through_validate_upgrade_from`):
5829 // the gate is reachable from the LayoutInvariants call site, not
5830 // only from a direct `entry.validate()`.
5831 let entries = vec![entry(
5832 "0.1.0",
5833 vec![UpgradeInstruction::Purge {
5834 module: "x-old".into(),
5835 }],
5836 )];
5837 let err = validate_upgrade_from(&entries).unwrap_err();
5838 assert!(
5839 matches!(
5840 err,
5841 UpgradeError::PurgeWithoutPriorLoad {
5842 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5843 ..
5844 }
5845 ),
5846 "validate_upgrade_from must thread the purge-ordering error, got {err:?}"
5847 );
5848 }
5849
5850 #[test]
5851 fn validate_state_change_ordering_threads_through_validate_upgrade_from() {
5852 // The whole-list entry-point surfaces the per-entry ordering
5853 // error (mirrors `validate_restart_exclusive_threads_through_…`):
5854 // the gate is reachable from the LayoutInvariants call site, not
5855 // only from a direct `entry.validate()`.
5856 let entries = vec![entry(
5857 "0.1.0",
5858 vec![UpgradeInstruction::StateChange {
5859 script: PathBuf::from("lib/m.lisp"),
5860 }],
5861 )];
5862 let err = validate_upgrade_from(&entries).unwrap_err();
5863 assert!(
5864 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
5865 "validate_upgrade_from must thread the ordering error, got {err:?}"
5866 );
5867 }
5868
5869 // ── within-entry cleanup-singularity invariant ─────────────────────
5870
5871 #[test]
5872 fn validate_rejects_duplicate_soft_purge_for_same_module() {
5873 // Fail-before-pass-after pin: `:soft-purge` drains-then-GCs
5874 // its target module (code:soft_purge/1 analog); after the
5875 // first the module is gone, so a second `:soft-purge` of the
5876 // same module is at best a no-op and at worst undefined
5877 // (depending on the operator's handling of a non-resident-
5878 // module purge). Author one cleanup per module.
5879 let e = entry(
5880 "0.1.0",
5881 vec![
5882 UpgradeInstruction::LoadModule { module: "x".into() },
5883 UpgradeInstruction::SoftPurge {
5884 module: "x-old".into(),
5885 },
5886 UpgradeInstruction::SoftPurge {
5887 module: "x-old".into(),
5888 },
5889 ],
5890 );
5891 let err = e.validate().unwrap_err();
5892 assert_eq!(
5893 err,
5894 UpgradeError::DuplicateCleanup {
5895 from: "0.1.0".into(),
5896 module: "x-old".into(),
5897 kinds: vec![
5898 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5899 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5900 ],
5901 },
5902 "two `:soft-purge` of the same module must surface as DuplicateCleanup naming the \
5903 module + both kinds in declaration order, got {err:?}"
5904 );
5905 }
5906
5907 #[test]
5908 fn validate_rejects_duplicate_purge_for_same_module() {
5909 // Per-arm coverage: `:purge` (immediate discard, no drain) is
5910 // the more catastrophic peer of `:soft-purge`; same gate, same
5911 // shape, kind-tag distinguishes so the author can grep their
5912 // caixa.lisp for the offending `(:purge …)` form.
5913 let e = entry(
5914 "0.1.0",
5915 vec![
5916 UpgradeInstruction::LoadModule { module: "x".into() },
5917 UpgradeInstruction::Purge {
5918 module: "x-old".into(),
5919 },
5920 UpgradeInstruction::Purge {
5921 module: "x-old".into(),
5922 },
5923 ],
5924 );
5925 let err = e.validate().unwrap_err();
5926 assert_eq!(
5927 err,
5928 UpgradeError::DuplicateCleanup {
5929 from: "0.1.0".into(),
5930 module: "x-old".into(),
5931 kinds: vec![
5932 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5933 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5934 ],
5935 },
5936 );
5937 }
5938
5939 #[test]
5940 fn validate_rejects_soft_purge_then_purge_for_same_module() {
5941 // Soft-then-hard footgun: the author wrote "drain, and if
5942 // drain doesn't clean up, force-discard", but the operator
5943 // runs declared instructions unconditionally — the `:purge`
5944 // fires whether the `:soft-purge` already discarded the
5945 // module or not, so the imagined fallback semantic is
5946 // missing. Fallback on cleanup failure is the operator's
5947 // job, not authored into the entry. Both kinds carry in
5948 // declaration order so the author can grep for either side
5949 // and pick one.
5950 let e = entry(
5951 "0.1.0",
5952 vec![
5953 UpgradeInstruction::LoadModule { module: "x".into() },
5954 UpgradeInstruction::SoftPurge {
5955 module: "x-old".into(),
5956 },
5957 UpgradeInstruction::Purge {
5958 module: "x-old".into(),
5959 },
5960 ],
5961 );
5962 let err = e.validate().unwrap_err();
5963 assert_eq!(
5964 err,
5965 UpgradeError::DuplicateCleanup {
5966 from: "0.1.0".into(),
5967 module: "x-old".into(),
5968 kinds: vec![
5969 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
5970 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
5971 ],
5972 },
5973 );
5974 }
5975
5976 #[test]
5977 fn validate_rejects_purge_then_soft_purge_for_same_module() {
5978 // Reversed-ordering arm: `:purge` discards immediately; the
5979 // trailing `:soft-purge` has no module to drain. The kinds
5980 // list reflects declaration order so the diagnostic locates
5981 // both forms in the source.
5982 let e = entry(
5983 "0.1.0",
5984 vec![
5985 UpgradeInstruction::LoadModule { module: "x".into() },
5986 UpgradeInstruction::Purge {
5987 module: "x-old".into(),
5988 },
5989 UpgradeInstruction::SoftPurge {
5990 module: "x-old".into(),
5991 },
5992 ],
5993 );
5994 let err = e.validate().unwrap_err();
5995 assert_eq!(
5996 err,
5997 UpgradeError::DuplicateCleanup {
5998 from: "0.1.0".into(),
5999 module: "x-old".into(),
6000 kinds: vec![
6001 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
6002 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6003 ],
6004 },
6005 );
6006 }
6007
6008 #[test]
6009 fn validate_accepts_distinct_cleanup_modules() {
6010 // Positive control: `:soft-purge` and `:purge` on *different*
6011 // modules pass the gate. Mirrors
6012 // `validate_accepts_multiple_purges_after_one_load` — the
6013 // cleanup-singularity gate is keyed on (module), not on
6014 // (kind, module) pair, so distinct old-version names render
6015 // distinct cleanup targets and don't collide. Sweep both
6016 // same-class (two `:soft-purge` distinct modules) and cross-
6017 // class (`:soft-purge` then `:purge` distinct modules) so a
6018 // future tighten to a kind-only key (which would over-fire on
6019 // distinct modules) surfaces here.
6020 let two_soft = entry(
6021 "0.1.0",
6022 vec![
6023 UpgradeInstruction::LoadModule { module: "x".into() },
6024 UpgradeInstruction::SoftPurge {
6025 module: "x-old".into(),
6026 },
6027 UpgradeInstruction::SoftPurge {
6028 module: "x-older".into(),
6029 },
6030 ],
6031 );
6032 two_soft.validate().unwrap();
6033 let mixed = entry(
6034 "0.1.0",
6035 vec![
6036 UpgradeInstruction::LoadModule { module: "x".into() },
6037 UpgradeInstruction::SoftPurge {
6038 module: "x-old".into(),
6039 },
6040 UpgradeInstruction::Purge {
6041 module: "x-oldest".into(),
6042 },
6043 ],
6044 );
6045 mixed.validate().unwrap();
6046 }
6047
6048 #[test]
6049 fn validate_accepts_single_cleanup_per_module() {
6050 // Boundary control: a list with exactly one `:soft-purge` and
6051 // one `:purge` (distinct modules, the canonical "drain one,
6052 // hard-discard the other" shape) is the gate's identity
6053 // element. Pin so a future off-by-one in the duplicate-detection
6054 // scan doesn't accidentally flag a single occurrence as
6055 // duplicating itself — mirrors
6056 // `validate_upgrade_from_single_entry_never_duplicates` on
6057 // the peer cross-entry duplicate axis.
6058 let e = entry(
6059 "0.1.0",
6060 vec![
6061 UpgradeInstruction::LoadModule { module: "x".into() },
6062 UpgradeInstruction::SoftPurge {
6063 module: "x-old".into(),
6064 },
6065 UpgradeInstruction::Purge {
6066 module: "y-old".into(),
6067 },
6068 ],
6069 );
6070 e.validate().unwrap();
6071 }
6072
6073 #[test]
6074 fn validate_cleanup_singularity_fires_after_purge_ordering() {
6075 // Diagnostic-precedence pin: an entry like `((:soft-purge "x")
6076 // (:soft-purge "x"))` is *both* purge-without-load and
6077 // duplicate-cleanup. The more-fundamental ordering gate must
6078 // win — the missing-load defect is load-bearing (the canonical
6079 // OTP shape requires the new code be resident before any
6080 // cleanup runs), and surfacing the duplicate diagnostic first
6081 // would mask the no-replacement-window defect the ordering
6082 // gate exists to close. Guards the call order in `validate`
6083 // against silent reordering. Same posture as
6084 // `validate_purge_ordering_fires_after_state_change_ordering`
6085 // on the sibling ordering gate.
6086 let e = entry(
6087 "0.1.0",
6088 vec![
6089 UpgradeInstruction::SoftPurge {
6090 module: "x-old".into(),
6091 },
6092 UpgradeInstruction::SoftPurge {
6093 module: "x-old".into(),
6094 },
6095 ],
6096 );
6097 let err = e.validate().unwrap_err();
6098 assert!(
6099 matches!(
6100 err,
6101 UpgradeError::PurgeWithoutPriorLoad {
6102 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6103 ..
6104 }
6105 ),
6106 "purge-without-load must surface before duplicate-cleanup, got {err:?}"
6107 );
6108 }
6109
6110 #[test]
6111 fn validate_cleanup_singularity_fires_after_per_instr_shape() {
6112 // Order pin: a malformed `:module` value on a `:soft-purge`
6113 // (an empty string) surfaces its narrower kind-tagged
6114 // `ModuleEmpty` diagnostic *before* the within-entry cleanup-
6115 // singularity gate fires. The per-instruction shape pass walks
6116 // the list inline before the singularity check, so the
6117 // narrower self-locating diagnostic surfaces first — mirrors
6118 // the empty-first cascade on every peer DNS-1123 gate and the
6119 // `validate_purge_ordering_fires_after_per_instr_shape` pin on
6120 // the sibling ordering gate.
6121 //
6122 // Two empty-string `:soft-purge` would *otherwise* duplicate
6123 // (both modules are the same empty string), so this pin
6124 // double-locks the precedence: the per-instr shape gate must
6125 // win on the first malformed instruction before the duplicate
6126 // scan even reaches the second.
6127 let e = entry(
6128 "0.1.0",
6129 vec![
6130 UpgradeInstruction::LoadModule { module: "x".into() },
6131 UpgradeInstruction::SoftPurge {
6132 module: String::new(),
6133 },
6134 UpgradeInstruction::SoftPurge {
6135 module: String::new(),
6136 },
6137 ],
6138 );
6139 let err = e.validate().unwrap_err();
6140 assert_eq!(
6141 err,
6142 UpgradeError::ModuleEmpty {
6143 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE
6144 },
6145 "malformed instruction must surface its kind-tagged diagnostic before the \
6146 cleanup-singularity gate fires, got {err:?}"
6147 );
6148 }
6149
6150 #[test]
6151 fn validate_cleanup_singularity_reports_first_collision() {
6152 // Determinism pin: with three cleanups of the same module the
6153 // gate reports the *first* collision (the second occurrence)
6154 // and stops — the third's duplicate is masked by the first
6155 // surfaced one. Mirrors
6156 // `validate_upgrade_from_duplicate_diagnostic_names_second_collision`
6157 // on the peer cross-entry duplicate axis.
6158 let e = entry(
6159 "0.1.0",
6160 vec![
6161 UpgradeInstruction::LoadModule { module: "x".into() },
6162 UpgradeInstruction::SoftPurge {
6163 module: "x-old".into(),
6164 },
6165 UpgradeInstruction::SoftPurge {
6166 module: "x-old".into(),
6167 },
6168 UpgradeInstruction::Purge {
6169 module: "x-old".into(),
6170 },
6171 ],
6172 );
6173 let err = e.validate().unwrap_err();
6174 assert_eq!(
6175 err,
6176 UpgradeError::DuplicateCleanup {
6177 from: "0.1.0".into(),
6178 module: "x-old".into(),
6179 kinds: vec![
6180 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6181 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6182 ],
6183 },
6184 "the first colliding pair must surface, not the later `:purge` collision"
6185 );
6186 }
6187
6188 #[test]
6189 fn validate_cleanup_singularity_threads_through_validate_upgrade_from() {
6190 // The whole-list entry-point surfaces the per-entry singularity
6191 // error (mirrors
6192 // `validate_purge_ordering_threads_through_validate_upgrade_from`):
6193 // the gate is reachable from the LayoutInvariants call site,
6194 // not only from a direct `entry.validate()`.
6195 let entries = vec![entry(
6196 "0.1.0",
6197 vec![
6198 UpgradeInstruction::LoadModule { module: "x".into() },
6199 UpgradeInstruction::SoftPurge {
6200 module: "x-old".into(),
6201 },
6202 UpgradeInstruction::Purge {
6203 module: "x-old".into(),
6204 },
6205 ],
6206 )];
6207 let err = validate_upgrade_from(&entries).unwrap_err();
6208 assert!(
6209 matches!(err, UpgradeError::DuplicateCleanup { .. }),
6210 "validate_upgrade_from must thread the cleanup-singularity error, got {err:?}"
6211 );
6212 }
6213
6214 #[test]
6215 fn validate_rejects_duplicate_load_module_for_same_module() {
6216 // `LoadModule` is the `code:load_module/1` analog (INSPIRATIONS
6217 // §II.4): each module is loaded exactly once per upgrade entry,
6218 // the operator's dispatch table reads the module name to bind
6219 // the wasm component, and a second `(:load-module "x")` re-reads
6220 // the same module name and re-binds the same component — a
6221 // no-op the second time. systools-generated `.relup` files emit
6222 // at most one `load_module` per module per upgrade step for
6223 // this reason. Author one `(:load-module "x")` per old module.
6224 let e = entry(
6225 "0.1.0",
6226 vec![
6227 UpgradeInstruction::LoadModule { module: "x".into() },
6228 UpgradeInstruction::LoadModule { module: "x".into() },
6229 ],
6230 );
6231 let err = e.validate().unwrap_err();
6232 assert_eq!(
6233 err,
6234 UpgradeError::DuplicateLoadModule {
6235 from: "0.1.0".into(),
6236 module: "x".into(),
6237 },
6238 "two `:load-module` of the same module must surface as DuplicateLoadModule naming \
6239 the module, got {err:?}"
6240 );
6241 }
6242
6243 #[test]
6244 fn validate_accepts_distinct_load_modules() {
6245 // Positive control: `:load-module` instructions on *different*
6246 // modules pass the gate. Mirrors
6247 // `validate_accepts_distinct_cleanup_modules` on the sibling
6248 // singularity axis — the load-singularity gate is keyed on
6249 // (module), so distinct module names render distinct load
6250 // targets and don't collide. Sweep both the bare two-load shape
6251 // and the canonical load-pair-with-cleanup shape so a future
6252 // tighten that over-fires on distinct loads surfaces here.
6253 let two_loads = entry(
6254 "0.1.0",
6255 vec![
6256 UpgradeInstruction::LoadModule { module: "x".into() },
6257 UpgradeInstruction::LoadModule { module: "y".into() },
6258 ],
6259 );
6260 two_loads.validate().unwrap();
6261 let with_cleanup = entry(
6262 "0.1.0",
6263 vec![
6264 UpgradeInstruction::LoadModule { module: "x".into() },
6265 UpgradeInstruction::LoadModule { module: "y".into() },
6266 UpgradeInstruction::SoftPurge {
6267 module: "x-old".into(),
6268 },
6269 UpgradeInstruction::SoftPurge {
6270 module: "y-old".into(),
6271 },
6272 ],
6273 );
6274 with_cleanup.validate().unwrap();
6275 }
6276
6277 #[test]
6278 fn validate_accepts_single_load_per_module() {
6279 // Boundary control: a list with exactly one `:load-module`
6280 // followed by the canonical `:state-change` + `:soft-purge`
6281 // sequence (the module-doc OTP shape) is the gate's identity
6282 // element. Pin so a future off-by-one in the duplicate-
6283 // detection scan doesn't accidentally flag a single occurrence
6284 // as duplicating itself — mirrors
6285 // `validate_accepts_single_cleanup_per_module` on the sibling
6286 // singularity axis.
6287 let e = entry(
6288 "0.1.0",
6289 vec![
6290 UpgradeInstruction::LoadModule { module: "x".into() },
6291 UpgradeInstruction::StateChange {
6292 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6293 },
6294 UpgradeInstruction::SoftPurge {
6295 module: "x-old".into(),
6296 },
6297 ],
6298 );
6299 e.validate().unwrap();
6300 }
6301
6302 #[test]
6303 fn validate_load_singularity_fires_after_state_change_ordering() {
6304 // Diagnostic-precedence pin: an entry like `((:state-change
6305 // "m.lisp") (:load-module "x") (:load-module "x"))` is *both*
6306 // state-change-without-load and duplicate-load. The more-
6307 // fundamental ordering gate must win — the missing-load defect
6308 // is load-bearing (the migration runs against unloaded code),
6309 // and surfacing the duplicate diagnostic first would mask the
6310 // migrate-into-unloaded-code defect the ordering gate exists
6311 // to close. Guards the call order in `validate` against silent
6312 // reordering. Same posture as
6313 // `validate_cleanup_singularity_fires_after_purge_ordering`
6314 // on the sibling singularity gate.
6315 let e = entry(
6316 "0.1.0",
6317 vec![
6318 UpgradeInstruction::StateChange {
6319 script: PathBuf::from("lib/m.lisp"),
6320 },
6321 UpgradeInstruction::LoadModule { module: "x".into() },
6322 UpgradeInstruction::LoadModule { module: "x".into() },
6323 ],
6324 );
6325 let err = e.validate().unwrap_err();
6326 assert!(
6327 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6328 "state-change-without-load must surface before duplicate-load, got {err:?}"
6329 );
6330 }
6331
6332 #[test]
6333 fn validate_load_singularity_fires_after_purge_ordering() {
6334 // Diagnostic-precedence pin: an entry like `((:soft-purge
6335 // "x-old") (:load-module "x") (:load-module "x"))` is *both*
6336 // purge-without-load and duplicate-load. The more-fundamental
6337 // ordering gate must win — the missing-load defect is load-
6338 // bearing (the cleanup runs against no-replacement-window),
6339 // and surfacing the duplicate diagnostic first would mask the
6340 // drain-to-nothing defect the ordering gate exists to close.
6341 // Sibling of
6342 // `validate_cleanup_singularity_fires_after_purge_ordering` on
6343 // the load-singularity axis.
6344 let e = entry(
6345 "0.1.0",
6346 vec![
6347 UpgradeInstruction::SoftPurge {
6348 module: "x-old".into(),
6349 },
6350 UpgradeInstruction::LoadModule { module: "x".into() },
6351 UpgradeInstruction::LoadModule { module: "x".into() },
6352 ],
6353 );
6354 let err = e.validate().unwrap_err();
6355 assert!(
6356 matches!(
6357 err,
6358 UpgradeError::PurgeWithoutPriorLoad {
6359 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6360 ..
6361 }
6362 ),
6363 "purge-without-load must surface before duplicate-load, got {err:?}"
6364 );
6365 }
6366
6367 #[test]
6368 fn validate_load_singularity_fires_after_per_instr_shape() {
6369 // Order pin: a malformed `:module` value on a `:load-module`
6370 // (an empty string) surfaces its narrower kind-tagged
6371 // `ModuleEmpty` diagnostic *before* the within-entry load-
6372 // singularity gate fires. The per-instruction shape pass walks
6373 // the list inline before the singularity check, so the
6374 // narrower self-locating diagnostic surfaces first — mirrors
6375 // the empty-first cascade on every peer DNS-1123 gate and the
6376 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6377 // pin on the sibling singularity gate.
6378 //
6379 // Two empty-string `:load-module` would *otherwise* duplicate
6380 // (both modules are the same empty string), so this pin
6381 // double-locks the precedence: the per-instr shape gate must
6382 // win on the first malformed instruction before the duplicate
6383 // scan even reaches the second.
6384 let e = entry(
6385 "0.1.0",
6386 vec![
6387 UpgradeInstruction::LoadModule {
6388 module: String::new(),
6389 },
6390 UpgradeInstruction::LoadModule {
6391 module: String::new(),
6392 },
6393 ],
6394 );
6395 let err = e.validate().unwrap_err();
6396 assert_eq!(
6397 err,
6398 UpgradeError::ModuleEmpty {
6399 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
6400 },
6401 "malformed instruction must surface its kind-tagged diagnostic before the \
6402 load-singularity gate fires, got {err:?}"
6403 );
6404 }
6405
6406 #[test]
6407 fn validate_load_singularity_fires_before_cleanup_singularity() {
6408 // Diagnostic-precedence pin: an entry that violates *both*
6409 // singularities — duplicate load on "x" *and* duplicate cleanup
6410 // on "y-old" — must surface the load-side diagnostic first.
6411 // The load axis precedes the cleanup axis in the canonical OTP
6412 // sequence (`code:load_module/1` then `code:soft_purge/1`) and
6413 // in [`UpgradeInstruction`] declaration order (LoadModule
6414 // before SoftPurge/Purge), so the load-side singularity is the
6415 // load-bearing diagnostic when both fire — the cleanup-side
6416 // duplicate is meaningless either way without a coherent load.
6417 // Guards the call order in `validate`: `validate_load_singularity`
6418 // runs before `validate_cleanup_singularity`.
6419 let e = entry(
6420 "0.1.0",
6421 vec![
6422 UpgradeInstruction::LoadModule { module: "x".into() },
6423 UpgradeInstruction::LoadModule { module: "x".into() },
6424 UpgradeInstruction::SoftPurge {
6425 module: "y-old".into(),
6426 },
6427 UpgradeInstruction::SoftPurge {
6428 module: "y-old".into(),
6429 },
6430 ],
6431 );
6432 let err = e.validate().unwrap_err();
6433 assert_eq!(
6434 err,
6435 UpgradeError::DuplicateLoadModule {
6436 from: "0.1.0".into(),
6437 module: "x".into(),
6438 },
6439 "duplicate-load must surface before duplicate-cleanup, got {err:?}"
6440 );
6441 }
6442
6443 #[test]
6444 fn validate_load_singularity_reports_first_collision() {
6445 // Determinism pin: with three loads of the same module the gate
6446 // reports the *first* collision (the second occurrence) and
6447 // stops — the third's duplicate is masked by the first surfaced
6448 // one. Mirrors
6449 // `validate_cleanup_singularity_reports_first_collision` on the
6450 // sibling singularity axis and every peer duplicate gate's
6451 // first-collision discipline.
6452 let e = entry(
6453 "0.1.0",
6454 vec![
6455 UpgradeInstruction::LoadModule { module: "x".into() },
6456 UpgradeInstruction::LoadModule { module: "x".into() },
6457 UpgradeInstruction::LoadModule { module: "x".into() },
6458 ],
6459 );
6460 let err = e.validate().unwrap_err();
6461 assert_eq!(
6462 err,
6463 UpgradeError::DuplicateLoadModule {
6464 from: "0.1.0".into(),
6465 module: "x".into(),
6466 },
6467 "the first colliding occurrence must surface, not the later third-load collision"
6468 );
6469 }
6470
6471 #[test]
6472 fn validate_load_singularity_threads_through_validate_upgrade_from() {
6473 // The whole-list entry-point surfaces the per-entry singularity
6474 // error (mirrors
6475 // `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6476 // the gate is reachable from the LayoutInvariants call site,
6477 // not only from a direct `entry.validate()`.
6478 let entries = vec![entry(
6479 "0.1.0",
6480 vec![
6481 UpgradeInstruction::LoadModule { module: "x".into() },
6482 UpgradeInstruction::LoadModule { module: "x".into() },
6483 ],
6484 )];
6485 let err = validate_upgrade_from(&entries).unwrap_err();
6486 assert!(
6487 matches!(err, UpgradeError::DuplicateLoadModule { .. }),
6488 "validate_upgrade_from must thread the load-singularity error, got {err:?}"
6489 );
6490 }
6491
6492 // ── within-entry state-change-singularity invariant ────────────────
6493
6494 #[test]
6495 fn validate_rejects_duplicate_state_change_for_same_script() {
6496 // `StateChange` is the `gen_server:code_change/3` analog
6497 // (INSPIRATIONS §II.4): the script folds the prior-version
6498 // state shape into the current-version shape — a one-shot
6499 // transition, not a step that composes with itself. OTP's
6500 // release_handler invokes `code_change/3` exactly once per
6501 // upgrade per gen_server; systools-generated `.relup` files
6502 // emit at most one `code_change` per gen_server per upgrade
6503 // step for this reason. A second `(:state-change "m.lisp")`
6504 // re-runs the same fold on the already-migrated state — at
6505 // best a no-op and at worst silent state corruption from
6506 // double-applied non-idempotent transforms (`add column`,
6507 // `increment counter`, `rename field`). Author one
6508 // `(:state-change "m.lisp")` per migration script per entry.
6509 let e = entry(
6510 "0.1.0",
6511 vec![
6512 UpgradeInstruction::LoadModule { module: "x".into() },
6513 UpgradeInstruction::StateChange {
6514 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6515 },
6516 UpgradeInstruction::StateChange {
6517 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6518 },
6519 ],
6520 );
6521 let err = e.validate().unwrap_err();
6522 assert_eq!(
6523 err,
6524 UpgradeError::DuplicateStateChange {
6525 from: "0.1.0".into(),
6526 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6527 },
6528 "two `:state-change` of the same script must surface as DuplicateStateChange naming \
6529 the script, got {err:?}"
6530 );
6531 }
6532
6533 #[test]
6534 fn validate_accepts_distinct_state_change_scripts() {
6535 // Positive control: `:state-change` instructions on *different*
6536 // scripts pass the gate. Mirrors
6537 // `validate_accepts_distinct_cleanup_modules` /
6538 // `validate_accepts_distinct_load_modules` on the sibling
6539 // singularity axes — the state-change-singularity gate is keyed
6540 // on the script PathBuf, so distinct scripts render distinct
6541 // migration targets and don't collide. Sweep both the bare two-
6542 // migration shape and the canonical load-pair-with-cleanup shape
6543 // so a future tighten that over-fires on distinct scripts
6544 // surfaces here. This positive control is the gate-level peer of
6545 // `validate_accepts_multiple_state_changes_after_one_load` (the
6546 // ordering-gate positive control on distinct scripts), pinned
6547 // here independently so a future refactor that decouples the
6548 // gates can't accidentally drop coverage on either.
6549 let two_migrations = entry(
6550 "0.1.0",
6551 vec![
6552 UpgradeInstruction::LoadModule { module: "x".into() },
6553 UpgradeInstruction::StateChange {
6554 script: PathBuf::from("lib/m1.lisp"),
6555 },
6556 UpgradeInstruction::StateChange {
6557 script: PathBuf::from("lib/m2.lisp"),
6558 },
6559 ],
6560 );
6561 two_migrations.validate().unwrap();
6562 let with_cleanup = entry(
6563 "0.1.0",
6564 vec![
6565 UpgradeInstruction::LoadModule { module: "x".into() },
6566 UpgradeInstruction::StateChange {
6567 script: PathBuf::from("lib/m1.lisp"),
6568 },
6569 UpgradeInstruction::StateChange {
6570 script: PathBuf::from("lib/m2.lisp"),
6571 },
6572 UpgradeInstruction::SoftPurge {
6573 module: "x-old".into(),
6574 },
6575 ],
6576 );
6577 with_cleanup.validate().unwrap();
6578 }
6579
6580 #[test]
6581 fn validate_accepts_single_state_change_per_script() {
6582 // Boundary control: a list with exactly one `:state-change`
6583 // wrapped by the canonical `:load-module` + `:soft-purge`
6584 // sequence (the module-doc OTP shape) is the gate's identity
6585 // element. Pin so a future off-by-one in the duplicate-
6586 // detection scan doesn't accidentally flag a single occurrence
6587 // as duplicating itself — mirrors
6588 // `validate_accepts_single_load_per_module` /
6589 // `validate_accepts_single_cleanup_per_module` on the sibling
6590 // singularity axes.
6591 let e = entry(
6592 "0.1.0",
6593 vec![
6594 UpgradeInstruction::LoadModule { module: "x".into() },
6595 UpgradeInstruction::StateChange {
6596 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
6597 },
6598 UpgradeInstruction::SoftPurge {
6599 module: "x-old".into(),
6600 },
6601 ],
6602 );
6603 e.validate().unwrap();
6604 }
6605
6606 #[test]
6607 fn validate_state_change_singularity_fires_after_state_change_ordering() {
6608 // Diagnostic-precedence pin: an entry like `((:state-change
6609 // "m.lisp") (:state-change "m.lisp"))` is *both* state-change-
6610 // without-load and duplicate-state-change. The more-fundamental
6611 // ordering gate must win — the missing-load defect is load-
6612 // bearing (the migration runs against unloaded code), and
6613 // surfacing the duplicate diagnostic first would mask the
6614 // migrate-into-unloaded-code defect the ordering gate exists to
6615 // close. Guards the call order in `validate` against silent
6616 // reordering. Same posture as
6617 // `validate_load_singularity_fires_after_state_change_ordering`
6618 // on the sibling singularity gate.
6619 //
6620 // Two same-script `:state-change` would *otherwise* duplicate
6621 // (both scripts collide on the very first `:state-change`-
6622 // without-load encountered), so this pin double-locks the
6623 // precedence: the ordering gate must win on the first un-loaded
6624 // `:state-change` before the singularity scan even reaches the
6625 // second.
6626 let e = entry(
6627 "0.1.0",
6628 vec![
6629 UpgradeInstruction::StateChange {
6630 script: PathBuf::from("lib/m.lisp"),
6631 },
6632 UpgradeInstruction::StateChange {
6633 script: PathBuf::from("lib/m.lisp"),
6634 },
6635 ],
6636 );
6637 let err = e.validate().unwrap_err();
6638 assert!(
6639 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
6640 "state-change-without-load must surface before duplicate-state-change, got {err:?}"
6641 );
6642 }
6643
6644 #[test]
6645 fn validate_state_change_singularity_fires_after_purge_ordering() {
6646 // Diagnostic-precedence pin: an entry like `((:soft-purge
6647 // "x-old") (:load-module "x") (:state-change "m.lisp")
6648 // (:state-change "m.lisp"))` is *both* purge-without-load and
6649 // duplicate-state-change. The more-fundamental ordering gate
6650 // must win — the missing-load defect (a cleanup that drains the
6651 // only resident version to nothing) is load-bearing, and
6652 // surfacing the duplicate diagnostic first would mask the
6653 // drain-to-nothing defect the ordering gate exists to close.
6654 // Sibling of `validate_load_singularity_fires_after_purge_ordering`
6655 // on the state-change-singularity axis.
6656 let e = entry(
6657 "0.1.0",
6658 vec![
6659 UpgradeInstruction::SoftPurge {
6660 module: "x-old".into(),
6661 },
6662 UpgradeInstruction::LoadModule { module: "x".into() },
6663 UpgradeInstruction::StateChange {
6664 script: PathBuf::from("lib/m.lisp"),
6665 },
6666 UpgradeInstruction::StateChange {
6667 script: PathBuf::from("lib/m.lisp"),
6668 },
6669 ],
6670 );
6671 let err = e.validate().unwrap_err();
6672 assert!(
6673 matches!(
6674 err,
6675 UpgradeError::PurgeWithoutPriorLoad {
6676 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
6677 ..
6678 }
6679 ),
6680 "purge-without-load must surface before duplicate-state-change, got {err:?}"
6681 );
6682 }
6683
6684 #[test]
6685 fn validate_state_change_singularity_fires_after_per_instr_shape() {
6686 // Order pin: a malformed `:script` value on a `:state-change`
6687 // (an empty path) surfaces its narrower `EmptyScript` diagnostic
6688 // *before* the within-entry state-change-singularity gate fires.
6689 // The per-instruction shape pass walks the list inline before
6690 // the singularity check, so the narrower self-locating
6691 // diagnostic surfaces first — mirrors the empty-first cascade on
6692 // every peer path-shape gate and the
6693 // `validate_load_singularity_fires_after_per_instr_shape` /
6694 // `validate_cleanup_singularity_fires_after_per_instr_shape`
6695 // pins on the sibling singularity gates.
6696 //
6697 // Two empty-path `:state-change` would *otherwise* duplicate
6698 // (both scripts are the same empty PathBuf), so this pin double-
6699 // locks the precedence: the per-instr shape gate must win on the
6700 // first malformed instruction before the duplicate scan even
6701 // reaches the second.
6702 let e = entry(
6703 "0.1.0",
6704 vec![
6705 UpgradeInstruction::LoadModule { module: "x".into() },
6706 UpgradeInstruction::StateChange {
6707 script: PathBuf::new(),
6708 },
6709 UpgradeInstruction::StateChange {
6710 script: PathBuf::new(),
6711 },
6712 ],
6713 );
6714 let err = e.validate().unwrap_err();
6715 assert_eq!(
6716 err,
6717 UpgradeError::EmptyScript,
6718 "malformed instruction must surface its narrower diagnostic before the \
6719 state-change-singularity gate fires, got {err:?}"
6720 );
6721 }
6722
6723 #[test]
6724 fn validate_state_change_singularity_fires_after_load_singularity() {
6725 // Diagnostic-precedence pin: an entry that violates *both*
6726 // singularities — duplicate load on "x" *and* duplicate
6727 // state-change on "m.lisp" — must surface the load-side
6728 // diagnostic first. The load axis precedes the migration axis
6729 // in the canonical OTP sequence (`code:load_module/1` then
6730 // `gen_server:code_change/3`) and in [`UpgradeInstruction`]
6731 // declaration order (LoadModule before StateChange), so the
6732 // load-side singularity is the load-bearing diagnostic when
6733 // both fire — the migration-side duplicate is meaningless
6734 // either way without a coherent load. Guards the call order in
6735 // `validate`: `validate_load_singularity` runs before
6736 // `validate_state_change_singularity`.
6737 let e = entry(
6738 "0.1.0",
6739 vec![
6740 UpgradeInstruction::LoadModule { module: "x".into() },
6741 UpgradeInstruction::LoadModule { module: "x".into() },
6742 UpgradeInstruction::StateChange {
6743 script: PathBuf::from("lib/m.lisp"),
6744 },
6745 UpgradeInstruction::StateChange {
6746 script: PathBuf::from("lib/m.lisp"),
6747 },
6748 ],
6749 );
6750 let err = e.validate().unwrap_err();
6751 assert_eq!(
6752 err,
6753 UpgradeError::DuplicateLoadModule {
6754 from: "0.1.0".into(),
6755 module: "x".into(),
6756 },
6757 "duplicate-load must surface before duplicate-state-change, got {err:?}"
6758 );
6759 }
6760
6761 #[test]
6762 fn validate_state_change_singularity_fires_before_cleanup_singularity() {
6763 // Diagnostic-precedence pin: an entry that violates *both*
6764 // singularities — duplicate state-change on "m.lisp" *and*
6765 // duplicate cleanup on "y-old" — must surface the migration-
6766 // side diagnostic first. The migration axis precedes the
6767 // cleanup axis in the canonical OTP sequence
6768 // (`gen_server:code_change/3` then `code:soft_purge/1`) and in
6769 // [`UpgradeInstruction`] declaration order (StateChange before
6770 // SoftPurge/Purge), so the migration-side singularity is the
6771 // load-bearing diagnostic when both fire — the cleanup-side
6772 // duplicate is irrelevant once the migration has corrupted
6773 // state by double-applying. Guards the call order in
6774 // `validate`: `validate_state_change_singularity` runs before
6775 // `validate_cleanup_singularity`.
6776 let e = entry(
6777 "0.1.0",
6778 vec![
6779 UpgradeInstruction::LoadModule { module: "x".into() },
6780 UpgradeInstruction::StateChange {
6781 script: PathBuf::from("lib/m.lisp"),
6782 },
6783 UpgradeInstruction::StateChange {
6784 script: PathBuf::from("lib/m.lisp"),
6785 },
6786 UpgradeInstruction::SoftPurge {
6787 module: "y-old".into(),
6788 },
6789 UpgradeInstruction::SoftPurge {
6790 module: "y-old".into(),
6791 },
6792 ],
6793 );
6794 let err = e.validate().unwrap_err();
6795 assert_eq!(
6796 err,
6797 UpgradeError::DuplicateStateChange {
6798 from: "0.1.0".into(),
6799 script: PathBuf::from("lib/m.lisp"),
6800 },
6801 "duplicate-state-change must surface before duplicate-cleanup, got {err:?}"
6802 );
6803 }
6804
6805 #[test]
6806 fn validate_state_change_singularity_reports_first_collision() {
6807 // Determinism pin: with three state-changes on the same script
6808 // the gate reports the *first* collision (the second
6809 // occurrence) and stops — the third's duplicate is masked by
6810 // the first surfaced one. Mirrors
6811 // `validate_load_singularity_reports_first_collision` /
6812 // `validate_cleanup_singularity_reports_first_collision` on the
6813 // sibling singularity axes and every peer duplicate gate's
6814 // first-collision discipline.
6815 let e = entry(
6816 "0.1.0",
6817 vec![
6818 UpgradeInstruction::LoadModule { module: "x".into() },
6819 UpgradeInstruction::StateChange {
6820 script: PathBuf::from("lib/m.lisp"),
6821 },
6822 UpgradeInstruction::StateChange {
6823 script: PathBuf::from("lib/m.lisp"),
6824 },
6825 UpgradeInstruction::StateChange {
6826 script: PathBuf::from("lib/m.lisp"),
6827 },
6828 ],
6829 );
6830 let err = e.validate().unwrap_err();
6831 assert_eq!(
6832 err,
6833 UpgradeError::DuplicateStateChange {
6834 from: "0.1.0".into(),
6835 script: PathBuf::from("lib/m.lisp"),
6836 },
6837 "the first colliding occurrence must surface, not the later third-migration collision"
6838 );
6839 }
6840
6841 #[test]
6842 fn validate_state_change_singularity_threads_through_validate_upgrade_from() {
6843 // The whole-list entry-point surfaces the per-entry singularity
6844 // error (mirrors
6845 // `validate_load_singularity_threads_through_validate_upgrade_from`
6846 // / `validate_cleanup_singularity_threads_through_validate_upgrade_from`):
6847 // the gate is reachable from the LayoutInvariants call site,
6848 // not only from a direct `entry.validate()`.
6849 let entries = vec![entry(
6850 "0.1.0",
6851 vec![
6852 UpgradeInstruction::LoadModule { module: "x".into() },
6853 UpgradeInstruction::StateChange {
6854 script: PathBuf::from("lib/m.lisp"),
6855 },
6856 UpgradeInstruction::StateChange {
6857 script: PathBuf::from("lib/m.lisp"),
6858 },
6859 ],
6860 )];
6861 let err = validate_upgrade_from(&entries).unwrap_err();
6862 assert!(
6863 matches!(err, UpgradeError::DuplicateStateChange { .. }),
6864 "validate_upgrade_from must thread the state-change-singularity error, got {err:?}"
6865 );
6866 }
6867
6868 #[test]
6869 fn validate_state_change_singularity_projects_scripts_through_declared_path_accessor() {
6870 // Composition pin: [`UpgradeFromEntry::validate_state_change_singularity`]'s
6871 // per-instruction `StateChange`-arm script-path projection must
6872 // route through the sibling lifted
6873 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
6874 // accessor, not the raw
6875 // `match instr { UpgradeInstruction::StateChange { script } =>
6876 // script.as_path(), _ => continue }` open-coded pattern-match
6877 // the gate previously carried.
6878 //
6879 // Structurally: the gate's projection accept-set is the union
6880 // of every [`UpgradeInstruction`] variant for which
6881 // `declared_path().is_some()` — today exactly
6882 // [`UpgradeInstruction::StateChange`] per the sibling
6883 // `declared_path_only_for_state_change` pin, so a
6884 // duplicate-scripts input trips `DuplicateStateChange` and a
6885 // non-`StateChange` input (module-bearing / terminal) leaves
6886 // `seen` empty and the gate returns `Ok(())` byte-identical to
6887 // the pattern-match shape.
6888 //
6889 // Byte-equal today (`declared_path` returns `Some(script)` iff
6890 // `StateChange`, byte-for-byte from the variant's own storage);
6891 // the pin catches any future accessor extension that promotes
6892 // an additional variant onto the `PathBuf`-carrying axis — the
6893 // gate then fires on duplicate scripts from that variant too,
6894 // and the singularity discipline the sibling
6895 // `validate_load_singularity` / `validate_cleanup_singularity`
6896 // gates share on the `String`-carrying axis's per-variant
6897 // consumers extends to the promoted variant by construction.
6898 //
6899 // Peer of the sibling four per-`UpgradeInstruction` consumers
6900 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
6901 // sandbox-path fan-out, the layout-side per-`StateChange`
6902 // script-existence fan-out at
6903 // `caixa-core/src/layout.rs:1017`, the cross-slot
6904 // [`validate_upgrade_from_against_behavior`] gate's per-
6905 // `StateChange` detection loop, the peer
6906 // [`UpgradeInstruction::declared_module`] `String`-axis
6907 // per-variant unifier) — this gate now shares one typed
6908 // dispatch on the substrate primitive's `PathBuf`-carrying
6909 // axis with those consumers, so a future rebrand on the axis
6910 // migrates as a single caixa-core edit rather than a
6911 // coordinated rewrite of five call sites.
6912 //
6913 // Three-arm projective coverage:
6914 // (a) `StateChange` scripts project through `declared_path()`
6915 // byte-equal to the raw `script.as_path()` field access;
6916 // (b) a duplicate-`StateChange` input trips the gate on the
6917 // second occurrence with `DuplicateStateChange` carrying
6918 // the offending script verbatim;
6919 // (c) a non-`StateChange`-only input (`LoadModule` /
6920 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
6921 // vacuous with `Ok(())` — the `declared_path().is_none()`
6922 // arm's `continue` fall-through pins.
6923 //
6924 // Fail-before-pass-after verified locally: swapping the
6925 // production `let Some(script) = instr.declared_path() else {
6926 // continue };` back to `let script = match instr {
6927 // UpgradeInstruction::StateChange { script } =>
6928 // script.as_path(), _ => continue, };` keeps arms (a)-(c)
6929 // passing but silently detaches the gate from the accessor's
6930 // typed dispatch — any future `declared_path` extension
6931 // (promotion of an additional variant onto the axis, an
6932 // operator-side pre-resolved-path cache the accessor
6933 // materializes) would then silently disagree between this
6934 // gate's raw pattern-match and the peer four sibling consumers
6935 // that route through the accessor.
6936 use std::path::PathBuf;
6937
6938 // (a) StateChange projection byte-equal via declared_path.
6939 let sc = UpgradeInstruction::StateChange {
6940 script: PathBuf::from("lib/m.lisp"),
6941 };
6942 assert_eq!(
6943 sc.declared_path().map(std::path::PathBuf::as_path),
6944 Some(PathBuf::from("lib/m.lisp").as_path()),
6945 "declared_path() must project the StateChange :script byte-equal to the raw \
6946 field access — accessor divergence would silently detach the gate from the \
6947 projection every peer per-`UpgradeInstruction` consumer routes through"
6948 );
6949
6950 // (b) Duplicate-StateChange input trips the gate.
6951 let dup = entry(
6952 "0.1.0",
6953 vec![
6954 UpgradeInstruction::LoadModule { module: "x".into() },
6955 UpgradeInstruction::StateChange {
6956 script: PathBuf::from("lib/m.lisp"),
6957 },
6958 UpgradeInstruction::StateChange {
6959 script: PathBuf::from("lib/m.lisp"),
6960 },
6961 ],
6962 );
6963 assert_eq!(
6964 dup.validate_state_change_singularity(),
6965 Err(UpgradeError::DuplicateStateChange {
6966 from: "0.1.0".into(),
6967 script: PathBuf::from("lib/m.lisp"),
6968 }),
6969 "duplicate StateChange scripts must trip the gate on the second occurrence \
6970 through the declared_path accessor's Some(script) arm"
6971 );
6972
6973 // (c) Non-StateChange-only inputs leave the gate vacuous.
6974 for instrs in [
6975 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
6976 vec![
6977 UpgradeInstruction::LoadModule { module: "x".into() },
6978 UpgradeInstruction::SoftPurge {
6979 module: "x-old".into(),
6980 },
6981 ],
6982 vec![
6983 UpgradeInstruction::LoadModule { module: "x".into() },
6984 UpgradeInstruction::Purge {
6985 module: "x-old".into(),
6986 },
6987 ],
6988 vec![UpgradeInstruction::Restart],
6989 ] {
6990 for instr in &instrs {
6991 assert!(
6992 instr.declared_path().is_none(),
6993 "non-StateChange variants must project None through declared_path — \
6994 accessor divergence would let this gate silently fire on a duplicate \
6995 module reference far from any :state-change site"
6996 );
6997 }
6998 let e = entry("0.1.0", instrs);
6999 assert_eq!(
7000 e.validate_state_change_singularity(),
7001 Ok(()),
7002 "the state-change-singularity gate must return Ok(()) on an entry whose \
7003 instructions all project None through declared_path — the accessor's \
7004 continue arm the pattern-match's `_ => continue` previously carried"
7005 );
7006 }
7007 }
7008
7009 // ── within-entry state-change-before-cleanup ordering invariant ──
7010
7011 #[test]
7012 fn validate_rejects_state_change_after_soft_purge() {
7013 // Fail-before-pass-after pin: `:state-change` is the
7014 // gen_server:code_change/3 analog and folds the prior-version
7015 // state shape into the current shape; `:soft-purge` drains the
7016 // prior code. The operator runs instructions in declared order,
7017 // so a `:soft-purge` ahead of a `:state-change` drains the
7018 // prior module before the migration callback runs against the
7019 // state it held — the canonical OTP error mode
7020 // "`code_change/3` invoked on a purged module" the
7021 // release_handler closes by always ordering the migration
7022 // before the cleanup.
7023 let e = entry(
7024 "0.1.0",
7025 vec![
7026 UpgradeInstruction::LoadModule { module: "x".into() },
7027 UpgradeInstruction::SoftPurge {
7028 module: "x-old".into(),
7029 },
7030 UpgradeInstruction::StateChange {
7031 script: PathBuf::from("lib/m.lisp"),
7032 },
7033 ],
7034 );
7035 let err = e.validate().unwrap_err();
7036 assert_eq!(
7037 err,
7038 UpgradeError::StateChangeAfterCleanup {
7039 from: "0.1.0".into(),
7040 script: PathBuf::from("lib/m.lisp"),
7041 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7042 prior_cleanup_module: "x-old".into(),
7043 },
7044 "a `:state-change` after a `:soft-purge` must surface as StateChangeAfterCleanup \
7045 naming the offending entry + script + the prior cleanup's kind/module, got {err:?}"
7046 );
7047 }
7048
7049 #[test]
7050 fn validate_rejects_state_change_after_purge() {
7051 // Per-arm coverage: `:purge` (immediate discard, no drain) is
7052 // the more catastrophic peer of `:soft-purge` on the cleanup
7053 // axis; same gate, same shape, the `prior_cleanup_kind` field
7054 // distinguishes the diagnostic so the author can grep their
7055 // caixa.lisp for the offending `(:purge …)` form.
7056 let e = entry(
7057 "0.1.0",
7058 vec![
7059 UpgradeInstruction::LoadModule { module: "x".into() },
7060 UpgradeInstruction::Purge {
7061 module: "x-old".into(),
7062 },
7063 UpgradeInstruction::StateChange {
7064 script: PathBuf::from("lib/m.lisp"),
7065 },
7066 ],
7067 );
7068 let err = e.validate().unwrap_err();
7069 assert_eq!(
7070 err,
7071 UpgradeError::StateChangeAfterCleanup {
7072 from: "0.1.0".into(),
7073 script: PathBuf::from("lib/m.lisp"),
7074 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
7075 prior_cleanup_module: "x-old".into(),
7076 },
7077 "a `:state-change` after a `:purge` must surface as StateChangeAfterCleanup with \
7078 `prior_cleanup_kind: \":purge\"`, got {err:?}"
7079 );
7080 }
7081
7082 #[test]
7083 fn validate_accepts_state_change_before_cleanup() {
7084 // Positive control: the canonical `(:load-module …)
7085 // (:state-change …) (:soft-purge …)` order validates — the
7086 // exact shape the module doc example and `validate_accepts_
7087 // well_formed` already pin, restated here on the new gate's
7088 // identity element so a future shortcut that runs the
7089 // singularity gates first doesn't silently mask a regression
7090 // here.
7091 let e = entry(
7092 "0.1.0",
7093 vec![
7094 UpgradeInstruction::LoadModule { module: "x".into() },
7095 UpgradeInstruction::StateChange {
7096 script: PathBuf::from("lib/m.lisp"),
7097 },
7098 UpgradeInstruction::SoftPurge {
7099 module: "x-old".into(),
7100 },
7101 ],
7102 );
7103 e.validate().unwrap();
7104 }
7105
7106 #[test]
7107 fn validate_accepts_cleanup_without_state_change() {
7108 // Empty-set identity: an entry that carries no `:state-change`
7109 // at all has nothing to order against the cleanup, so the gate
7110 // passes regardless of how the cleanups are placed (after the
7111 // single required `:load-module`). Mirrors the
7112 // `validate_accepts_multiple_purges_after_one_load` positive
7113 // control on the peer purge-ordering gate; metadata-only
7114 // upgrades with cleanup-but-no-migration land here.
7115 let e = entry(
7116 "0.1.0",
7117 vec![
7118 UpgradeInstruction::LoadModule { module: "x".into() },
7119 UpgradeInstruction::SoftPurge {
7120 module: "x-old".into(),
7121 },
7122 UpgradeInstruction::Purge {
7123 module: "x-oldest".into(),
7124 },
7125 ],
7126 );
7127 e.validate().unwrap();
7128 }
7129
7130 #[test]
7131 fn validate_accepts_state_change_without_cleanup() {
7132 // Empty-set identity on the dual axis: an entry that carries no
7133 // cleanup at all has nothing to order against the state-change,
7134 // so the gate passes — additive-upgrade shapes (load new code,
7135 // migrate state, leave old code resident for in-flight callers
7136 // to drain naturally) land here.
7137 let e = entry(
7138 "0.1.0",
7139 vec![
7140 UpgradeInstruction::LoadModule { module: "x".into() },
7141 UpgradeInstruction::StateChange {
7142 script: PathBuf::from("lib/m.lisp"),
7143 },
7144 ],
7145 );
7146 e.validate().unwrap();
7147 }
7148
7149 #[test]
7150 fn validate_accepts_multiple_state_changes_before_cleanup() {
7151 // Coverage: every state-change must precede every cleanup, not
7152 // just the first. A chain `(load) (sc) (sc) (sp)` is the
7153 // canonical "two distinct migration scripts on a chained
7154 // upgrade" shape (one module's schema *and* another's
7155 // projection per the DuplicateStateChange diagnostic), and
7156 // it must pass when each state-change has distinct script
7157 // paths. Pinned here so a future shortcut that only checks
7158 // the first state-change doesn't silently accept a
7159 // `(load) (sc-1) (sp) (sc-2)` regression.
7160 let e = entry(
7161 "0.1.0",
7162 vec![
7163 UpgradeInstruction::LoadModule { module: "x".into() },
7164 UpgradeInstruction::StateChange {
7165 script: PathBuf::from("lib/m1.lisp"),
7166 },
7167 UpgradeInstruction::StateChange {
7168 script: PathBuf::from("lib/m2.lisp"),
7169 },
7170 UpgradeInstruction::SoftPurge {
7171 module: "x-old".into(),
7172 },
7173 ],
7174 );
7175 e.validate().unwrap();
7176 }
7177
7178 #[test]
7179 fn validate_rejects_state_change_sandwiched_between_cleanups() {
7180 // First-cleanup-wins pin: an entry like `(load) (sp-1) (sc)
7181 // (sp-2)` violates the gate because the state-change runs
7182 // after the first cleanup. The reported `prior_cleanup_*`
7183 // names the *first* cleanup (the load-bearing one), not the
7184 // last — mirrors every peer first-collision diagnostic
7185 // posture on this module (`validate_state_change_ordering`,
7186 // `validate_purge_ordering`, `validate_load_singularity`,
7187 // `validate_state_change_singularity`,
7188 // `validate_cleanup_singularity` all report the first
7189 // colliding instruction, not the last).
7190 let e = entry(
7191 "0.1.0",
7192 vec![
7193 UpgradeInstruction::LoadModule { module: "x".into() },
7194 UpgradeInstruction::SoftPurge {
7195 module: "x-old".into(),
7196 },
7197 UpgradeInstruction::StateChange {
7198 script: PathBuf::from("lib/m.lisp"),
7199 },
7200 UpgradeInstruction::Purge {
7201 module: "y-old".into(),
7202 },
7203 ],
7204 );
7205 let err = e.validate().unwrap_err();
7206 assert_eq!(
7207 err,
7208 UpgradeError::StateChangeAfterCleanup {
7209 from: "0.1.0".into(),
7210 script: PathBuf::from("lib/m.lisp"),
7211 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7212 prior_cleanup_module: "x-old".into(),
7213 },
7214 "the first cleanup the state-change follows must surface (not the trailing one), \
7215 got {err:?}"
7216 );
7217 }
7218
7219 #[test]
7220 fn validate_state_change_before_cleanup_fires_after_purge_ordering() {
7221 // Diagnostic-precedence pin: an entry like `((:soft-purge
7222 // "x-old") (:load-module "x") (:state-change "m.lisp"))` is
7223 // *both* purge-without-load (the cleanup runs before the
7224 // load) and state-change-after-cleanup (the state-change
7225 // runs after the cleanup). The more-fundamental ordering
7226 // gate must win — the missing-load defect (a cleanup that
7227 // drains the only resident version to nothing) is load-
7228 // bearing, and surfacing the state-change-after-cleanup
7229 // diagnostic first would mask the drain-to-nothing defect
7230 // the peer purge-ordering gate exists to close. Guards the
7231 // call order in `validate` against silent reordering. Same
7232 // posture as `validate_purge_ordering_fires_after_state_
7233 // change_ordering` on the sibling ordering gate.
7234 //
7235 // Pin specifically uses the load-after-cleanup shape (rather
7236 // than load-less) so the state-change-ordering gate (which
7237 // would otherwise fire first on a `((:soft-purge …)
7238 // (:state-change …))` shape with no leading load) is
7239 // sidestepped: with the load present after the cleanup,
7240 // state-change-ordering passes (its `loaded` latch is set
7241 // before the state-change is encountered) but purge-ordering
7242 // still fails (the cleanup precedes the load). That isolates
7243 // the precedence between purge-ordering and this gate
7244 // cleanly.
7245 let e = entry(
7246 "0.1.0",
7247 vec![
7248 UpgradeInstruction::SoftPurge {
7249 module: "x-old".into(),
7250 },
7251 UpgradeInstruction::LoadModule { module: "x".into() },
7252 UpgradeInstruction::StateChange {
7253 script: PathBuf::from("lib/m.lisp"),
7254 },
7255 ],
7256 );
7257 let err = e.validate().unwrap_err();
7258 assert!(
7259 matches!(
7260 err,
7261 UpgradeError::PurgeWithoutPriorLoad {
7262 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7263 ..
7264 }
7265 ),
7266 "purge-without-load must surface before state-change-after-cleanup, got {err:?}"
7267 );
7268 }
7269
7270 #[test]
7271 fn validate_state_change_before_cleanup_fires_after_state_change_ordering() {
7272 // Diagnostic-precedence pin: an entry like `((:state-change
7273 // "m.lisp") (:soft-purge "x-old"))` is state-change-without-
7274 // load (because no `:load-module` precedes the state-change)
7275 // but *not* state-change-after-cleanup (the state-change
7276 // precedes the cleanup textually). The state-change-ordering
7277 // gate must surface first regardless — the missing-load
7278 // defect on the migration axis is the load-bearing semantic
7279 // and surfacing a different ordering diagnostic would mask
7280 // the migration-against-stale-code defect. Guards the call
7281 // order in `validate` against silent reordering on a shape
7282 // that fires only the state-change-ordering gate (not this
7283 // one), pinning that the state-change-ordering gate wins
7284 // ahead of this gate's chance to look at the list.
7285 let e = entry(
7286 "0.1.0",
7287 vec![
7288 UpgradeInstruction::StateChange {
7289 script: PathBuf::from("lib/m.lisp"),
7290 },
7291 UpgradeInstruction::SoftPurge {
7292 module: "x-old".into(),
7293 },
7294 ],
7295 );
7296 let err = e.validate().unwrap_err();
7297 assert!(
7298 matches!(err, UpgradeError::StateChangeWithoutPriorLoad { .. }),
7299 "state-change-without-load must surface before purge-without-load (the canonical \
7300 validate_purge_ordering_fires_after_state_change_ordering pin), got {err:?}"
7301 );
7302 }
7303
7304 #[test]
7305 fn validate_state_change_before_cleanup_fires_after_per_instr_shape() {
7306 // Order pin: a malformed `:script` value on a `:state-change`
7307 // (an empty path) surfaces its narrower `EmptyScript`
7308 // diagnostic *before* the within-entry state-change-before-
7309 // cleanup gate fires. The per-instruction shape pass walks
7310 // the list inline before the ordering check, so the narrower
7311 // self-locating diagnostic surfaces first — mirrors the
7312 // empty-first cascade on every peer path-shape gate and the
7313 // `validate_purge_ordering_fires_after_per_instr_shape` pin
7314 // on the sibling ordering gate.
7315 let e = 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::new(),
7324 },
7325 ],
7326 );
7327 let err = e.validate().unwrap_err();
7328 assert_eq!(
7329 err,
7330 UpgradeError::EmptyScript,
7331 "malformed instruction must surface its narrower diagnostic before the \
7332 state-change-before-cleanup gate fires, got {err:?}"
7333 );
7334 }
7335
7336 #[test]
7337 fn validate_state_change_before_cleanup_fires_before_state_change_singularity() {
7338 // Diagnostic-precedence pin: an entry like `((:load-module
7339 // "x") (:soft-purge "x-old") (:state-change "m.lisp")
7340 // (:state-change "m.lisp"))` violates *both* this ordering
7341 // gate (the first state-change follows the cleanup) and the
7342 // state-change-singularity gate (the same script appears
7343 // twice). The ordering gate must win — the canonical
7344 // "ordering before singularity" precedence the peer
7345 // `validate_state_change_ordering` / `validate_purge_
7346 // ordering` gates already establish over their own singularity
7347 // gates, applied uniformly across the OTP canonical-sequence
7348 // ordering axis here. Guards the call order in `validate`:
7349 // `validate_state_change_before_cleanup` runs before the
7350 // per-instruction-class singularity gates.
7351 let e = entry(
7352 "0.1.0",
7353 vec![
7354 UpgradeInstruction::LoadModule { module: "x".into() },
7355 UpgradeInstruction::SoftPurge {
7356 module: "x-old".into(),
7357 },
7358 UpgradeInstruction::StateChange {
7359 script: PathBuf::from("lib/m.lisp"),
7360 },
7361 UpgradeInstruction::StateChange {
7362 script: PathBuf::from("lib/m.lisp"),
7363 },
7364 ],
7365 );
7366 let err = e.validate().unwrap_err();
7367 assert!(
7368 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7369 "state-change-after-cleanup must surface before duplicate-state-change, got {err:?}"
7370 );
7371 }
7372
7373 #[test]
7374 fn validate_state_change_before_cleanup_threads_through_validate_upgrade_from() {
7375 // The whole-list entry-point surfaces the per-entry ordering
7376 // error (mirrors `validate_purge_ordering_threads_through_
7377 // validate_upgrade_from` and every peer wiring pin): the gate
7378 // is reachable from the LayoutInvariants call site, not only
7379 // from a direct `entry.validate()`.
7380 let entries = vec![entry(
7381 "0.1.0",
7382 vec![
7383 UpgradeInstruction::LoadModule { module: "x".into() },
7384 UpgradeInstruction::SoftPurge {
7385 module: "x-old".into(),
7386 },
7387 UpgradeInstruction::StateChange {
7388 script: PathBuf::from("lib/m.lisp"),
7389 },
7390 ],
7391 )];
7392 let err = validate_upgrade_from(&entries).unwrap_err();
7393 assert!(
7394 matches!(err, UpgradeError::StateChangeAfterCleanup { .. }),
7395 "validate_upgrade_from must thread the state-change-before-cleanup error, \
7396 got {err:?}"
7397 );
7398 }
7399
7400 #[test]
7401 fn validate_state_change_before_cleanup_projects_scripts_through_declared_path_accessor() {
7402 // Composition pin: [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
7403 // per-instruction `StateChange`-arm script-path projection must
7404 // route through the sibling lifted
7405 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7406 // accessor, not the raw
7407 // `if let UpgradeInstruction::StateChange { script } = instr`
7408 // open-coded pattern-match the gate previously carried inside
7409 // `impl UpgradeFromEntry` at caixa-core/src/upgrade.rs:806.
7410 //
7411 // Structurally: the gate's projection accept-set is the union
7412 // of every [`UpgradeInstruction`] variant for which
7413 // `declared_path().is_some()` — today exactly
7414 // [`UpgradeInstruction::StateChange`] per the sibling
7415 // `declared_path_only_for_state_change` pin, so a
7416 // state-change-after-cleanup input trips
7417 // `StateChangeAfterCleanup` and a non-`StateChange` input
7418 // (module-bearing / terminal) leaves the sticky-once latch
7419 // sweep quiet byte-identical to the pattern-match shape.
7420 //
7421 // Byte-equal today (`declared_path` returns `Some(script)` iff
7422 // `StateChange`, byte-for-byte from the variant's own storage);
7423 // the pin catches any future accessor extension that promotes
7424 // an additional variant onto the `PathBuf`-carrying axis — the
7425 // gate then fires on migrate-after-cleanup for that variant too,
7426 // and the migrate→cleanup ordering discipline the peer
7427 // [`validate_state_change_singularity`] /
7428 // [`validate_upgrade_from_against_behavior`] gates share on the
7429 // same axis extends to the promoted variant by construction.
7430 //
7431 // Peer of the sibling four per-`UpgradeInstruction` consumers
7432 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7433 // sandbox-path fan-out, the layout-side per-`StateChange`
7434 // script-existence fan-out at
7435 // `caixa-core/src/layout.rs:1058`, the within-entry
7436 // [`UpgradeFromEntry::validate_state_change_singularity`]
7437 // per-`StateChange` script-projection fan-out, the cross-slot
7438 // [`validate_upgrade_from_against_behavior`] per-`StateChange`
7439 // detection loop) — the fifth (and last unlifted inside
7440 // `impl UpgradeFromEntry`) per-`UpgradeInstruction`-consumer of
7441 // the `PathBuf`-carrying axis to now route through the accessor.
7442 // Same shape as the sibling
7443 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7444 // and `validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor`
7445 // pins extended onto the within-entry migrate→cleanup ordering
7446 // gate.
7447 //
7448 // Three-arm projective coverage:
7449 // (a) `StateChange` scripts project through `declared_path()`
7450 // byte-equal to the raw `script.clone()` field access
7451 // the diagnostic previously carried;
7452 // (b) a `:state-change`-after-cleanup input trips the gate
7453 // with `StateChangeAfterCleanup` carrying the offending
7454 // script + the prior cleanup's kind/module verbatim;
7455 // (c) a non-`StateChange`-only input (`LoadModule` /
7456 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7457 // vacuous with `Ok(())` — the `declared_path().is_none()`
7458 // arm's fall-through pins.
7459 //
7460 // Fail-before-pass-after verified structurally: swapping the
7461 // production
7462 // `else if let Some(script) = instr.declared_path() && … { … }`
7463 // back to
7464 // `else if let UpgradeInstruction::StateChange { script } = instr && … { … }`
7465 // keeps arms (a)-(c) passing but silently detaches this within-
7466 // entry ordering gate from the accessor's typed dispatch — any
7467 // future `declared_path` extension (promotion of an additional
7468 // variant onto the axis, an operator-side pre-resolved-path
7469 // cache the accessor materializes) would then silently disagree
7470 // between this gate's raw pattern-match and the peer four
7471 // sibling consumers that route through the accessor.
7472
7473 // (a) StateChange projection byte-equal via declared_path.
7474 let sc = UpgradeInstruction::StateChange {
7475 script: PathBuf::from("lib/m.lisp"),
7476 };
7477 assert_eq!(
7478 sc.declared_path().cloned(),
7479 Some(PathBuf::from("lib/m.lisp")),
7480 "declared_path() must project the StateChange :script byte-equal to the raw \
7481 field access — accessor divergence would silently detach this within-entry \
7482 migrate→cleanup ordering gate from the projection every peer per-`UpgradeInstruction` \
7483 consumer routes through"
7484 );
7485
7486 // (b) StateChange-after-cleanup trips the gate through the accessor.
7487 let after = entry(
7488 "0.1.0",
7489 vec![
7490 UpgradeInstruction::LoadModule { module: "x".into() },
7491 UpgradeInstruction::SoftPurge {
7492 module: "x-old".into(),
7493 },
7494 UpgradeInstruction::StateChange {
7495 script: PathBuf::from("lib/m.lisp"),
7496 },
7497 ],
7498 );
7499 assert_eq!(
7500 after.validate(),
7501 Err(UpgradeError::StateChangeAfterCleanup {
7502 from: "0.1.0".into(),
7503 script: PathBuf::from("lib/m.lisp"),
7504 prior_cleanup_kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
7505 prior_cleanup_module: "x-old".into(),
7506 }),
7507 "a :state-change following a cleanup must trip the gate through the declared_path \
7508 accessor's Some(script) arm — carrying the offending script + the prior cleanup's \
7509 kind/module verbatim byte-identical to the pattern-match shape"
7510 );
7511
7512 // (c) Non-StateChange-only inputs leave the gate vacuous.
7513 for instrs in [
7514 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
7515 vec![
7516 UpgradeInstruction::LoadModule { module: "x".into() },
7517 UpgradeInstruction::SoftPurge {
7518 module: "x-old".into(),
7519 },
7520 ],
7521 vec![
7522 UpgradeInstruction::LoadModule { module: "x".into() },
7523 UpgradeInstruction::Purge {
7524 module: "x-old".into(),
7525 },
7526 ],
7527 vec![UpgradeInstruction::Restart],
7528 ] {
7529 for instr in &instrs {
7530 assert!(
7531 instr.declared_path().is_none(),
7532 "non-StateChange variants must project None through declared_path — \
7533 accessor divergence would let this within-entry ordering gate silently \
7534 fire on a cleanup-only sequence far from any :state-change site"
7535 );
7536 }
7537 let e = entry("0.1.0", instrs);
7538 assert_eq!(
7539 e.validate(),
7540 Ok(()),
7541 "the state-change-before-cleanup gate must return Ok(()) on an entry whose \
7542 instructions all project None through declared_path — the accessor's \
7543 None arm the pattern-match's implicit fall-through previously carried"
7544 );
7545 }
7546 }
7547
7548 #[test]
7549 fn validate_restart_order_independent() {
7550 // Position-agnostic: `(:restart)` leading or trailing the
7551 // mixed sequence surfaces the same RestartNotExclusive shape.
7552 // Mirrors OTP appup's order-insensitive
7553 // `restart_emulator | restart_new_emulator` terminal rule —
7554 // the position of the restart instruction in the script is
7555 // irrelevant; what matters is the script *contains* it
7556 // alongside other instructions at all. The gate must not
7557 // gain a false positive by depending on instruction ordering.
7558 let leading = entry(
7559 "0.1.0",
7560 vec![
7561 UpgradeInstruction::Restart,
7562 UpgradeInstruction::LoadModule { module: "x".into() },
7563 ],
7564 );
7565 let trailing = entry(
7566 "0.1.0",
7567 vec![
7568 UpgradeInstruction::LoadModule { module: "x".into() },
7569 UpgradeInstruction::Restart,
7570 ],
7571 );
7572 let middle = entry(
7573 "0.1.0",
7574 vec![
7575 UpgradeInstruction::LoadModule { module: "a".into() },
7576 UpgradeInstruction::Restart,
7577 UpgradeInstruction::SoftPurge {
7578 module: "a-old".into(),
7579 },
7580 ],
7581 );
7582 for e in [&leading, &trailing, &middle] {
7583 assert!(
7584 matches!(
7585 e.validate().unwrap_err(),
7586 UpgradeError::RestartNotExclusive {
7587 restart_count: 1,
7588 ..
7589 }
7590 ),
7591 "mixed-with-:restart entry must surface RestartNotExclusive regardless of \
7592 instruction order, got {:?}",
7593 e.validate()
7594 );
7595 }
7596 }
7597
7598 #[test]
7599 fn validate_restart_exclusive_fires_after_per_instr_shape() {
7600 // Order pin: a malformed `:module` value on a Module-bearing
7601 // instruction (an empty string) surfaces its narrower
7602 // kind-tagged `ModuleEmpty` diagnostic *before* the within-
7603 // entry restart-exclusivity gate fires. The per-instruction
7604 // shape pass walks the list inline before the restart-
7605 // exclusive check, so the narrower self-locating diagnostic
7606 // surfaces first — mirrors the empty-first cascade on every
7607 // peer DNS-1123 gate (`validate_module`,
7608 // `validate_membro_caixa`, `validate_placement_cluster`) and
7609 // the `*_invalid_fires_before_duplicate_check` arm-ordering
7610 // pins on every typed-graph axis. Without this pin a future
7611 // shortcut that runs the restart-exclusive check ahead of
7612 // per-instruction shape would surface a less-actionable
7613 // RestartNotExclusive over an instruction list that's also
7614 // malformed at the per-instruction layer.
7615 let e = entry(
7616 "0.1.0",
7617 vec![
7618 UpgradeInstruction::LoadModule {
7619 module: String::new(),
7620 },
7621 UpgradeInstruction::Restart,
7622 ],
7623 );
7624 let err = e.validate().unwrap_err();
7625 assert_eq!(
7626 err,
7627 UpgradeError::ModuleEmpty {
7628 kind: crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE
7629 },
7630 "malformed instruction must surface its kind-tagged diagnostic before the \
7631 restart-exclusivity gate fires, got {err:?}"
7632 );
7633 }
7634
7635 fn behavior_with_state_change_callback() -> crate::BehaviorSpec {
7636 // Helper for the cross-slot composition gate's pass arm: a
7637 // BehaviorSpec carrying just the `:on-state-change` callback,
7638 // the runtime hook the per-version `(:state-change "…")`
7639 // instruction is delivered through during hot upgrade. Mirrors
7640 // the canonical authoring shape pinned in the module doc.
7641 crate::BehaviorSpec {
7642 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
7643 ..Default::default()
7644 }
7645 }
7646
7647 #[test]
7648 fn behavior_gate_rejects_state_change_without_any_behavior() {
7649 // `:upgrade-from` with a `(:state-change "lib/m.lisp")` and the
7650 // caixa carries no `:behavior` at all surfaces the missing-
7651 // callback diagnostic naming the offending entry's `:from` +
7652 // script. The "I added the upgrade path but never declared
7653 // `:behavior`" footgun: `:behavior` is optional at the typed
7654 // root, the typed `:upgrade-from` slot validates on its own
7655 // merits, and the operator's hot-upgrade dispatch reaches for
7656 // a callback that doesn't exist.
7657 let entries = vec![entry(
7658 "0.1.0",
7659 vec![
7660 UpgradeInstruction::LoadModule { module: "x".into() },
7661 UpgradeInstruction::StateChange {
7662 script: PathBuf::from("lib/m.lisp"),
7663 },
7664 ],
7665 )];
7666 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7667 assert_eq!(
7668 err,
7669 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7670 from: "0.1.0".into(),
7671 script: PathBuf::from("lib/m.lisp"),
7672 },
7673 );
7674 }
7675
7676 #[test]
7677 fn behavior_gate_rejects_state_change_when_on_state_change_is_none() {
7678 // `:behavior` declared with *other* callbacks set
7679 // (`:on-init`, `:on-terminate`, etc.) but `:on-state-change`
7680 // None still surfaces the missing-callback diagnostic — only
7681 // the `:on-state-change` axis matters for this gate. The
7682 // "I declared `:behavior` but missed the migration callback"
7683 // footgun: a caixa that registers its lifecycle hooks but
7684 // forgets the migration delivery path leaves the
7685 // `:state-change` instruction with no runtime hook to
7686 // dispatch through.
7687 let entries = vec![entry(
7688 "0.1.0",
7689 vec![
7690 UpgradeInstruction::LoadModule { module: "x".into() },
7691 UpgradeInstruction::StateChange {
7692 script: PathBuf::from("lib/m.lisp"),
7693 },
7694 ],
7695 )];
7696 let b = crate::BehaviorSpec {
7697 on_init: Some(PathBuf::from("lib/init.lisp")),
7698 on_terminate: Some(PathBuf::from("lib/cleanup.lisp")),
7699 ..Default::default()
7700 };
7701 let err = validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap_err();
7702 assert_eq!(
7703 err,
7704 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7705 from: "0.1.0".into(),
7706 script: PathBuf::from("lib/m.lisp"),
7707 },
7708 "only `:on-state-change` satisfies the composition; other callbacks must not mask \
7709 the missing migration hook"
7710 );
7711 }
7712
7713 #[test]
7714 fn behavior_gate_accepts_state_change_with_on_state_change_callback() {
7715 // The canonical composition shape: a per-version
7716 // `(:state-change "lib/m.lisp")` instruction paired with the
7717 // `:behavior :on-state-change "lib/migrations.lisp"` callback
7718 // it is delivered through at hot-upgrade time. Pins the gate's
7719 // pass arm — drift here = a future tighten that rejects the
7720 // canonical OTP-shape composition surfaces as a regression at
7721 // this positive-control pin.
7722 let entries = vec![entry(
7723 "0.1.0",
7724 vec![
7725 UpgradeInstruction::LoadModule { module: "x".into() },
7726 UpgradeInstruction::StateChange {
7727 script: PathBuf::from("lib/m.lisp"),
7728 },
7729 ],
7730 )];
7731 let b = behavior_with_state_change_callback();
7732 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7733 }
7734
7735 #[test]
7736 fn behavior_gate_accepts_entries_without_any_state_change() {
7737 // Empty-set identity: entries carrying no `:state-change`
7738 // instruction at all (load + cleanup only — the metadata-only
7739 // upgrade shape the module doc names, "On any failure, the
7740 // current version stays load-bearing — a typed atomic
7741 // upgrade") leave the gate vacuous. The composition only
7742 // requires a callback when the per-version script exists; a
7743 // load + cleanup pair has no migration to deliver, so the
7744 // absence of `:on-state-change` is coherent.
7745 let entries = vec![entry(
7746 "0.1.0",
7747 vec![
7748 UpgradeInstruction::LoadModule { module: "x".into() },
7749 UpgradeInstruction::SoftPurge {
7750 module: "x-old".into(),
7751 },
7752 ],
7753 )];
7754 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7755 }
7756
7757 #[test]
7758 fn behavior_gate_accepts_restart_only_entry() {
7759 // The terminal-fallback `((:restart))` shape carries no
7760 // `:state-change` — the operator restarts the pod and the
7761 // new version comes up fresh against its initial state, no
7762 // migration. Pinned alongside the metadata-only positive
7763 // control above as the second empty-state-change shape.
7764 let entries = vec![entry("0.1.0", vec![UpgradeInstruction::Restart])];
7765 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7766 }
7767
7768 #[test]
7769 fn behavior_gate_accepts_empty_entries_list() {
7770 // Empty `:upgrade-from` (a caixa with no declared upgrade
7771 // paths — the v0.1.0 caixa before any upgrade entries are
7772 // added) trivially passes the gate. Pinned so the gate
7773 // doesn't accidentally fire on a caixa that hasn't yet
7774 // declared any upgrades.
7775 let entries: Vec<UpgradeFromEntry> = vec![];
7776 validate_upgrade_from_against_behavior(&entries, None).unwrap();
7777 }
7778
7779 #[test]
7780 fn behavior_gate_reports_first_state_change_in_first_entry() {
7781 // First-collision determinism: with multiple `:state-change`
7782 // instructions across multiple entries, the gate reports the
7783 // *first* one encountered in declaration order — the entry's
7784 // declaration order first, then the within-entry instruction
7785 // order. Mirrors every peer first-collision diagnostic posture
7786 // on this module (`validate_state_change_ordering`,
7787 // `validate_purge_ordering`, the singularity gates), so a
7788 // future shortcut that walks the list in reverse or returns
7789 // the last collision surfaces as a regression here.
7790 let entries = vec![
7791 entry(
7792 "0.1.0",
7793 vec![
7794 UpgradeInstruction::LoadModule { module: "x".into() },
7795 UpgradeInstruction::StateChange {
7796 script: PathBuf::from("lib/m1.lisp"),
7797 },
7798 UpgradeInstruction::StateChange {
7799 script: PathBuf::from("lib/m2.lisp"),
7800 },
7801 ],
7802 ),
7803 entry(
7804 "0.1.5",
7805 vec![
7806 UpgradeInstruction::LoadModule { module: "x".into() },
7807 UpgradeInstruction::StateChange {
7808 script: PathBuf::from("lib/m3.lisp"),
7809 },
7810 ],
7811 ),
7812 ];
7813 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7814 assert_eq!(
7815 err,
7816 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7817 from: "0.1.0".into(),
7818 script: PathBuf::from("lib/m1.lisp"),
7819 },
7820 "the first :state-change in the first entry must surface, not later collisions"
7821 );
7822 }
7823
7824 #[test]
7825 fn behavior_gate_reports_second_entry_when_first_has_no_state_change() {
7826 // Cross-entry pin: a first entry with no `:state-change` (just
7827 // a load + cleanup) leaves the gate's per-entry walk continuing
7828 // to the second entry, where the offending instruction lives.
7829 // The diagnostic names the *second* entry's `:from` because
7830 // that's where the missing-callback shape is exposed — pinned
7831 // so a shortcut that bails on the first entry without a
7832 // `:state-change` (rather than continuing) doesn't mask the
7833 // defect in a later entry.
7834 let entries = vec![
7835 entry(
7836 "0.1.0",
7837 vec![
7838 UpgradeInstruction::LoadModule { module: "x".into() },
7839 UpgradeInstruction::SoftPurge {
7840 module: "x-old".into(),
7841 },
7842 ],
7843 ),
7844 entry(
7845 "0.1.5",
7846 vec![
7847 UpgradeInstruction::LoadModule { module: "x".into() },
7848 UpgradeInstruction::StateChange {
7849 script: PathBuf::from("lib/m.lisp"),
7850 },
7851 ],
7852 ),
7853 ];
7854 let err = validate_upgrade_from_against_behavior(&entries, None).unwrap_err();
7855 assert_eq!(
7856 err,
7857 UpgradeError::StateChangeWithoutOnStateChangeCallback {
7858 from: "0.1.5".into(),
7859 script: PathBuf::from("lib/m.lisp"),
7860 },
7861 "the offending entry's `:from` must surface even when an earlier entry carries no \
7862 :state-change"
7863 );
7864 }
7865
7866 #[test]
7867 fn behavior_gate_does_not_fire_when_callback_is_declared_across_many_entries() {
7868 // Positive control: a multi-entry `:upgrade-from` (chained
7869 // upgrades from v0.1.0 *and* v0.1.5) where every entry carries
7870 // a `:state-change` passes when the callback is declared once
7871 // at the caixa root. The callback is a single per-caixa
7872 // runtime hook; one declaration covers every entry's
7873 // `:state-change`, mirroring OTP's
7874 // `release_handler:install_release/1` which dispatches every
7875 // appup's `code_change` instruction through the single
7876 // `gen_server:code_change/3` callback registered on the
7877 // module.
7878 let entries = vec![
7879 entry(
7880 "0.1.0",
7881 vec![
7882 UpgradeInstruction::LoadModule { module: "x".into() },
7883 UpgradeInstruction::StateChange {
7884 script: PathBuf::from("lib/m1.lisp"),
7885 },
7886 ],
7887 ),
7888 entry(
7889 "0.1.5",
7890 vec![
7891 UpgradeInstruction::LoadModule { module: "x".into() },
7892 UpgradeInstruction::StateChange {
7893 script: PathBuf::from("lib/m2.lisp"),
7894 },
7895 ],
7896 ),
7897 ];
7898 let b = behavior_with_state_change_callback();
7899 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7900 }
7901
7902 #[test]
7903 fn behavior_gate_accepts_load_and_cleanup_only_when_behavior_carries_on_state_change() {
7904 // Symmetry pin: the gate's pass arm doesn't depend on the
7905 // entry actually carrying a `:state-change` — if no
7906 // `:state-change` is declared, the gate is vacuous regardless
7907 // of the callback (an `:on-state-change` declared without a
7908 // matching per-version script is fine, the callback is the
7909 // runtime default for any *future* migration the author hasn't
7910 // yet added). Pins that a caixa author can declare the
7911 // callback ahead of any migration without the gate
7912 // complaining.
7913 let entries = vec![entry(
7914 "0.1.0",
7915 vec![
7916 UpgradeInstruction::LoadModule { module: "x".into() },
7917 UpgradeInstruction::SoftPurge {
7918 module: "x-old".into(),
7919 },
7920 ],
7921 )];
7922 let b = behavior_with_state_change_callback();
7923 validate_upgrade_from_against_behavior(&entries, Some(&b)).unwrap();
7924 }
7925
7926 #[test]
7927 fn validate_upgrade_from_against_behavior_projects_scripts_through_declared_path_accessor() {
7928 // Composition pin: [`validate_upgrade_from_against_behavior`]'s
7929 // per-instruction `StateChange`-arm script-path projection must
7930 // route through the sibling lifted
7931 // [`UpgradeInstruction::declared_path`] `Option<&PathBuf>`
7932 // accessor, not the raw
7933 // `if let UpgradeInstruction::StateChange { script } = instr`
7934 // open-coded pattern-match the cross-slot gate previously
7935 // carried at caixa-core/src/upgrade.rs:1365.
7936 //
7937 // Structurally: the gate's projection accept-set is the union
7938 // of every [`UpgradeInstruction`] variant for which
7939 // `declared_path().is_some()` — today exactly
7940 // [`UpgradeInstruction::StateChange`] per the sibling
7941 // `declared_path_only_for_state_change` pin, so a
7942 // `:state-change`-carrying entry without an `:on-state-change`
7943 // callback trips `StateChangeWithoutOnStateChangeCallback` and
7944 // a non-`StateChange` entry (load-only / cleanup-only /
7945 // restart-only / empty-`:instructions`) leaves the per-entry
7946 // walk continuing past every non-projecting instruction
7947 // byte-identical to the pattern-match shape.
7948 //
7949 // Byte-equal today (`declared_path` returns `Some(script)` iff
7950 // `StateChange`, byte-for-byte from the variant's own storage);
7951 // the pin catches any future accessor extension that promotes
7952 // an additional variant onto the `PathBuf`-carrying axis — the
7953 // gate then fires on scripts from that variant too, and the
7954 // cross-slot composition discipline the sibling per-
7955 // `UpgradeInstruction` consumers share on the `PathBuf`-
7956 // carrying axis extends to the promoted variant by
7957 // construction.
7958 //
7959 // Peer of the sibling four per-`UpgradeInstruction` consumers
7960 // ([`UpgradeInstruction::validate`]'s per-`StateChange`
7961 // sandbox-path fan-out, the layout-side per-`StateChange`
7962 // script-existence fan-out at
7963 // `caixa-core/src/layout.rs:1058`, the within-entry
7964 // [`UpgradeFromEntry::validate_state_change_singularity`]
7965 // (2bf3ce5) per-`StateChange` script-projection fan-out, the
7966 // peer [`UpgradeInstruction::declared_module`] `String`-axis
7967 // per-variant unifier) — the fourth (and last) per-
7968 // `UpgradeInstruction`-consumer of the `PathBuf`-carrying axis
7969 // to now route through the accessor. Same shape as the
7970 // sibling
7971 // `validate_state_change_singularity_projects_scripts_through_declared_path_accessor`
7972 // pin extended onto the cross-slot composition gate.
7973 //
7974 // Three-arm projective coverage:
7975 // (a) `StateChange` scripts project through `declared_path()`
7976 // byte-equal to the raw `script.clone()` field access
7977 // the diagnostic previously carried;
7978 // (b) a `:state-change`-carrying entry with `behavior: None`
7979 // trips the gate with `StateChangeWithoutOnStateChangeCallback`
7980 // carrying the offending script verbatim;
7981 // (c) a non-`StateChange`-only entry (`LoadModule` /
7982 // `SoftPurge` / `Purge` / `Restart`) leaves the gate
7983 // vacuous with `Ok(())` — the `declared_path().is_none()`
7984 // arm's fall-through pins.
7985 //
7986 // Fail-before-pass-after verified structurally: swapping the
7987 // production
7988 // `if let Some(script) = instr.declared_path() { … }`
7989 // back to
7990 // `if let UpgradeInstruction::StateChange { script } = instr { … }`
7991 // keeps arms (a)-(c) passing but silently detaches the gate
7992 // from the accessor's typed dispatch — any future
7993 // `declared_path` extension (promotion of an additional
7994 // variant onto the axis, an operator-side pre-resolved-path
7995 // cache the accessor materializes) would then silently
7996 // disagree between this cross-slot gate's raw pattern-match
7997 // and the peer four sibling consumers that route through the
7998 // accessor.
7999
8000 // (a) StateChange projection byte-equal via declared_path.
8001 let sc = UpgradeInstruction::StateChange {
8002 script: PathBuf::from("lib/m.lisp"),
8003 };
8004 assert_eq!(
8005 sc.declared_path().cloned(),
8006 Some(PathBuf::from("lib/m.lisp")),
8007 "declared_path() must project the StateChange :script byte-equal to the raw \
8008 field access — accessor divergence would silently detach this cross-slot \
8009 composition gate from the projection every peer per-`UpgradeInstruction` \
8010 consumer routes through"
8011 );
8012
8013 // (b) StateChange-carrying entry with behavior: None trips gate.
8014 let entries = vec![entry(
8015 "0.1.0",
8016 vec![
8017 UpgradeInstruction::LoadModule { module: "x".into() },
8018 UpgradeInstruction::StateChange {
8019 script: PathBuf::from("lib/m.lisp"),
8020 },
8021 ],
8022 )];
8023 assert_eq!(
8024 validate_upgrade_from_against_behavior(&entries, None),
8025 Err(UpgradeError::StateChangeWithoutOnStateChangeCallback {
8026 from: "0.1.0".into(),
8027 script: PathBuf::from("lib/m.lisp"),
8028 }),
8029 "a :state-change-carrying entry with behavior: None must trip the gate through \
8030 the declared_path accessor's Some(script) arm — carrying the offending script \
8031 verbatim byte-identical to the pattern-match shape"
8032 );
8033
8034 // (c) Non-StateChange-only inputs leave the gate vacuous.
8035 for instrs in [
8036 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8037 vec![
8038 UpgradeInstruction::LoadModule { module: "x".into() },
8039 UpgradeInstruction::SoftPurge {
8040 module: "x-old".into(),
8041 },
8042 ],
8043 vec![
8044 UpgradeInstruction::LoadModule { module: "x".into() },
8045 UpgradeInstruction::Purge {
8046 module: "x-old".into(),
8047 },
8048 ],
8049 vec![UpgradeInstruction::Restart],
8050 ] {
8051 for instr in &instrs {
8052 assert!(
8053 instr.declared_path().is_none(),
8054 "non-StateChange variants must project None through declared_path — \
8055 accessor divergence would let this cross-slot composition gate silently \
8056 fire on a module reference far from any :state-change site"
8057 );
8058 }
8059 let entries = vec![entry("0.1.0", instrs)];
8060 assert_eq!(
8061 validate_upgrade_from_against_behavior(&entries, None),
8062 Ok(()),
8063 "the cross-slot composition gate must return Ok(()) on an entry whose \
8064 instructions all project None through declared_path — the accessor's \
8065 None arm the pattern-match's implicit fall-through previously carried"
8066 );
8067 }
8068 }
8069
8070 #[test]
8071 fn validate_restart_exclusive_threads_through_validate_upgrade_from() {
8072 // Wiring pin: the within-entry restart-exclusivity gate fires
8073 // through [`validate_upgrade_from`] (which delegates to
8074 // [`UpgradeFromEntry::validate`] per entry) before the cross-
8075 // entry duplicate-`:from` gate would have a chance to run on
8076 // the malformed entry. Pinned here so a future refactor that
8077 // walks the cross-entry gate first doesn't accidentally
8078 // surface a DuplicateFrom over an entry that's also malformed
8079 // at the within-entry restart-exclusivity layer.
8080 let entries = vec![
8081 entry(
8082 "0.1.0",
8083 vec![
8084 UpgradeInstruction::LoadModule { module: "x".into() },
8085 UpgradeInstruction::Restart,
8086 ],
8087 ),
8088 entry("0.1.0", vec![UpgradeInstruction::Restart]),
8089 ];
8090 let err = validate_upgrade_from(&entries).unwrap_err();
8091 assert!(
8092 matches!(
8093 err,
8094 UpgradeError::RestartNotExclusive {
8095 restart_count: 1,
8096 ..
8097 }
8098 ),
8099 "within-entry restart-exclusivity diagnostic must surface before the cross-entry \
8100 duplicate-`:from` gate fires, got {err:?}"
8101 );
8102 }
8103
8104 // ── drift-detection: serde-derive-to-M2_UPGRADE_FROM_KEY_* identity ──
8105
8106 #[test]
8107 fn upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts() {
8108 // Load-bearing invariant: the two `M2_UPGRADE_FROM_KEY_*` consts
8109 // (`M2_UPGRADE_FROM_KEY_FROM` / `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`)
8110 // name the exact camelCase JSON keys the `#[serde(rename_all =
8111 // "camelCase")]` attribute on `UpgradeFromEntry` emits, and every
8112 // test-side probe across the caixa-core / caixa-flux renderer
8113 // test fixtures navigates into each element of the rendered
8114 // `:upgrade-from` overlay sequence by consulting one of these two
8115 // `&'static str`s. Serialize a fully-populated UpgradeFromEntry
8116 // and pin that each canonical byte-sequence appears verbatim in
8117 // the JSON — a future accidental `rename_all = "snake_case"` /
8118 // `"kebab-case"` / verbatim-field-name flip at the derive
8119 // attribute (any of which would silently break every test-side
8120 // probe that reaches for one of the two consts) surfaces here as
8121 // a build-time test failure at `upgrade.rs`, not as an apply-time
8122 // `.get(<stale-canonical-const>)` returning `None` far from the
8123 // derive-attr drift's commit. Same discipline the sibling
8124 // `limits_spec_serde_keys_match_lifted_m2_limits_key_consts`
8125 // (d8b8b4f) and
8126 // `behavior_spec_serde_keys_match_lifted_m2_behavior_key_consts`
8127 // (21fe462) pins established on the peer `:limits` / `:behavior`
8128 // sub-slot axes: one canonical byte-string per typed sub-key
8129 // axis, pinned to the load-bearing serde derivation at the type
8130 // itself.
8131 let e = UpgradeFromEntry {
8132 from: "0.1.0".into(),
8133 instructions: vec![UpgradeInstruction::LoadModule {
8134 module: "hello-rio".into(),
8135 }],
8136 };
8137 let json = serde_json::to_string(&e).unwrap();
8138 for key in [
8139 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8140 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8141 ] {
8142 let quoted = format!("\"{key}\"");
8143 assert!(
8144 json.contains("ed),
8145 "serialized UpgradeFromEntry must carry the lifted \
8146 M2_UPGRADE_FROM_KEY_* byte-sequence {quoted} verbatim in \
8147 the JSON emission (got: {json})",
8148 );
8149 }
8150 }
8151
8152 #[test]
8153 fn m2_upgrade_from_key_consts_are_pairwise_distinct() {
8154 // Cross-axis drift-detection pin: a future collapse of the two
8155 // canonical sub-key byte-strings onto the same value (e.g. an
8156 // accidental copy-paste flip of `M2_UPGRADE_FROM_KEY_INSTRUCTIONS`
8157 // to also read `"from"`) would silently reroute every test-side
8158 // probe on one axis onto the sibling axis's per-entry field and
8159 // pass every propagation-probe test that expected only the stale
8160 // axis's value. Peer of `m2_limits_key_consts_are_pairwise_distinct`
8161 // (d8b8b4f) and `m2_behavior_key_consts_are_pairwise_distinct`
8162 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8163 let all = [
8164 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8165 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8166 ];
8167 for (i, a) in all.iter().enumerate() {
8168 for b in all.iter().skip(i + 1) {
8169 assert_ne!(
8170 a, b,
8171 "M2_UPGRADE_FROM_KEY_* consts must be pairwise-distinct \
8172 canonical byte-sequences — got `{a}` == `{b}`",
8173 );
8174 }
8175 }
8176 }
8177
8178 #[test]
8179 fn upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const() {
8180 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8181 // per-entry OTP-appup [`UpgradeInstruction`] enum's internally-
8182 // tagged variant-discriminator key axis: the
8183 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` const names the exact tag-slot
8184 // JSON key the `#[serde(tag = "kind", rename_all = "kebab-case")]`
8185 // attribute on [`UpgradeInstruction`] emits, and every downstream
8186 // consumer that navigates the serialized instruction blob to
8187 // route by variant (the caixa-core reflection-vs-serde round-trip
8188 // check in `dispatcher_registration.rs` that probes
8189 // `v.get("kind")` against every variant's expected kebab-case
8190 // tag, the future M4 admission-webhook path, any wasm-operator
8191 // dispatch step consuming the serialized instruction blob) reads
8192 // through the same `&'static str`. Serialize every variant and
8193 // pin that the const's byte-sequence appears verbatim as the
8194 // tag-slot JSON key with the expected kebab-case value — a
8195 // future accidental `tag = "type"` / `tag = "op"` /
8196 // `tag = "instruction"` rebrand at the derive attribute (any of
8197 // which would silently break every consumer probe reaching for
8198 // the stale-tag-key const) surfaces here as a build-time test
8199 // failure at `upgrade.rs`, not as an apply-time
8200 // `.get(<stale-tag-key>)` returning `None` far from the derive-
8201 // attr drift's commit.
8202 //
8203 // Same "one canonical byte-string per typed axis" discipline the
8204 // sibling `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8205 // pin (36ffe65) established on the peer `:upgrade-from` per-entry
8206 // outer-container axis — this pin extends the discipline one
8207 // altitude deeper onto the per-instruction *tag* axis inside
8208 // each element of the `:instructions` list, completing the
8209 // typed coverage of the `:upgrade-from :instructions` dual
8210 // (key = "kind" + five variant-value tags): the five
8211 // `M2_UPGRADE_INSTRUCTION_KIND_*` consts (56120ef) pin the
8212 // per-variant kebab-case *values*; this pin pins the tag *key*
8213 // above them.
8214 let samples: [(UpgradeInstruction, &'static str); 5] = [
8215 (
8216 UpgradeInstruction::LoadModule {
8217 module: "hello-rio".into(),
8218 },
8219 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE.trim_start_matches(':'),
8220 ),
8221 (
8222 UpgradeInstruction::StateChange {
8223 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8224 },
8225 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE.trim_start_matches(':'),
8226 ),
8227 (
8228 UpgradeInstruction::SoftPurge {
8229 module: "hello-rio-old".into(),
8230 },
8231 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE.trim_start_matches(':'),
8232 ),
8233 (
8234 UpgradeInstruction::Purge {
8235 module: "hello-rio-old".into(),
8236 },
8237 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE.trim_start_matches(':'),
8238 ),
8239 (
8240 UpgradeInstruction::Restart,
8241 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART.trim_start_matches(':'),
8242 ),
8243 ];
8244 for (sample, expected_value) in &samples {
8245 let v: serde_json::Value = serde_json::to_value(sample).unwrap();
8246 let got = v
8247 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
8248 .and_then(|k| k.as_str());
8249 assert_eq!(
8250 got,
8251 Some(*expected_value),
8252 "serialized {sample:?} must carry the lifted \
8253 M2_UPGRADE_INSTRUCTION_KEY_KIND byte-sequence \
8254 ({:?}) verbatim as the tag-slot JSON key, holding the \
8255 expected kebab-case value {expected_value:?} (got: {v})",
8256 crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND,
8257 );
8258 }
8259 }
8260
8261 #[test]
8262 fn m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape() {
8263 // Shape-pin: the `M2_UPGRADE_INSTRUCTION_KEY_KIND` const must be
8264 // a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8265 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8266 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8267 // whitespace / colons / dots) — the canonical shape a serde
8268 // internally-tagged discriminator key takes across every peer
8269 // enum in this crate. A future flip to a non-camelCase byte at
8270 // the const surfaces here at build time. Peer of
8271 // `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on the
8272 // sibling per-entry outer-container axis.
8273 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8274 assert!(
8275 !key.is_empty(),
8276 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be non-empty (got {key:?})"
8277 );
8278 let first = key.chars().next().unwrap();
8279 assert!(
8280 first.is_ascii_lowercase(),
8281 "M2_UPGRADE_INSTRUCTION_KEY_KIND must lead with an ASCII-lowercase \
8282 byte (got {key:?}, leads with {first:?})",
8283 );
8284 assert!(
8285 key.chars().all(|c| c.is_ascii_alphanumeric()),
8286 "M2_UPGRADE_INSTRUCTION_KEY_KIND must be ASCII-alphanumeric only \
8287 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8288 );
8289 }
8290
8291 #[test]
8292 fn m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys() {
8293 // Cross-axis drift-detection pin: the tag-slot key
8294 // `M2_UPGRADE_INSTRUCTION_KEY_KIND` (`"kind"`) must be
8295 // disjoint from every per-variant data-field key the
8296 // internally-tagged serialization also emits (`"module"` for
8297 // LoadModule/SoftPurge/Purge, `"script"` for StateChange). A
8298 // future accidental rebrand that collapses `tag = "kind"` onto
8299 // one of the data-field names (e.g. `tag = "module"`) would
8300 // silently corrupt every serialized LoadModule blob (the
8301 // module string and the variant tag would collide on the same
8302 // JSON key) and every consumer probe would either misread the
8303 // tag or fail to distinguish variants. Pin the disjointness at
8304 // build time. Same cross-axis discipline the sibling
8305 // `m2_upgrade_from_key_consts_are_pairwise_distinct` pin
8306 // (36ffe65) established on the outer container's own
8307 // `from`/`instructions` pair.
8308 let key = crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND;
8309 // Enumerate every per-variant data-field key across all five
8310 // variants of [`UpgradeInstruction`], routing through the two
8311 // lifted `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` byte-string consts
8312 // that name the same per-variant data-field JSON keys the
8313 // `variant_fields` reflection in
8314 // `caixa-core/tests/dispatcher_registration.rs` surfaces. A future
8315 // per-variant struct-field rebrand (`module` → `component`,
8316 // `script` → `path`) lands as an edit to exactly one const and
8317 // reaches this disjointness pin by construction — the two axes
8318 // (tag-slot key on one side, per-variant data-field keys on the
8319 // other) share one source of truth per axis.
8320 for data_field in [
8321 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8322 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8323 ] {
8324 assert_ne!(
8325 key, data_field,
8326 "M2_UPGRADE_INSTRUCTION_KEY_KIND (the serde `tag` slot) \
8327 must be disjoint from every UpgradeInstruction per-variant \
8328 data-field key — got tag-key {key:?} colliding with \
8329 data-field {data_field:?}, which would silently corrupt \
8330 the internally-tagged serialization",
8331 );
8332 }
8333 }
8334
8335 #[test]
8336 fn upgrade_instruction_variant_data_field_keys_match_lifted_field_key_consts() {
8337 // Load-bearing invariant on the M2 `:upgrade-from :instructions`
8338 // per-entry OTP-appup [`UpgradeInstruction`] enum's per-variant
8339 // data-field JSON key axis: the two
8340 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` consts (`_MODULE`,
8341 // `_SCRIPT`) name the exact per-variant field JSON keys the
8342 // `#[serde(tag = "kind", rename_all = "kebab-case")]` attribute on
8343 // [`UpgradeInstruction`] emits alongside the tag-slot key from the
8344 // sibling [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`]
8345 // const — the `module: String` struct-field on
8346 // `LoadModule`/`SoftPurge`/`Purge` and the `script: PathBuf`
8347 // struct-field on `StateChange` are promoted to sibling JSON keys
8348 // at the same nesting level as the tag by the internally-tagged
8349 // serialization, and every downstream consumer that navigates the
8350 // serialized instruction blob to reach the payload (the caixa-core
8351 // reflection round-trip in `dispatcher_registration.rs` that
8352 // consults `variant_fields`, the sibling disjointness pin below,
8353 // any future wasm-operator upgrade-dispatch step consuming the
8354 // serialized instruction blob to route the per-module load /
8355 // soft-purge / purge action or the per-script state-change action)
8356 // reads through the same `&'static str`. Serialize one Module-
8357 // bearing variant and one Script-bearing variant, then pin that
8358 // each const's byte-sequence appears verbatim in the JSON emission
8359 // — a future accidental struct-field rebrand (`module: String` →
8360 // `component: String`, `script: PathBuf` → `path: PathBuf`) at
8361 // either variant surfaces here as a build-time test failure at
8362 // `upgrade.rs`, not as an apply-time `.get(<stale-field-key>)`
8363 // returning `None` far from the field-name drift's commit.
8364 //
8365 // Same "one canonical byte-string per typed axis" discipline the
8366 // sibling `upgrade_instruction_serde_tag_key_matches_lifted_m2_upgrade_instruction_key_kind_const`
8367 // pin established on the peer tag-slot key axis on the same
8368 // enum — this pin extends the discipline onto the per-variant
8369 // data-field key axis, completing the `:upgrade-from :instructions`
8370 // variant-JSON dual (tag key + tag values + per-variant field keys)
8371 // fully into caixa-core.
8372 let module_sample = UpgradeInstruction::LoadModule {
8373 module: "hello-rio".into(),
8374 };
8375 let v: serde_json::Value = serde_json::to_value(&module_sample).unwrap();
8376 assert_eq!(
8377 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE)
8378 .and_then(|k| k.as_str()),
8379 Some("hello-rio"),
8380 "serialized {module_sample:?} must carry the lifted \
8381 M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE byte-sequence \
8382 ({:?}) verbatim as the data-field JSON key holding the \
8383 module string (got: {v})",
8384 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8385 );
8386
8387 let script_sample = UpgradeInstruction::StateChange {
8388 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8389 };
8390 let v: serde_json::Value = serde_json::to_value(&script_sample).unwrap();
8391 assert_eq!(
8392 v.get(crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT)
8393 .and_then(|k| k.as_str()),
8394 Some("lib/migrations/v01-to-v02.lisp"),
8395 "serialized {script_sample:?} must carry the lifted \
8396 M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT byte-sequence \
8397 ({:?}) verbatim as the data-field JSON key holding the \
8398 script path (got: {v})",
8399 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8400 );
8401 }
8402
8403 #[test]
8404 fn m2_upgrade_instruction_field_key_consts_are_lower_camel_case_shape() {
8405 // Shape-pin: every `M2_UPGRADE_INSTRUCTION_FIELD_KEY_*` const must
8406 // be a lowerCamelCase byte-sequence (non-empty, ASCII-lowercase
8407 // leader, ASCII-alphanumeric only — no `snake_case` underscores,
8408 // no `kebab-case` hyphens, no `PascalCase` leading capital, no
8409 // whitespace / colons / dots) — the canonical shape a Rust
8410 // struct-field name promoted to a JSON key by serde takes on this
8411 // internally-tagged variant surface, matching the sibling
8412 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag-slot key
8413 // shape. A future flip to a non-camelCase byte at either const
8414 // (an accidental `rename_all` regime interleave, or a struct-
8415 // field flip like `module` → `module_name`) surfaces here at
8416 // build time. Peer of
8417 // `m2_upgrade_instruction_key_kind_const_is_lower_camel_case_shape`
8418 // and `m2_upgrade_from_key_consts_are_lower_camel_case_shape` on
8419 // the sibling wire-key axes.
8420 for key in [
8421 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8422 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8423 ] {
8424 assert!(
8425 !key.is_empty(),
8426 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be non-empty (got {key:?})"
8427 );
8428 let first = key.chars().next().unwrap();
8429 assert!(
8430 first.is_ascii_lowercase(),
8431 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must lead with an ASCII-lowercase \
8432 byte (got {key:?}, leads with {first:?})",
8433 );
8434 assert!(
8435 key.chars().all(|c| c.is_ascii_alphanumeric()),
8436 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* must be ASCII-alphanumeric only \
8437 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8438 );
8439 }
8440 }
8441
8442 #[test]
8443 fn m2_upgrade_instruction_field_key_consts_are_pairwise_distinct() {
8444 // Cross-axis drift-detection pin: a future collapse of the two
8445 // canonical per-variant data-field byte-strings onto the same
8446 // value (e.g. an accidental copy-paste flip of
8447 // `M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT` to also read
8448 // `"module"`) would silently reroute every test-side probe on one
8449 // variant's payload onto the sibling variant's payload and pass
8450 // every propagation-probe test that expected only the stale
8451 // axis's value. Peer of `m2_upgrade_from_key_consts_are_pairwise_distinct`
8452 // on the sibling per-entry outer-container axis, and of
8453 // `m2_upgrade_instruction_key_kind_const_disjoint_from_variant_data_keys`
8454 // on the sibling tag-slot key ↔ per-variant data-field key axis.
8455 let all = [
8456 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_MODULE,
8457 crate::render::M2_UPGRADE_INSTRUCTION_FIELD_KEY_SCRIPT,
8458 ];
8459 for (i, a) in all.iter().enumerate() {
8460 for b in all.iter().skip(i + 1) {
8461 assert_ne!(
8462 a, b,
8463 "M2_UPGRADE_INSTRUCTION_FIELD_KEY_* consts must be pairwise-distinct \
8464 canonical byte-sequences — got `{a}` == `{b}`",
8465 );
8466 }
8467 }
8468 }
8469
8470 #[test]
8471 fn m2_upgrade_from_key_consts_are_lower_camel_case_shape() {
8472 // Shape-pin: every `M2_UPGRADE_FROM_KEY_*` const must be a
8473 // lowerCamelCase byte-sequence (no `snake_case` underscores, no
8474 // `kebab-case` hyphens, no `PascalCase` leading capital, no
8475 // whitespace / colons / dots) — the canonical shape the
8476 // `#[serde(rename_all = "camelCase")]` derive produces on
8477 // `UpgradeFromEntry`. A future flip to a non-camelCase attribute
8478 // at the derive surfaces both here (this test fails on the
8479 // stale-constant shape) and at
8480 // `upgrade_from_entry_serde_keys_match_lifted_m2_upgrade_from_key_consts`
8481 // (that test fails on the mismatch between const and derive).
8482 // Peer of `m2_limits_key_consts_are_lower_camel_case_shape`
8483 // (d8b8b4f) and `m2_behavior_key_consts_are_lower_camel_case_shape`
8484 // (21fe462) on the sibling `:limits` / `:behavior` sub-slot axes.
8485 for key in [
8486 crate::render::M2_UPGRADE_FROM_KEY_FROM,
8487 crate::render::M2_UPGRADE_FROM_KEY_INSTRUCTIONS,
8488 ] {
8489 assert!(
8490 !key.is_empty(),
8491 "M2_UPGRADE_FROM_KEY_* must be non-empty (got {key:?})"
8492 );
8493 let first = key.chars().next().unwrap();
8494 assert!(
8495 first.is_ascii_lowercase(),
8496 "M2_UPGRADE_FROM_KEY_* must lead with an ASCII-lowercase \
8497 byte (got {key:?}, leads with {first:?})",
8498 );
8499 assert!(
8500 key.chars().all(|c| c.is_ascii_alphanumeric()),
8501 "M2_UPGRADE_FROM_KEY_* must be ASCII-alphanumeric only \
8502 — no `_` / `-` / `:` / `.` / whitespace (got {key:?})",
8503 );
8504 }
8505 }
8506
8507 #[test]
8508 fn m2_upgrade_instruction_kind_consts_pin_canonical_kebab_case_labels() {
8509 // Scalar-value pin on the M2 `:upgrade-from :instructions` per-entry
8510 // OTP-appup variant-tag axis: the five canonical author-facing
8511 // kebab-case labels (`:load-module` / `:state-change` /
8512 // `:soft-purge` / `:purge` / `:restart`) the substrate's
8513 // per-variant [`UpgradeInstruction::lisp_form`] dispatch reads
8514 // from and every downstream consumer probes for verbatim. Same
8515 // scalar-value discipline the peer
8516 // `contrato_author_key_consts_pin_canonical_kebab_case_labels`
8517 // (f50c875), `m3_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8518 // (882f498), `m2_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8519 // (f49c8b0), and `supervisor_top_level_author_key_consts_pin_canonical_kebab_case_labels`
8520 // (be40492) established for the sibling M2 / M3 / Supervisor
8521 // top-level and sub-slot author-facing-label axes. Fail-before-
8522 // pass-after locally verified by mutating
8523 // `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE` to `":load"` — this
8524 // pin fires as expected; restoring passes.
8525 //
8526 // A future OTP-lineage per-variant rebrand (e.g.
8527 // `:load-module` → `:load` matching Erlang's abbreviated
8528 // `code:load_module` name, `:state-change` → `:code-change`
8529 // matching Erlang's verbatim `code_change/3` callback,
8530 // `:soft-purge` → `:drain` matching a hypothetical operator-side
8531 // vocabulary flip, `:purge` → `:discard` matching a hypothetical
8532 // Elixir/Phoenix hot-reload rebrand, `:restart` → `:reboot`
8533 // matching a supervisor-tree vocabulary alignment) lands as an
8534 // edit to exactly one const, and every consumer that reaches for
8535 // the label (the [`UpgradeInstruction::lisp_form`] dispatch, the
8536 // [`validate_cleanup_singularity`] per-variant `kind:` tagger,
8537 // every [`UpgradeError`] `kind:` / `kinds:` / `other_kinds:` /
8538 // `prior_cleanup_kind:` diagnostic field, the
8539 // [`LayoutError::UpgradeViolation`] `issue:` probe in
8540 // `layout.rs`) picks it up at build time rather than at runtime
8541 // as a downstream `kind: <stale-kebab-case>` diagnostic mismatch
8542 // far from the rename's commit.
8543 assert_eq!(
8544 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8545 ":load-module"
8546 );
8547 assert_eq!(
8548 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8549 ":state-change"
8550 );
8551 assert_eq!(
8552 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8553 ":soft-purge"
8554 );
8555 assert_eq!(crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE, ":purge");
8556 assert_eq!(
8557 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8558 ":restart"
8559 );
8560 }
8561
8562 #[test]
8563 fn m2_upgrade_instruction_kind_consts_are_pairwise_distinct() {
8564 // Cross-arm drift-detection pin on the M2
8565 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`] /
8566 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE`] /
8567 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`] /
8568 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE`] /
8569 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART`]
8570 // closed-set OTP-appup variant-tag pentad: a future collapse
8571 // of two canonical variant byte-strings onto the same value
8572 // (an accidental copy-paste flip of
8573 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8574 // to also read `":purge"`, a per-arm rebrand that lands one
8575 // const without touching its paired peer) would silently
8576 // reroute every downstream OTP-appup dispatcher's per-
8577 // instruction branch onto the sibling arm's runtime
8578 // behavior and pass every propagation-probe test that
8579 // expected only the stale arm's tag — a `:soft-purge`
8580 // instruction (drain-then-swap: existing callers finish
8581 // under the old module, new callers land on the new one)
8582 // would come up under the `:purge` reconcile branch
8583 // (drop-existing: every in-flight caller terminates
8584 // immediately) on every hot-upgrade cycle, so a rolling
8585 // module swap would silently downgrade to a hard cutover
8586 // against its declared appup discipline, with no field
8587 // naming the instruction-tag drift root cause. Every
8588 // [`crate::UpgradeError`] diagnostic that surfaces the tag
8589 // ([`crate::UpgradeError::ModuleEmpty`] with `kind:` field,
8590 // [`crate::UpgradeError::CleanupCollision`] with `kinds:`
8591 // slice, [`crate::UpgradeError::CleanupPrecedes`] with
8592 // `prior_cleanup_kind:` field, the
8593 // [`crate::LayoutError::UpgradeViolation`] `issue:` probe in
8594 // `layout.rs`) would emit the sibling arm's stale bytes at
8595 // the operator's console, far from the source rebrand
8596 // commit. Peer of the sibling
8597 // [`crate::supervisor::tests::supervisor_estrategia_consts_are_pairwise_distinct`]
8598 // (09ffb2d) /
8599 // [`crate::supervisor::tests::supervisor_child_restart_consts_are_pairwise_distinct`]
8600 // (ccdf955) /
8601 // [`crate::kind::tests::caixa_kind_label_consts_are_pairwise_distinct`]
8602 // (d739850) distinctness pins on the sibling OTP-shape /
8603 // caixa-kind closed-set typed-enum discriminator axes —
8604 // the fifth closed-set OTP-appup / typed-enum axis to
8605 // converge on the same
8606 // "pairwise-distinct-by-construction" discipline, and the
8607 // canonical companion to the peer
8608 // [`m2_upgrade_instruction_field_key_consts_are_pairwise_distinct`]
8609 // (ff980bb) distinctness pin on the sibling internally-
8610 // tagged-JSON per-variant data-field-key axis (the tag axis
8611 // this pin covers vs. the data-field-key axis its peer
8612 // covers — two paired axes on the same
8613 // [`crate::UpgradeInstruction`] typed enum surface).
8614 //
8615 // Fail-before-pass-after locally verified by mutating
8616 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE`]
8617 // to also read `":purge"` — this pin fires as expected;
8618 // restoring passes.
8619 let all = [
8620 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8621 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8622 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8623 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8624 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8625 ];
8626 for (i, a) in all.iter().enumerate() {
8627 for (j, b) in all.iter().enumerate() {
8628 if i != j {
8629 assert_ne!(
8630 a, b,
8631 "M2_UPGRADE_INSTRUCTION_KIND_* consts must be pairwise \
8632 distinct — got duplicate {a:?} at indices {i} and {j}",
8633 );
8634 }
8635 }
8636 }
8637 }
8638
8639 #[test]
8640 fn upgrade_instruction_lisp_form_routes_through_lifted_kind_consts() {
8641 // Production-through-const pin: the five per-variant labels
8642 // [`UpgradeInstruction::lisp_form`] returns route through the
8643 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] consts,
8644 // so a future rebrand that reaches the const but not the
8645 // dispatch (or vice versa) surfaces here at build time rather
8646 // than at runtime as a downstream
8647 // [`UpgradeError::ModuleEmpty`] `kind: <stale-kebab-case>`
8648 // diagnostic drift far from the rename's commit. Mirror of the
8649 // peer `contrato_shape_gate_routes_through_lifted_contrato_author_key_consts`
8650 // (f50c875), `declared_mesh_slots_route_through_lifted_m3_author_key_consts`
8651 // (882f498), and `declared_servico_slots_route_through_lifted_m2_author_key_consts`
8652 // (f49c8b0) production-through-const pins on the sibling M3 /
8653 // M2 top-level slot axes.
8654 //
8655 // Fail-before-pass-after locally verified by mutating
8656 // `UpgradeInstruction::lisp_form`'s `Self::Purge` arm to return
8657 // `":purge-drift"` — this pin fires as expected; restoring
8658 // passes.
8659 let cases: &[(UpgradeInstruction, &'static str)] = &[
8660 (
8661 UpgradeInstruction::LoadModule { module: "x".into() },
8662 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8663 ),
8664 (
8665 UpgradeInstruction::StateChange {
8666 script: PathBuf::from("lib/m.lisp"),
8667 },
8668 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
8669 ),
8670 (
8671 UpgradeInstruction::SoftPurge {
8672 module: "x-old".into(),
8673 },
8674 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
8675 ),
8676 (
8677 UpgradeInstruction::Purge {
8678 module: "x-old".into(),
8679 },
8680 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
8681 ),
8682 (
8683 UpgradeInstruction::Restart,
8684 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8685 ),
8686 ];
8687 for (instr, expected) in cases {
8688 assert_eq!(
8689 instr.lisp_form(),
8690 *expected,
8691 "UpgradeInstruction::lisp_form on {instr:?} must route through the lifted \
8692 const (expected {expected:?})",
8693 );
8694 }
8695 }
8696
8697 #[test]
8698 fn upgrade_instruction_as_str_routes_through_lifted_wire_consts() {
8699 // Production-through-const pin on the peer wire-form axis: the
8700 // five per-variant un-prefixed kebab byte-strings
8701 // [`UpgradeInstruction::as_str`] returns route through the
8702 // lifted [`crate::render::M2_UPGRADE_INSTRUCTION_WIRE_*`] consts,
8703 // so a future rebrand that reaches the const but not the
8704 // dispatch (or vice versa) surfaces here at build time rather
8705 // than at runtime as a divergent JSON `"kind"` tag between the
8706 // serde-derived wire byte-string and the accessor-routed
8707 // source of truth on every K8s-CR round-trip / structured-log
8708 // line / dispatcher-catalog lookup. Peer of the sibling
8709 // [`upgrade_instruction_lisp_form_routes_through_lifted_kind_consts`]
8710 // pin on the tatara-lisp author-surface form axis — the
8711 // two-axis discipline (author-surface `:load-module` /
8712 // wire-form `load-module`) is now fully lifted into caixa-core
8713 // through paired `M2_UPGRADE_INSTRUCTION_KIND_*` +
8714 // `M2_UPGRADE_INSTRUCTION_WIRE_*` const families, so a per-
8715 // consumer rebrand at either axis lands at exactly one edit
8716 // site and every downstream projection picks it up by
8717 // construction.
8718 //
8719 // Fail-before-pass-after locally verified by mutating
8720 // `UpgradeInstruction::as_str`'s `Self::Purge` arm to return
8721 // `"purge-drift"` — this pin fires as expected; restoring
8722 // passes.
8723 let cases: &[(UpgradeInstruction, &'static str)] = &[
8724 (
8725 UpgradeInstruction::LoadModule { module: "x".into() },
8726 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_LOAD_MODULE,
8727 ),
8728 (
8729 UpgradeInstruction::StateChange {
8730 script: PathBuf::from("lib/m.lisp"),
8731 },
8732 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_STATE_CHANGE,
8733 ),
8734 (
8735 UpgradeInstruction::SoftPurge {
8736 module: "x-old".into(),
8737 },
8738 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_SOFT_PURGE,
8739 ),
8740 (
8741 UpgradeInstruction::Purge {
8742 module: "x-old".into(),
8743 },
8744 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_PURGE,
8745 ),
8746 (
8747 UpgradeInstruction::Restart,
8748 crate::render::M2_UPGRADE_INSTRUCTION_WIRE_RESTART,
8749 ),
8750 ];
8751 for (instr, expected) in cases {
8752 assert_eq!(
8753 instr.as_str(),
8754 *expected,
8755 "UpgradeInstruction::as_str on {instr:?} must route through the lifted \
8756 M2_UPGRADE_INSTRUCTION_WIRE_* const (expected {expected:?})",
8757 );
8758 assert!(
8759 !expected.starts_with(':'),
8760 "M2_UPGRADE_INSTRUCTION_WIRE_* entry {expected:?} must \
8761 not open with a `:` prefix — a bare `:` -prefixed entry \
8762 would collide the wire-form axis with the peer tatara-\
8763 lisp author-surface form the M2_UPGRADE_INSTRUCTION_KIND_* \
8764 family carries",
8765 );
8766 }
8767 }
8768
8769 #[test]
8770 fn upgrade_instruction_lisp_form_return_is_static_str_stashable_in_program_lifetime_position() {
8771 // Return-lifetime pin on the substrate primitive: because
8772 // [`UpgradeInstruction::lisp_form`] returns `&'static str`
8773 // (threaded verbatim from the paired
8774 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] `pub const`
8775 // roster's program-lifetime storage), the label survives
8776 // dropping the borrow through `self` — a downstream logger
8777 // that stashes the tag in a `&'static`-bounded position
8778 // (a `HashMap<&'static str, _>` key, a slice-of-`&'static str`
8779 // accept-set, a static formatter's `%s` argument) reads it
8780 // without re-borrowing through the instruction reference. A
8781 // future refactor that accidentally narrows the return to
8782 // `&str` (lifetime-bound to `&self`) — say by projecting through
8783 // an owned `String` intermediate — would fail this compile-time
8784 // pin at build time far from the runtime-side lifetime
8785 // regression at every downstream `&'static str` consumer. Peer
8786 // pin discipline the sibling
8787 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] const roster's
8788 // `pub const _: &str = "..."` shape already carries at the
8789 // paired wire-form axis.
8790 //
8791 // The pin fires by taking the label from an instruction that
8792 // goes out of scope before the label is read — if
8793 // `lisp_form` returned a `&str` tied to `&self`, this would
8794 // fail to compile with "borrowed value does not live long
8795 // enough". Fail-before-pass-after locally verified: narrowing
8796 // the signature to `fn lisp_form(&self) -> &str { … }`
8797 // reproduces the compile error.
8798 fn stash_label_as_static(instr: &UpgradeInstruction) -> &'static str {
8799 instr.lisp_form()
8800 }
8801 let label = {
8802 let instr = UpgradeInstruction::LoadModule {
8803 module: "ephemeral".into(),
8804 };
8805 stash_label_as_static(&instr)
8806 // instr drops here; label must survive
8807 };
8808 assert_eq!(
8809 label,
8810 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
8811 "the &'static str return must survive the borrowed \
8812 UpgradeInstruction going out of scope — a lifetime narrowing \
8813 to &str would fail this pin at build time",
8814 );
8815 }
8816
8817 #[test]
8818 fn upgrade_instruction_lisp_form_is_pub_const_fn_usable_in_const_position() {
8819 // Const-position pin on the substrate primitive: because
8820 // [`UpgradeInstruction::lisp_form`] is `pub const fn`, downstream
8821 // consumers can call it in `const` contexts — a `const`
8822 // declaration threading the label through, a `static` lookup
8823 // table pre-computed at compile time, a `match` arm's
8824 // `const`-eligible branch label. `pub` matters here: a
8825 // `pub(crate) const fn` would compile in-crate const contexts
8826 // but no external caixa-<target> renderer or feira verb could
8827 // reach the projection in a const context. Fail-before-pass-
8828 // after locally verified: reverting the visibility to
8829 // `pub(crate) const fn` (or removing `pub`) makes this pin
8830 // fail to compile at the const-context call site below.
8831 const RESTART_LABEL: &str = UpgradeInstruction::Restart.lisp_form();
8832 assert_eq!(
8833 RESTART_LABEL,
8834 crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART,
8835 "const-position dispatch on Restart must yield the lifted \
8836 M2_UPGRADE_INSTRUCTION_KIND_RESTART tag verbatim",
8837 );
8838 }
8839
8840 #[test]
8841 fn upgrade_from_entry_instructions_returns_instructions_slice_byte_equal_across_permutations() {
8842 // The canonical per-`:upgrade-from :instructions` OTP-appup
8843 // migration-instruction-list slice-shape pin:
8844 // [`UpgradeFromEntry::instructions`] must return the
8845 // `:instructions` typed `Vec<UpgradeInstruction>` verbatim as
8846 // a `&[UpgradeInstruction]` slice-view over the same backing
8847 // buffer the raw `self.instructions.as_slice()` field access
8848 // borrows from, byte-equal across every representative fixture
8849 // in the accept-set — the empty slice (the "no-op upgrade" /
8850 // metadata-only sentinel the [`UpgradeFromEntry::instructions`]
8851 // field's own docstring names), the singleton slice on every
8852 // variant of the [`UpgradeInstruction`] arm-space
8853 // (`LoadModule` / `StateChange` / `SoftPurge` / `Purge` /
8854 // `Restart` — the five OTP-appup runtime-primitive variants),
8855 // and multi-instruction cohorts (the canonical
8856 // `LoadModule → StateChange → SoftPurge` OTP two-phase code-
8857 // load + state-migration triad the module doc names as the
8858 // "runs the instructions in order" example).
8859 //
8860 // Pins against a future silent detour that returned
8861 // `&Vec<UpgradeInstruction>` (which would type-check but leak
8862 // the storage-side `Vec`'s grow/push/reserve surface no
8863 // consumer of the typed view reaches for), a fresh-allocated
8864 // `Vec<UpgradeInstruction>` copy (which would type-check via
8865 // a coercion but silently break every downstream caller that
8866 // relied on the slice sharing the backing buffer's identity),
8867 // or an out-of-order or length-drifted projection (which
8868 // would silently split the paired within-entry cross-
8869 // instruction ordering gates' inputs from the peer per-
8870 // instruction shape-check loop's input, one seven-gate cohort
8871 // silently drifting from the peer gate's actual traversal
8872 // input).
8873 //
8874 // Peer of the sibling
8875 // `aplicacao_spec_contratos_returns_contratos_slice_byte_equal_across_permutations`
8876 // (0dcc926) `&[WitContract]` byte-equal pin on the M3 per-
8877 // `:contratos` edge-list axis, extended onto the M2 per-
8878 // `:upgrade-from :instructions` migration-instruction-list
8879 // axis — the fifth `&[T]`-return byte-equal pin, closing the
8880 // last unlifted `Vec`-carry axis on any M2 or M3 typed slot.
8881 let fixtures: Vec<Vec<UpgradeInstruction>> = vec![
8882 Vec::new(),
8883 vec![UpgradeInstruction::LoadModule { module: "x".into() }],
8884 vec![UpgradeInstruction::StateChange {
8885 script: PathBuf::from("lib/m.lisp"),
8886 }],
8887 vec![UpgradeInstruction::SoftPurge {
8888 module: "x-old".into(),
8889 }],
8890 vec![UpgradeInstruction::Purge {
8891 module: "x-old".into(),
8892 }],
8893 vec![UpgradeInstruction::Restart],
8894 vec![
8895 UpgradeInstruction::LoadModule { module: "x".into() },
8896 UpgradeInstruction::StateChange {
8897 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8898 },
8899 UpgradeInstruction::SoftPurge {
8900 module: "x-old".into(),
8901 },
8902 ],
8903 ];
8904 for instructions in fixtures {
8905 let e = UpgradeFromEntry {
8906 from: "0.1.0".into(),
8907 instructions: instructions.clone(),
8908 };
8909 assert_eq!(
8910 e.instructions(),
8911 e.instructions.as_slice(),
8912 "UpgradeFromEntry::instructions must project the raw \
8913 `:instructions` `Vec<UpgradeInstruction>` verbatim as a \
8914 `&[UpgradeInstruction]` slice-view over the same backing buffer \
8915 (fixture: {instructions:?})",
8916 );
8917 assert_eq!(
8918 e.instructions().len(),
8919 instructions.len(),
8920 "UpgradeFromEntry::instructions length must match the raw \
8921 `:instructions` `Vec<UpgradeInstruction>` length (fixture: {instructions:?})",
8922 );
8923 }
8924 }
8925
8926 #[test]
8927 fn validate_reads_through_lifted_instructions_accessor() {
8928 // Three-consumer coherence pin on the lifted
8929 // [`UpgradeFromEntry::instructions`] slice-return accessor:
8930 // exercises three of the nine paired production consumers of
8931 // the per-`:upgrade-from :instructions` OTP-appup migration-
8932 // instruction-list surface through end-to-end validate() paths
8933 // that require the accessor to reach each of the fixture's
8934 // instructions.
8935 //
8936 // (1) The per-instruction shape-check fan-out
8937 // ([`UpgradeFromEntry::validate`]'s `for instr in
8938 // self.instructions()` loop): pass the well-formed load →
8939 // state-change → soft-purge triad — `validate()` must accept
8940 // it, which requires the accessor to project every entry so
8941 // each `instr.validate()` fires.
8942 //
8943 // (2) The within-entry state-change-ordering gate
8944 // ([`Self::validate_state_change_ordering`]): pass a
8945 // `((:state-change …))` singleton — `validate()` must return
8946 // [`UpgradeError::StateChangeWithoutPriorLoad`], which
8947 // requires the accessor to reach the state-change so the
8948 // no-prior-load probe fires.
8949 //
8950 // (3) The within-entry per-module cleanup-singularity gate
8951 // ([`Self::validate_cleanup_singularity`]): pass a
8952 // `((:load-module "x") (:soft-purge "x-old") (:soft-purge
8953 // "x-old"))` cohort — `validate()` must return
8954 // [`UpgradeError::DuplicateCleanup`], which requires the
8955 // accessor to iterate the whole list so the second `SoftPurge`
8956 // matches the first via the `seen` set.
8957 //
8958 // Peer of the sibling
8959 // `validate_reads_through_lifted_contratos_accessor` (0dcc926)
8960 // three-consumer coherence pin on the M3 per-`:contratos`
8961 // edge-list axis, extended onto the M2 per-`:upgrade-from
8962 // :instructions` migration-instruction-list axis.
8963
8964 // (1) accept the well-formed OTP two-phase code-load triad
8965 let well_formed = entry(
8966 "0.1.0",
8967 vec![
8968 UpgradeInstruction::LoadModule { module: "x".into() },
8969 UpgradeInstruction::StateChange {
8970 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
8971 },
8972 UpgradeInstruction::SoftPurge {
8973 module: "x-old".into(),
8974 },
8975 ],
8976 );
8977 assert!(
8978 well_formed.validate().is_ok(),
8979 "well-formed `LoadModule → StateChange → SoftPurge` triad must accept — \
8980 the per-instruction shape-check fan-out requires the accessor to reach every entry"
8981 );
8982
8983 // (2) refuse a `((:state-change …))` singleton — the
8984 // state-change-without-prior-load gate must fire, which
8985 // requires the accessor to reach the single instruction.
8986 let no_prior_load = entry(
8987 "0.1.0",
8988 vec![UpgradeInstruction::StateChange {
8989 script: PathBuf::from("lib/m.lisp"),
8990 }],
8991 );
8992 match no_prior_load.validate() {
8993 Err(UpgradeError::StateChangeWithoutPriorLoad { .. }) => {}
8994 other => panic!(
8995 "expected StateChangeWithoutPriorLoad on a `((:state-change …))` singleton \
8996 — the within-entry state-change-ordering gate must reach the single \
8997 instruction through the lifted accessor; got: {other:?}"
8998 ),
8999 }
9000
9001 // (3) refuse a `((:load-module "x") (:soft-purge "x-old")
9002 // (:soft-purge "x-old"))` cohort — the per-module cleanup-
9003 // singularity gate must fire on the second `SoftPurge`, which
9004 // requires the accessor to iterate the whole list.
9005 let duplicate_cleanup = entry(
9006 "0.1.0",
9007 vec![
9008 UpgradeInstruction::LoadModule { module: "x".into() },
9009 UpgradeInstruction::SoftPurge {
9010 module: "x-old".into(),
9011 },
9012 UpgradeInstruction::SoftPurge {
9013 module: "x-old".into(),
9014 },
9015 ],
9016 );
9017 match duplicate_cleanup.validate() {
9018 Err(UpgradeError::DuplicateCleanup { module, .. }) => {
9019 assert_eq!(
9020 module, "x-old",
9021 "DuplicateCleanup must name the colliding module `x-old` — the per-module \
9022 cleanup-singularity gate must iterate through the lifted accessor to \
9023 match the second SoftPurge against the first via the `seen` set"
9024 );
9025 }
9026 other => panic!(
9027 "expected DuplicateCleanup on `((:load-module x) (:soft-purge x-old) \
9028 (:soft-purge x-old))` — the within-entry cleanup-singularity gate must \
9029 iterate the whole list through the lifted accessor; got: {other:?}"
9030 ),
9031 }
9032
9033 // Path::new suppresses the unused-import warning if the
9034 // outer module trims `use std::path::Path;` in a future edit.
9035 let _ = Path::new("lib/m.lisp");
9036 }
9037
9038 // Per-variant equivalence pins for the [`upgrade_from_script_ctors!`]
9039 // macro definition (see the paired doc-block above the macro
9040 // definition) — every generated `<ctor>(from: &str, script: &Path)
9041 // -> Self` constructor folds the uniform `Self::<Variant> { from:
9042 // from.to_string(), script: script.to_path_buf() }` two-field
9043 // struct-literal onto one substrate primitive. The three per-variant
9044 // equivalence pins below (fail-before-pass-after by construction — a
9045 // byte-mismatched macro arm would trip its equivalence pin first)
9046 // lock each generated constructor to its struct-literal peer under
9047 // `PartialEq`, so every wire-up in
9048 // [`UpgradeFromEntry::validate_state_change_ordering`],
9049 // [`UpgradeFromEntry::validate_state_change_uniqueness`], and
9050 // [`validate_state_change_on_state_change_callback`] on that
9051 // variant produces a byte-equal `UpgradeError` to the pre-lift
9052 // open-coded struct-literal. The cross-axis pin that follows
9053 // (non-default `(from, script)` pair) routes both constructor input
9054 // axes through `.to_string()` / `.to_path_buf()`, so the fold does
9055 // not silently collapse onto a fixed `from` / `script` value.
9056 //
9057 // Peer of the sibling `empty_child_version_ctor_matches_struct_
9058 // literal_wrap` / `duplicate_child_caixa_ctor_matches_struct_
9059 // literal_wrap` / `child_supervises_self_ctor_matches_struct_
9060 // literal_wrap` / `supervisor_caixa_only_ctors_route_caixa_through_
9061 // to_string` equivalence + cross-axis pins the sibling
9062 // [`crate::supervisor::supervisor_caixa_only_ctors!`] family (db09650)
9063 // established on the peer `SupervisorError` envelope; extended
9064 // here onto the `UpgradeError` `{ from: String, script: PathBuf }`
9065 // two-slot envelope so every substrate-primitive ctor family in
9066 // caixa-core guarantees the same-shape fold every wire-up on the
9067 // family reads through one dispatch.
9068
9069 #[test]
9070 fn state_change_without_prior_load_ctor_matches_struct_literal_wrap() {
9071 let from = "0.1.0";
9072 let script = Path::new("lib/migrations/v01-to-v02.lisp");
9073 assert_eq!(
9074 UpgradeError::state_change_without_prior_load(from, script),
9075 UpgradeError::StateChangeWithoutPriorLoad {
9076 from: from.to_string(),
9077 script: script.to_path_buf(),
9078 },
9079 "generated state_change_without_prior_load ctor must produce \
9080 byte-equal UpgradeError to the open-coded struct-literal \
9081 wrap on the same (&str, &Path) fixture",
9082 );
9083 }
9084
9085 #[test]
9086 fn duplicate_state_change_ctor_matches_struct_literal_wrap() {
9087 let from = "0.1.0";
9088 let script = Path::new("lib/migrations/v01-to-v02.lisp");
9089 assert_eq!(
9090 UpgradeError::duplicate_state_change(from, script),
9091 UpgradeError::DuplicateStateChange {
9092 from: from.to_string(),
9093 script: script.to_path_buf(),
9094 },
9095 "generated duplicate_state_change ctor must produce byte-equal \
9096 UpgradeError to the open-coded struct-literal wrap on the \
9097 same (&str, &Path) fixture",
9098 );
9099 }
9100
9101 #[test]
9102 fn state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap() {
9103 let from = "0.1.0";
9104 let script = Path::new("lib/migrations/v01-to-v02.lisp");
9105 assert_eq!(
9106 UpgradeError::state_change_without_on_state_change_callback(from, script),
9107 UpgradeError::StateChangeWithoutOnStateChangeCallback {
9108 from: from.to_string(),
9109 script: script.to_path_buf(),
9110 },
9111 "generated state_change_without_on_state_change_callback ctor \
9112 must produce byte-equal UpgradeError to the open-coded \
9113 struct-literal wrap on the same (&str, &Path) fixture",
9114 );
9115 }
9116
9117 #[test]
9118 fn upgrade_from_script_ctors_route_from_and_script_verbatim() {
9119 // Cross-axis pin: sweep both constructor input axes (`from:
9120 // &str`, `script: &Path`) through non-default fixtures against
9121 // every generated arm in the [`upgrade_from_script_ctors!`]
9122 // macro, so any wrapper-side lowercase / trim / truncate /
9123 // re-order / fixed-path substitution on the two-field
9124 // construction surfaces here rather than at a downstream
9125 // diagnostic-shape mismatch. Also exercises the `&Path`
9126 // parameter under both `&Path` (direct `Path::new`) and
9127 // `&PathBuf` (via Deref coercion), matching the two shapes the
9128 // three wire-up sites thread through — the ordering /
9129 // callback-declaration gates hand a `&PathBuf` from
9130 // `instr.declared_path()`; the uniqueness gate hands a `&Path`
9131 // from `script.as_path()`. Peer of the sibling
9132 // `supervisor_caixa_only_ctors_route_caixa_through_to_string`
9133 // cross-axis pin on the peer `SupervisorError` `{ caixa:
9134 // String }` envelope.
9135 let from = "1.2.3-rc.1";
9136 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9137 let script_ref: &Path = script_owned.as_path();
9138 for script in [script_ref, &script_owned as &Path] {
9139 assert_eq!(
9140 UpgradeError::state_change_without_prior_load(from, script),
9141 UpgradeError::StateChangeWithoutPriorLoad {
9142 from: from.to_string(),
9143 script: script.to_path_buf(),
9144 },
9145 );
9146 assert_eq!(
9147 UpgradeError::duplicate_state_change(from, script),
9148 UpgradeError::DuplicateStateChange {
9149 from: from.to_string(),
9150 script: script.to_path_buf(),
9151 },
9152 );
9153 assert_eq!(
9154 UpgradeError::state_change_without_on_state_change_callback(from, script),
9155 UpgradeError::StateChangeWithoutOnStateChangeCallback {
9156 from: from.to_string(),
9157 script: script.to_path_buf(),
9158 },
9159 );
9160 }
9161 }
9162
9163 // Per-variant equivalence pins for the [`upgrade_script_only_ctors!`]
9164 // macro definition (see the paired doc-block above the macro
9165 // definition) — every generated `<ctor>(script: &Path) -> Self`
9166 // constructor folds the uniform `Self::<Variant> { script:
9167 // script.to_path_buf() }` one-field struct-literal onto one substrate
9168 // primitive. The three per-variant equivalence pins below
9169 // (fail-before-pass-after by construction — a byte-mismatched macro
9170 // arm would trip its equivalence pin first) lock each generated
9171 // constructor to its struct-literal peer under `PartialEq`, so every
9172 // closure passed to [`crate::render::require_sandboxed_lisp_path`]
9173 // at [`UpgradeInstruction::validate`] on that variant produces a
9174 // byte-equal `UpgradeError` to the pre-lift open-coded
9175 // struct-literal. The cross-axis pin that follows (non-default
9176 // `script` path, both `&Path` and `&PathBuf` shapes) routes the
9177 // constructor input axis through `.to_path_buf()`, so the fold does
9178 // not silently collapse onto a fixed `script` value or drop the
9179 // Deref-coercion arm the wire-up sites depend on.
9180 //
9181 // Peer of the sibling
9182 // `state_change_without_prior_load_ctor_matches_struct_literal_wrap`
9183 // / `duplicate_state_change_ctor_matches_struct_literal_wrap` /
9184 // `state_change_without_on_state_change_callback_ctor_matches_struct_literal_wrap`
9185 // / `upgrade_from_script_ctors_route_from_and_script_verbatim`
9186 // equivalence + cross-axis pins the sibling
9187 // [`upgrade_from_script_ctors!`] family (8e67041) established on the
9188 // peer `{ from: String, script: PathBuf }` two-slot envelope shape;
9189 // extended here onto the `{ script: PathBuf }` one-slot envelope
9190 // shape so every substrate-primitive ctor family on `UpgradeError`
9191 // guarantees the same-shape fold every wire-up on the family reads
9192 // through one dispatch.
9193
9194 #[test]
9195 fn absolute_script_ctor_matches_struct_literal_wrap() {
9196 let script = Path::new("/etc/nope.lisp");
9197 assert_eq!(
9198 UpgradeError::absolute_script(script),
9199 UpgradeError::AbsoluteScript {
9200 script: script.to_path_buf(),
9201 },
9202 "generated absolute_script ctor must produce byte-equal \
9203 UpgradeError to the open-coded struct-literal wrap on the \
9204 same &Path fixture",
9205 );
9206 }
9207
9208 #[test]
9209 fn parent_escape_script_ctor_matches_struct_literal_wrap() {
9210 let script = Path::new("../oops.lisp");
9211 assert_eq!(
9212 UpgradeError::parent_escape_script(script),
9213 UpgradeError::ParentEscapeScript {
9214 script: script.to_path_buf(),
9215 },
9216 "generated parent_escape_script ctor must produce byte-equal \
9217 UpgradeError to the open-coded struct-literal wrap on the \
9218 same &Path fixture",
9219 );
9220 }
9221
9222 #[test]
9223 fn non_lisp_extension_script_ctor_matches_struct_literal_wrap() {
9224 let script = Path::new("lib/migrations.rs");
9225 assert_eq!(
9226 UpgradeError::non_lisp_extension_script(script),
9227 UpgradeError::NonLispExtensionScript {
9228 script: script.to_path_buf(),
9229 },
9230 "generated non_lisp_extension_script ctor must produce \
9231 byte-equal UpgradeError to the open-coded struct-literal \
9232 wrap on the same &Path fixture",
9233 );
9234 }
9235
9236 #[test]
9237 fn upgrade_script_only_ctors_route_script_through_to_path_buf() {
9238 // Cross-axis pin: sweep the constructor input axis (`script:
9239 // &Path`) through a non-default fixture against every generated
9240 // arm in the [`upgrade_script_only_ctors!`] macro, so any
9241 // wrapper-side lowercase / trim / truncate / re-order /
9242 // fixed-path substitution on the one-field construction
9243 // surfaces here rather than at a downstream diagnostic-shape
9244 // mismatch. Also exercises the `&Path` parameter under both
9245 // `&Path` (direct `Path::new`) and `&PathBuf` (via Deref
9246 // coercion), matching the shape the three closures at
9247 // [`UpgradeInstruction::validate`] thread through — the
9248 // wire-ups hand a `&PathBuf` from `instr.declared_path()` into
9249 // each closure, so the Deref-coercion arm the ctor advertises
9250 // must actually route through `.to_path_buf()` and not
9251 // silently swap in a fixed path.
9252 //
9253 // Peer of the sibling
9254 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9255 // cross-axis pin on the sibling `{ from, script }` two-slot
9256 // envelope shape.
9257 let script_owned = PathBuf::from("lib/migrations/v02-to-v03.lisp");
9258 let script_ref: &Path = script_owned.as_path();
9259 for script in [script_ref, &script_owned as &Path] {
9260 assert_eq!(
9261 UpgradeError::absolute_script(script),
9262 UpgradeError::AbsoluteScript {
9263 script: script.to_path_buf(),
9264 },
9265 );
9266 assert_eq!(
9267 UpgradeError::parent_escape_script(script),
9268 UpgradeError::ParentEscapeScript {
9269 script: script.to_path_buf(),
9270 },
9271 );
9272 assert_eq!(
9273 UpgradeError::non_lisp_extension_script(script),
9274 UpgradeError::NonLispExtensionScript {
9275 script: script.to_path_buf(),
9276 },
9277 );
9278 }
9279 }
9280
9281 // Per-variant equivalence pins for the [`upgrade_from_axis_ctors!`]
9282 // macro definition (see the paired doc-block above the macro
9283 // definition) — every generated `<ctor>(from: &str, <axis>: &str)
9284 // -> Self` constructor folds the uniform `Self::<Variant> { from:
9285 // from.to_string(), <axis>: <axis>.to_string() }` two-field
9286 // struct-literal onto one substrate primitive. The three per-variant
9287 // equivalence pins below (fail-before-pass-after by construction — a
9288 // byte-mismatched macro arm would trip its equivalence pin first)
9289 // lock each generated constructor to its struct-literal peer under
9290 // `PartialEq`, so every wire-up in
9291 // [`UpgradeFromEntry::validate`]'s `:from` SemVer-2 parse gate,
9292 // [`UpgradeFromEntry::validate_load_singularity`]'s per-module dedup
9293 // gate, and [`validate_upgrade_from_against_versao`]'s per-entry
9294 // `:from < :versao` gate on that variant produces a byte-equal
9295 // `UpgradeError` to the pre-lift open-coded struct-literal. The
9296 // cross-axis pin that follows (distinct-per-axis `from` / `<axis>`
9297 // pair) routes both constructor input axes through `.to_string()`
9298 // in declared field order, so the fold does not silently swap `from`
9299 // and the middle `<axis>` field, or silently collapse onto a fixed
9300 // `from` / `<axis>` value on any one variant.
9301 //
9302 // Peer of the sibling `state_change_without_prior_load_ctor_matches_
9303 // struct_literal_wrap` / `duplicate_state_change_ctor_matches_
9304 // struct_literal_wrap` / `state_change_without_on_state_change_
9305 // callback_ctor_matches_struct_literal_wrap` / `upgrade_from_script_
9306 // ctors_route_from_and_script_verbatim` equivalence + cross-axis
9307 // pins the sibling [`upgrade_from_script_ctors!`] family (8e67041)
9308 // established on the sibling `{ from: String, script: PathBuf }`
9309 // two-slot envelope shape; extended here onto the `{ from: String,
9310 // <axis>: String }` two-slot envelope shape so every substrate-
9311 // primitive ctor family on `UpgradeError` guarantees the same-shape
9312 // fold every wire-up on the family reads through one dispatch. Also
9313 // mirror-symmetric peer of the sibling
9314 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9315 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome: String,
9316 // <axis>: String }` two-slot envelope shape.
9317
9318 #[test]
9319 fn from_invalid_ctor_matches_struct_literal_wrap() {
9320 let from = "not-a-semver";
9321 let reason = "unexpected character '-' at position 3";
9322 assert_eq!(
9323 UpgradeError::from_invalid(from, reason),
9324 UpgradeError::FromInvalid {
9325 from: from.to_string(),
9326 reason: reason.to_string(),
9327 },
9328 "generated from_invalid ctor must produce byte-equal \
9329 UpgradeError to the open-coded struct-literal wrap on the \
9330 same (&str, &str) fixture",
9331 );
9332 }
9333
9334 #[test]
9335 fn from_not_before_versao_ctor_matches_struct_literal_wrap() {
9336 let from = "0.2.0";
9337 let versao = "0.1.0";
9338 assert_eq!(
9339 UpgradeError::from_not_before_versao(from, versao),
9340 UpgradeError::FromNotBeforeVersao {
9341 from: from.to_string(),
9342 versao: versao.to_string(),
9343 },
9344 "generated from_not_before_versao ctor must produce byte-equal \
9345 UpgradeError to the open-coded struct-literal wrap on the \
9346 same (&str, &str) fixture",
9347 );
9348 }
9349
9350 #[test]
9351 fn duplicate_load_module_ctor_matches_struct_literal_wrap() {
9352 let from = "0.1.0";
9353 let module = "hello-rio";
9354 assert_eq!(
9355 UpgradeError::duplicate_load_module(from, module),
9356 UpgradeError::DuplicateLoadModule {
9357 from: from.to_string(),
9358 module: module.to_string(),
9359 },
9360 "generated duplicate_load_module ctor must produce byte-equal \
9361 UpgradeError to the open-coded struct-literal wrap on the \
9362 same (&str, &str) fixture",
9363 );
9364 }
9365
9366 #[test]
9367 fn upgrade_from_axis_ctors_route_from_and_axis_through_to_string_uniformly() {
9368 // Cross-axis routing pin: sweep the two constructor input axes
9369 // (`from: &str`, `<axis>: &str`) through distinct-per-axis
9370 // fixtures against every generated arm in the
9371 // [`upgrade_from_axis_ctors!`] macro, so any wrapper-side
9372 // lowercase / trim / truncate at codegen time — a silent field
9373 // swap between `from` and the middle `<axis>` field, or a
9374 // `<axis>` axis silently rerouted through the wrong field on any
9375 // one variant — surfaces here rather than at a downstream
9376 // diagnostic-shape mismatch. Peer of the sibling
9377 // `upgrade_from_script_ctors_route_from_and_script_verbatim`
9378 // (8e67041) cross-axis pin on the same envelope's sibling
9379 // `{ from: String, script: PathBuf }` two-slot family, and of the
9380 // sibling
9381 // `dep_nome_axis_ctors_route_nome_and_axis_through_to_string_uniformly`
9382 // (7f7c950) cross-axis pin on the peer `DepError` `{ nome:
9383 // String, <axis>: String }` two-slot envelope. Distinct-per-
9384 // axis fixtures rule out any two-axis swap (`from` ↔ `<axis>`)
9385 // that would still pass a same-fixture-per-axis pin. Both
9386 // `&str`-literal and `&String` (via Deref coercion) carriers
9387 // are exercised because the three wire-up sites hand a mix of
9388 // both (the `from_invalid` site hands `&e.to_string()` — an
9389 // owned `String` — for `reason`; the `duplicate_load_module`
9390 // site hands a `&str` slice for `module`; the
9391 // `from_not_before_versao` site hands the caller-supplied
9392 // `versao: &str` for `versao`).
9393 let from = "0.1.0";
9394 let axis = "distinct-axis-value";
9395 let from_owned: String = from.to_string();
9396 let axis_owned: String = axis.to_string();
9397 for (from_in, axis_in) in [(from, axis), (from_owned.as_str(), axis_owned.as_str())] {
9398 assert_eq!(
9399 UpgradeError::from_invalid(from_in, axis_in),
9400 UpgradeError::FromInvalid {
9401 from: from.to_string(),
9402 reason: axis.to_string(),
9403 },
9404 "from_invalid must route `from` → `from`, `axis` → `reason` \
9405 in declared field order",
9406 );
9407 assert_eq!(
9408 UpgradeError::from_not_before_versao(from_in, axis_in),
9409 UpgradeError::FromNotBeforeVersao {
9410 from: from.to_string(),
9411 versao: axis.to_string(),
9412 },
9413 "from_not_before_versao must route `from` → `from`, \
9414 `axis` → `versao` in declared field order",
9415 );
9416 assert_eq!(
9417 UpgradeError::duplicate_load_module(from_in, axis_in),
9418 UpgradeError::DuplicateLoadModule {
9419 from: from.to_string(),
9420 module: axis.to_string(),
9421 },
9422 "duplicate_load_module must route `from` → `from`, \
9423 `axis` → `module` in declared field order",
9424 );
9425 }
9426 }
9427
9428 // Per-variant equivalence + accessor-fidelity + cross-axis pins for
9429 // the standalone [`UpgradeError::duplicate_from`] inherent ctor (see
9430 // the paired doc-block above the ctor definition) — the fold of the
9431 // last open-coded one-slot `{ from: entry.prior_versao().to_string() }`
9432 // struct-literal inside [`validate_upgrade_from`]'s cross-entry
9433 // duplicate gate onto one substrate primitive on the
9434 // [`UpgradeError`] envelope, projecting through the paired
9435 // [`UpgradeFromEntry::prior_versao`] scalar accessor on the substrate
9436 // primitive. A byte-mismatched ctor body would trip the equivalence
9437 // pin first, ahead of any downstream diagnostic-shape drift.
9438 //
9439 // Peer of the sibling standalone-ctor equivalence pins on the peer
9440 // one-off variants across caixa-core:
9441 // `contrato_self_loop_ctor_matches_struct_literal_wrap` (b30edfe) on
9442 // the paired two-slot `{ caixa, wit }` [`AplicacaoError`] envelope,
9443 // `contrato_endpoint_not_absolute_ctor_matches_struct_literal_wrap`
9444 // (cdf1a2c) on the paired three-slot `{ de, para, endpoint }`
9445 // envelope, the sibling
9446 // `contrato_endpoint_empty_ctor_matches_struct_literal_wrap` +
9447 // `contrato_endpoint_invalid_ctor_matches_struct_literal_wrap` pins,
9448 // and the sibling `entrada_host_invalid_ctor_matches_struct_literal_wrap`
9449 // pin on the sibling standalone `{ host, reason }` two-slot ctor.
9450
9451 #[test]
9452 fn duplicate_from_ctor_matches_struct_literal_wrap() {
9453 // Equivalence pin: the ctor produces byte-equal
9454 // `UpgradeError::DuplicateFrom` to the pre-lift open-coded
9455 // struct-literal that read the same `from` field through
9456 // [`UpgradeFromEntry::prior_versao`]. Guards any future field-
9457 // addition / reordering / string-conversion tweak on the
9458 // variant. Same equivalence-pin shape as the sibling
9459 // `contrato_self_loop_ctor_matches_struct_literal_wrap`
9460 // (b30edfe) on the paired two-slot `{ caixa, wit }`
9461 // envelope inside `impl AplicacaoSpec`.
9462 let entry = entry("0.1.0", vec![UpgradeInstruction::Restart]);
9463 let lifted = UpgradeError::duplicate_from(&entry);
9464 let struct_literal = UpgradeError::DuplicateFrom {
9465 from: entry.prior_versao().to_string(),
9466 };
9467 assert_eq!(lifted, struct_literal);
9468 }
9469
9470 #[test]
9471 fn duplicate_from_ctor_routes_prior_versao_through_verbatim() {
9472 // Routing pin sweeping a non-default `:from` value
9473 // (`"1.2.3-rc.4+build.5"` — a full SemVer-2 identity with pre-
9474 // release and build metadata) through the paired
9475 // [`UpgradeFromEntry::prior_versao`] scalar accessor axis so any
9476 // wrapper-side lowercase / trim / truncate on the one-field
9477 // construction surfaces here rather than at a downstream
9478 // diagnostic-shape drift. Peer of the sibling
9479 // `contrato_self_loop_ctor_routes_source_and_world_ref_through_verbatim`
9480 // (b30edfe) routing pin on the sibling two-slot envelope.
9481 //
9482 // The pre-release + build-metadata carrier value is deliberately
9483 // chosen to exercise the `.to_string()` path against a `:from`
9484 // shape [`semver::Version::PartialEq`] treats as distinct from
9485 // its release-only sibling (per the
9486 // `validate_upgrade_from_treats_pre_release_as_distinct` and
9487 // build-metadata-tightening-note doc-block on
9488 // [`validate_upgrade_from`]) — so any silent normalization at
9489 // the ctor body (a `.trim_matches('+')` / `.split_once('+')` /
9490 // `.split_once('-')` collapse) would drop bytes from the
9491 // rendered diagnostic and surface here.
9492 let entry = entry("1.2.3-rc.4+build.5", vec![UpgradeInstruction::Restart]);
9493 let built = UpgradeError::duplicate_from(&entry);
9494 match built {
9495 UpgradeError::DuplicateFrom { from } => {
9496 assert_eq!(
9497 from, "1.2.3-rc.4+build.5",
9498 "from slot must thread UpgradeFromEntry::prior_versao() verbatim, \
9499 preserving pre-release + build-metadata bytes"
9500 );
9501 }
9502 other => panic!("expected DuplicateFrom, got {other:?}"),
9503 }
9504 }
9505
9506 #[test]
9507 fn duplicate_from_ctor_projects_prior_versao_scalar_accessor() {
9508 // Accessor-fidelity pin: the ctor's `from` slot keys off the
9509 // [`UpgradeFromEntry::prior_versao`] scalar accessor (matching
9510 // the pre-lift open-coded body's field selection), not any
9511 // stringified rendering of the full entry (e.g. the
9512 // `impl Display for UpgradeFromEntry` output, if one were later
9513 // added, or a `format!("{:?}", entry)` debug dump). Pins the
9514 // projection axis so a silent swap at the ctor body — say, a
9515 // future refactor that projects through `entry.instructions()`
9516 // in shape (dropping the `:from` axis entirely) or through a
9517 // whole-entry `format!` — surfaces here rather than at a
9518 // downstream diagnostic mis-attribution far from the duplicate
9519 // gate's owner.
9520 //
9521 // A future consumer that constructs the ctor against a not-yet-
9522 // gated candidate entry (an M4 `mesh.pleme.io/v1alpha1/Caixa`
9523 // CR admission webhook re-checking a per-`:upgrade-from`-patched
9524 // candidate before the cross-entry duplicate gate re-fires, a
9525 // per-tenant per-`Caixa` overlay resolver rejecting a duplicate
9526 // `(:from …)` introduced by a cluster-local `:upgrade-from`
9527 // override) needs the pre-lift projection axis pinned.
9528 //
9529 // The fixture threads a distinctive `:from` (`"0.2.0-alpha.7"`)
9530 // paired with a distinctive multi-instruction sequence so a
9531 // silent swap that projects through the whole-entry rendering
9532 // instead of the paired scalar accessor would land debug bytes
9533 // from the `:instructions` list into the `from` slot and trip
9534 // the assertion here.
9535 let entry = entry(
9536 "0.2.0-alpha.7",
9537 vec![
9538 UpgradeInstruction::LoadModule {
9539 module: "distinctive-load-target".into(),
9540 },
9541 UpgradeInstruction::StateChange {
9542 script: PathBuf::from("lib/distinctive-migrate.lisp"),
9543 },
9544 UpgradeInstruction::Restart,
9545 ],
9546 );
9547 let built = UpgradeError::duplicate_from(&entry);
9548 match built {
9549 UpgradeError::DuplicateFrom { from } => {
9550 assert_eq!(
9551 from, "0.2.0-alpha.7",
9552 "from slot must project UpgradeFromEntry::prior_versao() \
9553 (not any whole-entry rendering)"
9554 );
9555 }
9556 other => panic!("expected DuplicateFrom, got {other:?}"),
9557 }
9558 }
9559
9560 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9561 // the standalone [`UpgradeError::purge_without_prior_load`] inherent
9562 // ctor (see the paired doc-block above the ctor definition) — the
9563 // fold of the last open-coded three-slot `{ from: String, kind:
9564 // &'static str, module: String }` struct-literal wire-up on
9565 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9566 // [`UpgradeFromEntry::validate_purge_ordering`]'s per-instruction
9567 // load-family sticky-latch dispatch onto one substrate primitive.
9568 // A byte-mismatched ctor body would trip the equivalence pin first,
9569 // ahead of any downstream diagnostic-shape drift.
9570 //
9571 // Peer of the sibling standalone-ctor equivalence + routing pins on
9572 // the sibling one-off variants across `UpgradeError`
9573 // (`duplicate_from_ctor_matches_struct_literal_wrap` /
9574 // `duplicate_from_ctor_routes_prior_versao_through_verbatim` /
9575 // `duplicate_from_ctor_projects_prior_versao_scalar_accessor` on
9576 // the paired one-slot `{ from: String }` envelope) and across
9577 // caixa-core (`contrato_endpoint_not_absolute_ctor_matches_struct_
9578 // literal_wrap` on the paired three-slot `{ de, para, endpoint:
9579 // String }` `AplicacaoError` envelope).
9580
9581 #[test]
9582 fn purge_without_prior_load_ctor_matches_struct_literal_wrap() {
9583 // Equivalence pin: the ctor produces byte-equal
9584 // `UpgradeError::PurgeWithoutPriorLoad` to the pre-lift
9585 // open-coded three-field struct-literal on the same `(&str,
9586 // &'static str, &str)` fixture. Guards any future field-
9587 // addition / reordering / string-conversion tweak on the
9588 // variant. Same equivalence-pin shape as the sibling
9589 // `duplicate_from_ctor_matches_struct_literal_wrap` (7e52aec)
9590 // on the peer one-slot `{ from: String }` envelope.
9591 let from = "0.1.0";
9592 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9593 let module = "hello-rio-old";
9594 assert_eq!(
9595 UpgradeError::purge_without_prior_load(from, kind, module),
9596 UpgradeError::PurgeWithoutPriorLoad {
9597 from: from.to_string(),
9598 kind,
9599 module: module.to_string(),
9600 },
9601 "generated purge_without_prior_load ctor must produce \
9602 byte-equal UpgradeError to the open-coded struct-literal \
9603 wrap on the same (&str, &'static str, &str) fixture",
9604 );
9605 }
9606
9607 #[test]
9608 fn purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim() {
9609 // Cross-axis routing pin: sweep the three constructor input
9610 // axes (`from: &str`, `kind: &'static str`, `module: &str`)
9611 // through distinct-per-axis fixtures across every cleanup-family
9612 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9613 // SemVer-2 `from` shapes (pre-release, build-metadata) + DNS-1123
9614 // module shapes (leaf, hyphenated, deeply-hyphenated) so any
9615 // wrapper-side lowercase / trim / truncate / silent axis-swap
9616 // (`from` ↔ `module`, `kind` misrouted onto `from`) on the
9617 // three-field construction surfaces at assert time rather than
9618 // at a downstream diagnostic consumer that reads the fields
9619 // back and gets a different value than the one it stored. Both
9620 // `&str`-literal and `&String` (via Deref coercion) carriers
9621 // are exercised for `from` / `module` because the sole wire-up
9622 // hands `self.prior_versao()` (a `&str` accessor) and
9623 // `instr.declared_module().expect(…)` (also a `&str`) — the
9624 // ctor must accept both shapes without a pre-conversion.
9625 let kinds: [&'static str; 2] = [
9626 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9627 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9628 ];
9629 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9630 let modules: [&str; 4] = ["x", "hello-rio-old", "cache-v2-ancient", "a-b-c-d-e-f"];
9631 for kind in kinds {
9632 for from in froms {
9633 for module in modules {
9634 let from_owned: String = from.to_string();
9635 let module_owned: String = module.to_string();
9636 for (from_in, module_in) in
9637 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9638 {
9639 assert_eq!(
9640 UpgradeError::purge_without_prior_load(from_in, kind, module_in),
9641 UpgradeError::PurgeWithoutPriorLoad {
9642 from: from.to_string(),
9643 kind,
9644 module: module.to_string(),
9645 },
9646 "purge_without_prior_load must route from → from, \
9647 kind → kind, module → module in declared field \
9648 order verbatim on ({from:?}, {kind:?}, {module:?})",
9649 );
9650 }
9651 }
9652 }
9653 }
9654 }
9655
9656 #[test]
9657 fn validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor() {
9658 // End-to-end wire-up pin: build an entry whose declared
9659 // `:instructions` list places a `:soft-purge` (and separately a
9660 // `:purge`) before any `:load-module` so
9661 // [`UpgradeFromEntry::validate_purge_ordering`]'s load-family
9662 // sticky-latch dispatch surfaces
9663 // `UpgradeError::PurgeWithoutPriorLoad`, then pin that the
9664 // observed `Err` byte-equals the substrate-primitive
9665 // [`UpgradeError::purge_without_prior_load`] ctor's output on
9666 // the same fixture. A future silent de-lift of the wire-up back
9667 // to the open-coded struct-literal (or a silent axis-swap on
9668 // the three-field construction at the wire-up site) trips at
9669 // caixa-core test time rather than at a downstream diagnostic
9670 // consumer far from the wire-up commit. Same end-to-end-wire-up
9671 // discipline as the sibling
9672 // `validate_upgrade_from_duplicate_diagnostic_arm_routes_through_duplicate_from_ctor`
9673 // on the peer cross-entry duplicate-`:from` gate; both key off
9674 // exactly one typed dispatch on the substrate primitive.
9675 let cases: [(&str, UpgradeInstruction, &'static str, &str); 2] = [
9676 (
9677 "0.1.0",
9678 UpgradeInstruction::SoftPurge {
9679 module: "hello-rio-old".into(),
9680 },
9681 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9682 "hello-rio-old",
9683 ),
9684 (
9685 "1.2.3-rc.1",
9686 UpgradeInstruction::Purge {
9687 module: "cache-v2-ancient".into(),
9688 },
9689 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9690 "cache-v2-ancient",
9691 ),
9692 ];
9693 for (from, instr, kind, module) in cases {
9694 let e = entry(from, vec![instr]);
9695 let observed = e.validate().unwrap_err();
9696 assert_eq!(
9697 observed,
9698 UpgradeError::purge_without_prior_load(from, kind, module),
9699 "validate_purge_ordering must route its refusal through \
9700 UpgradeError::purge_without_prior_load(from, kind, \
9701 module) on a bare-cleanup {kind:?} entry, byte-equal \
9702 to the pre-lift open-coded struct-literal wrap on the \
9703 same fixture",
9704 );
9705 }
9706 }
9707
9708 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9709 // the standalone [`UpgradeError::state_change_after_cleanup`]
9710 // inherent ctor (see the paired doc-block above the ctor
9711 // definition) — the fold of the last open-coded four-slot `{ from:
9712 // String, script: PathBuf, prior_cleanup_kind: &'static str,
9713 // prior_cleanup_module: String }` struct-literal wire-up on
9714 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9715 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9716 // migrate-family sticky-latch dispatch onto one substrate primitive.
9717 // A byte-mismatched ctor body would trip the equivalence pin first,
9718 // ahead of any downstream diagnostic-shape drift. Peer of the
9719 // sibling standalone-ctor equivalence + routing pins on the sibling
9720 // one-off variants across `UpgradeError`
9721 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` /
9722 // `purge_without_prior_load_ctor_routes_from_kind_and_module_through_verbatim`
9723 // / `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9724 // on the paired three-slot `{ from, kind, module }` envelope;
9725 // `duplicate_from_ctor_matches_struct_literal_wrap` on the paired
9726 // one-slot `{ from }` envelope).
9727
9728 #[test]
9729 fn state_change_after_cleanup_ctor_matches_struct_literal_wrap() {
9730 // Equivalence pin: the ctor produces byte-equal
9731 // `UpgradeError::StateChangeAfterCleanup` to the pre-lift
9732 // open-coded four-field struct-literal on the same `(&str,
9733 // &Path, &'static str, &str)` fixture. Guards any future
9734 // field-addition / reordering / string-conversion tweak on the
9735 // variant. Same equivalence-pin shape as the sibling
9736 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9737 // (9752da1) on the peer three-slot envelope.
9738 let from = "0.1.0";
9739 let script = Path::new("lib/m.lisp");
9740 let prior_cleanup_kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE;
9741 let prior_cleanup_module = "x-old";
9742 assert_eq!(
9743 UpgradeError::state_change_after_cleanup(
9744 from,
9745 script,
9746 prior_cleanup_kind,
9747 prior_cleanup_module,
9748 ),
9749 UpgradeError::StateChangeAfterCleanup {
9750 from: from.to_string(),
9751 script: script.to_path_buf(),
9752 prior_cleanup_kind,
9753 prior_cleanup_module: prior_cleanup_module.to_string(),
9754 },
9755 "generated state_change_after_cleanup ctor must produce \
9756 byte-equal UpgradeError to the open-coded struct-literal \
9757 wrap on the same (&str, &Path, &'static str, &str) fixture",
9758 );
9759 }
9760
9761 #[test]
9762 fn state_change_after_cleanup_ctor_routes_from_script_kind_and_module_through_verbatim() {
9763 // Cross-axis routing pin: sweep the four constructor input
9764 // axes (`from: &str`, `script: &Path`, `prior_cleanup_kind:
9765 // &'static str`, `prior_cleanup_module: &str`) through
9766 // distinct-per-axis fixtures across every cleanup-family
9767 // [`UpgradeInstruction::lisp_form`] variant + a boundary mix of
9768 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9769 // build-metadata, zero), sibling-`.lisp` script-path shapes
9770 // (leaf, nested, deeply-nested), and DNS-1123 module shapes
9771 // (leaf, hyphenated, deeply-hyphenated) so any wrapper-side
9772 // lowercase / trim / truncate / silent axis-swap
9773 // (`from` ↔ `prior_cleanup_module`, `script` misrouted onto
9774 // `from`, `prior_cleanup_kind` misrouted onto
9775 // `prior_cleanup_module`) on the four-field construction
9776 // surfaces at assert time rather than at a downstream diagnostic
9777 // consumer that reads the fields back and gets a different value
9778 // than the one it stored. Both `&str`-literal and `&String` (via
9779 // Deref coercion) carriers are exercised for `from` /
9780 // `prior_cleanup_module` because the sole wire-up hands
9781 // `self.prior_versao()` (a `&str` accessor) and `prior_module`
9782 // (also `&str`, from `declared_module().expect(…)`) — the ctor
9783 // must accept both shapes without a pre-conversion. Both
9784 // `&Path`-direct and `&PathBuf` (via Deref coercion) carriers
9785 // are exercised for `script` because the sole wire-up hands a
9786 // `&PathBuf` sticky-latch projection from `declared_path()`'s
9787 // `Option<&PathBuf>` return — the ctor must accept both shapes
9788 // without a pre-conversion.
9789 let kinds: [&'static str; 2] = [
9790 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9791 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9792 ];
9793 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9794 let scripts: [&str; 3] = [
9795 "m.lisp",
9796 "lib/migrations.lisp",
9797 "lib/migrations/v01/step-1.lisp",
9798 ];
9799 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9800 for kind in kinds {
9801 for from in froms {
9802 for script_str in scripts {
9803 for module in modules {
9804 let from_owned: String = from.to_string();
9805 let module_owned: String = module.to_string();
9806 let script_path = Path::new(script_str);
9807 let script_pathbuf = PathBuf::from(script_str);
9808 for (from_in, module_in, script_in) in [
9809 (from, module, script_path),
9810 (
9811 from_owned.as_str(),
9812 module_owned.as_str(),
9813 script_pathbuf.as_path(),
9814 ),
9815 ] {
9816 assert_eq!(
9817 UpgradeError::state_change_after_cleanup(
9818 from_in, script_in, kind, module_in,
9819 ),
9820 UpgradeError::StateChangeAfterCleanup {
9821 from: from.to_string(),
9822 script: PathBuf::from(script_str),
9823 prior_cleanup_kind: kind,
9824 prior_cleanup_module: module.to_string(),
9825 },
9826 "state_change_after_cleanup must route from → from, \
9827 script → script, prior_cleanup_kind → prior_cleanup_kind, \
9828 prior_cleanup_module → prior_cleanup_module in declared \
9829 field order verbatim on ({from:?}, {script_str:?}, \
9830 {kind:?}, {module:?})",
9831 );
9832 }
9833 }
9834 }
9835 }
9836 }
9837 }
9838
9839 #[test]
9840 fn validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor() {
9841 // End-to-end wire-up pin: build an entry whose declared
9842 // `:instructions` list places a `:soft-purge` (and separately a
9843 // `:purge`) before a `:state-change` so
9844 // [`UpgradeFromEntry::validate_state_change_before_cleanup`]'s
9845 // migrate-family sticky-latch dispatch surfaces
9846 // `UpgradeError::StateChangeAfterCleanup`, then pin that the
9847 // observed `Err` byte-equals the substrate-primitive
9848 // [`UpgradeError::state_change_after_cleanup`] ctor's output on
9849 // the same fixture. A future silent de-lift of the wire-up back
9850 // to the open-coded struct-literal (or a silent axis-swap on
9851 // the four-field construction at the wire-up site) trips at
9852 // caixa-core test time rather than at a downstream diagnostic
9853 // consumer far from the wire-up commit. Same end-to-end-wire-up
9854 // discipline as the sibling
9855 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
9856 // on the peer load → cleanup ordering gate; both key off
9857 // exactly one typed dispatch on the substrate primitive. Every
9858 // entry here front-loads a `:load-module` so the sole surviving
9859 // ordering refusal is the migrate → cleanup one this gate
9860 // owns — the peer `validate_purge_ordering` load → cleanup gate
9861 // returns `Ok(())` on these fixtures, so the migrate-after-
9862 // cleanup arm is the only path to an `Err`.
9863 let cases: [(&str, UpgradeInstruction, &'static str, &str, &str); 2] = [
9864 (
9865 "0.1.0",
9866 UpgradeInstruction::SoftPurge {
9867 module: "hello-rio-old".into(),
9868 },
9869 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9870 "hello-rio-old",
9871 "lib/migrations/v01.lisp",
9872 ),
9873 (
9874 "1.2.3-rc.1",
9875 UpgradeInstruction::Purge {
9876 module: "cache-v2-ancient".into(),
9877 },
9878 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9879 "cache-v2-ancient",
9880 "lib/migrations/v02.lisp",
9881 ),
9882 ];
9883 for (from, cleanup, kind, module, script_str) in cases {
9884 let script = PathBuf::from(script_str);
9885 let e = entry(
9886 from,
9887 vec![
9888 UpgradeInstruction::LoadModule {
9889 module: "hello-rio".into(),
9890 },
9891 cleanup,
9892 UpgradeInstruction::StateChange {
9893 script: script.clone(),
9894 },
9895 ],
9896 );
9897 let observed = e.validate().unwrap_err();
9898 assert_eq!(
9899 observed,
9900 UpgradeError::state_change_after_cleanup(from, &script, kind, module),
9901 "validate_state_change_before_cleanup must route its \
9902 refusal through \
9903 UpgradeError::state_change_after_cleanup(from, script, \
9904 prior_cleanup_kind, prior_cleanup_module) on a \
9905 `:state-change` after a bare-cleanup {kind:?} entry, \
9906 byte-equal to the pre-lift open-coded struct-literal \
9907 wrap on the same fixture",
9908 );
9909 }
9910 }
9911
9912 // Per-variant equivalence + cross-axis + end-to-end wire-up pins for
9913 // the standalone [`UpgradeError::duplicate_cleanup`] inherent ctor
9914 // (see the paired doc-block above the ctor definition) — the fold of
9915 // the last open-coded three-slot `{ from: String, module: String,
9916 // kinds: Vec<&'static str> }` struct-literal wire-up on
9917 // [`UpgradeError`] closes the sole in-crate wire-up site inside
9918 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
9919 // cleanup-family dedup arm onto one substrate primitive. A byte-
9920 // mismatched ctor body would trip the equivalence pin first, ahead of
9921 // any downstream diagnostic-shape drift. Peer of the sibling
9922 // standalone-ctor equivalence + routing pins on the sibling one-off
9923 // variants across `UpgradeError`
9924 // (`purge_without_prior_load_ctor_matches_struct_literal_wrap` on the
9925 // paired three-slot `{ from, kind, module }` envelope for the sibling
9926 // load → cleanup ordering axis;
9927 // `state_change_after_cleanup_ctor_matches_struct_literal_wrap` on
9928 // the paired four-slot `{ from, script, prior_cleanup_kind,
9929 // prior_cleanup_module }` envelope for the migrate → cleanup
9930 // boundary; `duplicate_from_ctor_matches_struct_literal_wrap` on the
9931 // paired one-slot `{ from }` envelope for the cross-entry duplicate-
9932 // `:from` gate).
9933
9934 #[test]
9935 fn duplicate_cleanup_ctor_matches_struct_literal_wrap() {
9936 // Equivalence pin: the ctor produces byte-equal
9937 // `UpgradeError::DuplicateCleanup` to the pre-lift open-coded
9938 // three-field struct-literal on the same `(&str, &str,
9939 // Vec<&'static str>)` fixture. Guards any future field-addition /
9940 // reordering / string-conversion tweak on the variant. Same
9941 // equivalence-pin shape as the sibling
9942 // `purge_without_prior_load_ctor_matches_struct_literal_wrap`
9943 // (9752da1) on the peer three-slot envelope.
9944 let from = "0.1.0";
9945 let module = "x-old";
9946 let kinds: Vec<&'static str> = vec![
9947 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9948 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9949 ];
9950 assert_eq!(
9951 UpgradeError::duplicate_cleanup(from, module, kinds.clone()),
9952 UpgradeError::DuplicateCleanup {
9953 from: from.to_string(),
9954 module: module.to_string(),
9955 kinds,
9956 },
9957 "generated duplicate_cleanup ctor must produce byte-equal \
9958 UpgradeError to the open-coded struct-literal wrap on the \
9959 same (&str, &str, Vec<&'static str>) fixture",
9960 );
9961 }
9962
9963 #[test]
9964 fn duplicate_cleanup_ctor_routes_from_module_and_kinds_through_verbatim() {
9965 // Cross-axis routing pin: sweep the three constructor input axes
9966 // (`from: &str`, `module: &str`, `kinds: Vec<&'static str>`)
9967 // through distinct-per-axis fixtures across every ordered pair of
9968 // cleanup-family [`UpgradeInstruction::lisp_form`] variants (the
9969 // four `(prior_kind, kind)` combinations `validate_cleanup_
9970 // singularity` can emit: SS, PP, SP, PS) + a boundary mix of
9971 // SemVer-2 `from` shapes (release, pre-release, pre-release +
9972 // build-metadata, zero) + DNS-1123 module shapes (leaf,
9973 // hyphenated, deeply-hyphenated) so any wrapper-side lowercase /
9974 // trim / truncate / silent axis-swap (`from` ↔ `module`, kinds
9975 // pair-reorder, kinds-vec drop-or-duplicate on the two-element
9976 // owned `Vec<&'static str>`) on the three-field construction
9977 // surfaces at assert time rather than at a downstream diagnostic
9978 // consumer that reads the fields back and gets a different value
9979 // than the one it stored. Both `&str`-literal and `&String` (via
9980 // Deref coercion) carriers are exercised for `from` / `module`
9981 // because the sole wire-up hands `self.prior_versao()` (a `&str`
9982 // accessor) and `module` (also `&str`, from `declared_module().
9983 // expect(…)`) — the ctor must accept both shapes without a
9984 // pre-conversion.
9985 let all_kinds: [&'static str; 2] = [
9986 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
9987 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
9988 ];
9989 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
9990 let modules: [&str; 3] = ["x", "hello-rio-old", "cache-v2-ancient"];
9991 for prior_kind in all_kinds {
9992 for kind in all_kinds {
9993 for from in froms {
9994 for module in modules {
9995 let from_owned: String = from.to_string();
9996 let module_owned: String = module.to_string();
9997 for (from_in, module_in) in
9998 [(from, module), (from_owned.as_str(), module_owned.as_str())]
9999 {
10000 let kinds: Vec<&'static str> = vec![prior_kind, kind];
10001 assert_eq!(
10002 UpgradeError::duplicate_cleanup(from_in, module_in, kinds.clone(),),
10003 UpgradeError::DuplicateCleanup {
10004 from: from.to_string(),
10005 module: module.to_string(),
10006 kinds,
10007 },
10008 "duplicate_cleanup must route from → from, \
10009 module → module, kinds → kinds in declared \
10010 field order verbatim on ({from:?}, \
10011 {module:?}, [{prior_kind:?}, {kind:?}])",
10012 );
10013 }
10014 }
10015 }
10016 }
10017 }
10018 }
10019
10020 #[test]
10021 fn validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor() {
10022 // End-to-end wire-up pin: build an entry whose declared
10023 // `:instructions` list front-loads a `:load-module` (so the
10024 // sibling `validate_purge_ordering` load → cleanup gate returns
10025 // `Ok(())` on the fixture) and then places two cleanup
10026 // instructions targeting the same module so
10027 // [`UpgradeFromEntry::validate_cleanup_singularity`]'s per-module
10028 // cleanup-family dedup arm surfaces
10029 // `UpgradeError::DuplicateCleanup`, then pin that the observed
10030 // `Err` byte-equals the substrate-primitive
10031 // [`UpgradeError::duplicate_cleanup`] ctor's output on the same
10032 // fixture. A future silent de-lift of the wire-up back to the
10033 // open-coded struct-literal (or a silent axis-swap on the three-
10034 // field construction at the wire-up site, or a kinds-pair
10035 // reorder) trips at caixa-core test time rather than at a
10036 // downstream diagnostic consumer far from the wire-up commit.
10037 // Same end-to-end-wire-up discipline as the sibling
10038 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
10039 // on the peer load → cleanup ordering gate and
10040 // `validate_state_change_before_cleanup_arm_routes_through_state_change_after_cleanup_ctor`
10041 // on the peer migrate → cleanup boundary; all three key off
10042 // exactly one typed dispatch on the substrate primitive.
10043 let cases: [(
10044 &str,
10045 UpgradeInstruction,
10046 UpgradeInstruction,
10047 &str,
10048 [&'static str; 2],
10049 ); 4] = [
10050 (
10051 "0.1.0",
10052 UpgradeInstruction::SoftPurge {
10053 module: "hello-rio-old".into(),
10054 },
10055 UpgradeInstruction::SoftPurge {
10056 module: "hello-rio-old".into(),
10057 },
10058 "hello-rio-old",
10059 [
10060 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10061 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10062 ],
10063 ),
10064 (
10065 "1.2.3-rc.1",
10066 UpgradeInstruction::Purge {
10067 module: "cache-v2-ancient".into(),
10068 },
10069 UpgradeInstruction::Purge {
10070 module: "cache-v2-ancient".into(),
10071 },
10072 "cache-v2-ancient",
10073 [
10074 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10075 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10076 ],
10077 ),
10078 (
10079 "0.2.0-alpha.7+build.5",
10080 UpgradeInstruction::SoftPurge {
10081 module: "x-old".into(),
10082 },
10083 UpgradeInstruction::Purge {
10084 module: "x-old".into(),
10085 },
10086 "x-old",
10087 [
10088 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10089 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10090 ],
10091 ),
10092 (
10093 "0.0.0",
10094 UpgradeInstruction::Purge {
10095 module: "x-old".into(),
10096 },
10097 UpgradeInstruction::SoftPurge {
10098 module: "x-old".into(),
10099 },
10100 "x-old",
10101 [
10102 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10103 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10104 ],
10105 ),
10106 ];
10107 for (from, first, second, module, kinds) in cases {
10108 let e = entry(
10109 from,
10110 vec![
10111 UpgradeInstruction::LoadModule {
10112 module: "hello-rio".into(),
10113 },
10114 first,
10115 second,
10116 ],
10117 );
10118 let observed = e.validate().unwrap_err();
10119 assert_eq!(
10120 observed,
10121 UpgradeError::duplicate_cleanup(from, module, kinds.to_vec()),
10122 "validate_cleanup_singularity must route its refusal \
10123 through UpgradeError::duplicate_cleanup(from, module, \
10124 kinds) on a two-cleanup {kinds:?} entry targeting the \
10125 same module, byte-equal to the pre-lift open-coded \
10126 struct-literal wrap on the same fixture",
10127 );
10128 }
10129 }
10130
10131 #[test]
10132 fn restart_not_exclusive_ctor_matches_struct_literal_wrap() {
10133 // Equivalence pin: the ctor produces byte-equal
10134 // `UpgradeError::RestartNotExclusive` to the pre-lift open-coded
10135 // three-field struct-literal on the same `(&str, usize,
10136 // Vec<&'static str>)` fixture. Guards any future field-addition /
10137 // reordering / string-conversion tweak on the variant. Same
10138 // equivalence-pin shape as the sibling
10139 // `duplicate_cleanup_ctor_matches_struct_literal_wrap` (10a5b48)
10140 // on the peer three-slot envelope.
10141 let from = "0.1.0";
10142 let restart_count: usize = 1;
10143 let other_kinds: Vec<&'static str> =
10144 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE];
10145 assert_eq!(
10146 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10147 UpgradeError::RestartNotExclusive {
10148 from: from.to_string(),
10149 restart_count,
10150 other_kinds,
10151 },
10152 "generated restart_not_exclusive ctor must produce byte-equal \
10153 UpgradeError to the open-coded struct-literal wrap on the \
10154 same (&str, usize, Vec<&'static str>) fixture",
10155 );
10156 }
10157
10158 #[test]
10159 fn restart_not_exclusive_ctor_routes_from_restart_count_and_other_kinds_through_verbatim() {
10160 // Cross-axis routing pin: sweep the three constructor input axes
10161 // (`from: &str`, `restart_count: usize`, `other_kinds:
10162 // Vec<&'static str>`) through distinct-per-axis fixtures across a
10163 // boundary matrix of SemVer-2 `from` shapes (release, pre-release,
10164 // pre-release + build-metadata, zero) × non-degenerate
10165 // `restart_count` values (1 — the mixed-with-typed shape, 2 — the
10166 // pure-duplication shape, 3 — the deeply-duplicated shape) ×
10167 // ordered `other_kinds` lisp-form lists spanning the four
10168 // non-`:restart` [`UpgradeInstruction::lisp_form`] arms
10169 // (`:load-module`, `:state-change`, `:soft-purge`, `:purge`) —
10170 // empty (the `((:restart) (:restart))` shape), singleton
10171 // (`((:load-module …) (:restart))`), and the full typed sequence
10172 // (`((:load-module …) (:state-change …) (:soft-purge …) (:purge
10173 // …) (:restart))`) — so any wrapper-side silent lowercase / trim
10174 // / truncate / silent axis-swap (`from` ↔ swap onto
10175 // `restart_count`'s numeric axis, `other_kinds`-vec drop-or-
10176 // duplicate on the four-element owned `Vec<&'static str>`,
10177 // `other_kinds` reorder against declared instruction order) on
10178 // the three-field construction surfaces at assert time rather
10179 // than at a downstream diagnostic consumer that reads the fields
10180 // back and gets a different value than the one it stored. Both
10181 // `&str`-literal and `&String` (via Deref coercion) carriers are
10182 // exercised for `from` because the sole wire-up hands
10183 // `self.prior_versao()` (a `&str` accessor).
10184 let all_typed_kinds: [&'static str; 4] = [
10185 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10186 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10187 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10188 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10189 ];
10190 let other_kinds_matrix: [Vec<&'static str>; 3] =
10191 [vec![], vec![all_typed_kinds[0]], all_typed_kinds.to_vec()];
10192 let froms: [&str; 4] = ["0.1.0", "1.2.3-rc.1", "0.2.0-alpha.7+build.5", "0.0.0"];
10193 let restart_counts: [usize; 3] = [1, 2, 3];
10194 for other_kinds in &other_kinds_matrix {
10195 for restart_count in restart_counts {
10196 for from in froms {
10197 let from_owned: String = from.to_string();
10198 for from_in in [from, from_owned.as_str()] {
10199 assert_eq!(
10200 UpgradeError::restart_not_exclusive(
10201 from_in,
10202 restart_count,
10203 other_kinds.clone(),
10204 ),
10205 UpgradeError::RestartNotExclusive {
10206 from: from.to_string(),
10207 restart_count,
10208 other_kinds: other_kinds.clone(),
10209 },
10210 "restart_not_exclusive must route from → from, \
10211 restart_count → restart_count, other_kinds → \
10212 other_kinds in declared field order verbatim \
10213 on ({from:?}, {restart_count:?}, \
10214 {other_kinds:?})",
10215 );
10216 }
10217 }
10218 }
10219 }
10220 }
10221
10222 #[test]
10223 fn validate_restart_exclusive_arm_routes_through_restart_not_exclusive_ctor() {
10224 // End-to-end wire-up pin: sweep the three canonical exclusivity-
10225 // violation shapes the `validate_restart_exclusive` gate can
10226 // refuse — restart + one typed instruction (`restart_count: 1,
10227 // other_kinds: [load-module]`), restart + full typed sequence
10228 // (`restart_count: 1, other_kinds: [load-module, state-change,
10229 // soft-purge, purge]`), and duplicated restart only
10230 // (`restart_count: 2, other_kinds: []`) — and pin that each
10231 // observed `Err` byte-equals the substrate-primitive
10232 // [`UpgradeError::restart_not_exclusive`] ctor's output on the
10233 // same fixture. A future silent de-lift of the wire-up back to
10234 // the open-coded struct-literal (or a silent axis-swap on the
10235 // three-field construction at the wire-up site, or an
10236 // `other_kinds` reorder / drop) trips at caixa-core test time
10237 // rather than at a downstream diagnostic consumer far from the
10238 // wire-up commit. Same end-to-end-wire-up discipline as the
10239 // sibling
10240 // `validate_cleanup_singularity_arm_routes_through_duplicate_cleanup_ctor`
10241 // (10a5b48) on the peer per-module cleanup-singularity axis and
10242 // `validate_purge_ordering_arm_routes_through_purge_without_prior_load_ctor`
10243 // on the peer load → cleanup ordering gate; all three key off
10244 // exactly one typed dispatch on the substrate primitive.
10245 let cases: [(&str, Vec<UpgradeInstruction>, usize, Vec<&'static str>); 3] = [
10246 (
10247 "0.1.0",
10248 vec![
10249 UpgradeInstruction::LoadModule {
10250 module: "hello-rio".into(),
10251 },
10252 UpgradeInstruction::Restart,
10253 ],
10254 1,
10255 vec![crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE],
10256 ),
10257 (
10258 "1.2.3-rc.1",
10259 vec![
10260 UpgradeInstruction::LoadModule {
10261 module: "hello-rio".into(),
10262 },
10263 UpgradeInstruction::StateChange {
10264 script: PathBuf::from("lib/m.lisp"),
10265 },
10266 UpgradeInstruction::SoftPurge {
10267 module: "hello-rio-old".into(),
10268 },
10269 UpgradeInstruction::Purge {
10270 module: "hello-rio-old".into(),
10271 },
10272 UpgradeInstruction::Restart,
10273 ],
10274 1,
10275 vec![
10276 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10277 crate::render::M2_UPGRADE_INSTRUCTION_KIND_STATE_CHANGE,
10278 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10279 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10280 ],
10281 ),
10282 (
10283 "0.0.0",
10284 vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
10285 2,
10286 vec![],
10287 ),
10288 ];
10289 for (from, instructions, restart_count, other_kinds) in cases {
10290 let e = entry(from, instructions);
10291 let observed = e.validate().unwrap_err();
10292 assert_eq!(
10293 observed,
10294 UpgradeError::restart_not_exclusive(from, restart_count, other_kinds.clone()),
10295 "validate_restart_exclusive must route its refusal \
10296 through UpgradeError::restart_not_exclusive(from, \
10297 restart_count, other_kinds) on a mixed-`(:restart)` \
10298 entry, byte-equal to the pre-lift open-coded struct-\
10299 literal wrap on the same fixture",
10300 );
10301 }
10302 }
10303
10304 #[test]
10305 fn module_invalid_ctor_matches_struct_literal_wrap() {
10306 // Fail-before-pass-after equivalence pin on
10307 // [`UpgradeError::module_invalid`] — the constructor must
10308 // produce a byte-equal `UpgradeError` to the pre-lift open-
10309 // coded `Self::ModuleInvalid { kind, module: module.to_string(),
10310 // reason }` struct-literal on the same `(:load-module …)` /
10311 // `:module "Hello-Rio"` / parser-shaped-reason fixture. A byte-
10312 // mismatched constructor body (a stray `.trim()`, a rebased
10313 // field order, a `String::new()` reason substitution) would
10314 // trip this pin first, byte-for-byte against the sibling
10315 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484) /
10316 // [`crate::SupervisorError::child_caixa_invalid`] /
10317 // [`crate::DepError::nome_invalid`] (077aa3d) per-envelope pin
10318 // discipline on the peer three-slot `{ *, reason: String }`
10319 // invalid-arm ctor family.
10320 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10321 let module = "Hello-Rio";
10322 let reason = "must be lowercase alphanumeric or `-`";
10323 assert_eq!(
10324 UpgradeError::module_invalid(kind, module, reason),
10325 UpgradeError::ModuleInvalid {
10326 kind,
10327 module: module.to_string(),
10328 reason: reason.to_string(),
10329 },
10330 "generated module_invalid ctor must produce byte-equal \
10331 UpgradeError to the open-coded struct-literal wrap on the \
10332 same (kind, module, reason) fixture",
10333 );
10334 }
10335
10336 #[test]
10337 fn module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10338 // Cross-axis pin: sweep the constructor's `kind: &'static str`
10339 // input across every [`UpgradeInstruction::declared_module`]-
10340 // bearing variant's canonical
10341 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10342 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10343 // canonical `":phantom"` fourth arm proving the ctor does not
10344 // silently clamp `kind` to the three-arm roster. The
10345 // `reason: impl Into<String>` bound accepts both `&str`
10346 // literals and the [`String`] the underlying
10347 // [`crate::render::is_dns_1123_label`] predicate returns via
10348 // `.into()`, matching the peer
10349 // [`crate::AplicacaoError::contrato_caixa_invalid`] cross-axis
10350 // sweep on the sibling `:contratos` per-edge envelope.
10351 let module = "Hello-Rio";
10352 let reason = "must be lowercase alphanumeric or `-`";
10353 for kind in [
10354 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10355 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10356 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10357 ":phantom",
10358 ] {
10359 assert_eq!(
10360 UpgradeError::module_invalid(kind, module, reason),
10361 UpgradeError::ModuleInvalid {
10362 kind,
10363 module: module.to_string(),
10364 reason: reason.to_string(),
10365 },
10366 "module_invalid ctor must thread kind={kind:?} verbatim",
10367 );
10368 }
10369 }
10370
10371 #[test]
10372 fn validate_module_wire_up_routes_invalid_through_module_invalid_ctor() {
10373 // End-to-end wire-up pin: [`validate_module`]'s
10374 // [`crate::render::require_valid_dns_1123_label`] invalid-arm
10375 // must emit a diagnostic byte-equal to the ctor's output on the
10376 // same `(kind, module)` fixture — the fold's invariant that
10377 // [`validate_module`]'s cascade reaches the
10378 // [`UpgradeError::ModuleInvalid`] envelope through the
10379 // substrate primitive [`UpgradeError::module_invalid`] rather
10380 // than the pre-lift open-coded struct-literal. Sweep every
10381 // [`UpgradeInstruction::declared_module`]-bearing variant
10382 // against a canonical footgun (`"Hello-Rio"` — the uppercase-
10383 // lead footgun the peer `validate_rejects_non_dns_1123_module`
10384 // test above already carries) so every wire-up on the invalid-
10385 // arm cascade lands on the ctor's output. Matches the peer
10386 // sibling end-to-end pin
10387 // [`crate::AplicacaoError::contrato_caixa_invalid`] (3d1e484)
10388 // carries on `validate_contrato_caixa`'s
10389 // `require_valid_dns_1123_label` invalid-arm.
10390 let module = "Hello-Rio";
10391 let cases: &[(UpgradeInstruction, &'static str)] = &[
10392 (
10393 UpgradeInstruction::LoadModule {
10394 module: module.to_string(),
10395 },
10396 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10397 ),
10398 (
10399 UpgradeInstruction::SoftPurge {
10400 module: module.to_string(),
10401 },
10402 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10403 ),
10404 (
10405 UpgradeInstruction::Purge {
10406 module: module.to_string(),
10407 },
10408 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10409 ),
10410 ];
10411 for (instr, expected_kind) in cases {
10412 let observed = instr.validate().unwrap_err();
10413 let UpgradeError::ModuleInvalid {
10414 reason: observed_reason,
10415 ..
10416 } = &observed
10417 else {
10418 panic!("expected ModuleInvalid on {instr:?}, got {observed:?}");
10419 };
10420 assert_eq!(
10421 observed,
10422 UpgradeError::module_invalid(expected_kind, module, observed_reason.clone()),
10423 "validate_module must route its invalid-arm refusal \
10424 through UpgradeError::module_invalid(kind, module, \
10425 reason) on {instr:?}, byte-equal to the pre-lift open-\
10426 coded struct-literal wrap on the same fixture",
10427 );
10428 }
10429 }
10430
10431 #[test]
10432 fn module_empty_ctor_matches_struct_literal_wrap() {
10433 // Fail-before-pass-after equivalence pin on
10434 // [`UpgradeError::module_empty`] — the constructor must produce
10435 // a byte-equal `UpgradeError` to the pre-lift open-coded
10436 // `Self::ModuleEmpty { kind }` struct-literal on the same
10437 // `(:load-module …)` `M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE`
10438 // axis-tag fixture. A byte-mismatched constructor body (a stray
10439 // `.trim()` or `.to_lowercase()` on `kind`, a silent clamp to
10440 // one of the three canonical arms, a fixed-slot substitution)
10441 // would trip this pin first, matching the sibling
10442 // [`crate::AplicacaoError::contrato_caixa_empty`] (815cc87) /
10443 // [`crate::behavior::BehaviorError::empty_path`] per-envelope
10444 // pin discipline on the peer one-slot `{ *: &'static str }`
10445 // empty-arm ctor family.
10446 let kind = crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE;
10447 assert_eq!(
10448 UpgradeError::module_empty(kind),
10449 UpgradeError::ModuleEmpty { kind },
10450 "generated module_empty ctor must produce byte-equal \
10451 UpgradeError to the open-coded struct-literal wrap on the \
10452 same kind fixture",
10453 );
10454 }
10455
10456 #[test]
10457 fn module_empty_ctor_routes_kind_verbatim_across_every_declared_module_variant() {
10458 // Cross-axis pin: sweep the constructor's `kind: &'static str`
10459 // input across every [`UpgradeInstruction::declared_module`]-
10460 // bearing variant's canonical
10461 // [`crate::render::M2_UPGRADE_INSTRUCTION_KIND_*`] tag —
10462 // `:load-module` / `:soft-purge` / `:purge` — plus a non-
10463 // canonical `":phantom"` fourth arm proving the ctor does not
10464 // silently clamp `kind` to the three-arm roster (a future
10465 // fourth `declared_module`-bearing `UpgradeInstruction` variant
10466 // lands on this ctor without a per-arm rewrite). Matches the
10467 // sibling [`Self::module_invalid`] cross-axis sweep at
10468 // `module_invalid_ctor_routes_kind_verbatim_across_every_declared_module_variant`
10469 // on the paired three-slot invalid-arm envelope so both arms of
10470 // the [`validate_module`] two-closure cascade carry the same
10471 // axis-invariance guarantee.
10472 for kind in [
10473 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10474 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10475 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10476 ":phantom",
10477 ] {
10478 assert_eq!(
10479 UpgradeError::module_empty(kind),
10480 UpgradeError::ModuleEmpty { kind },
10481 "module_empty ctor must thread kind={kind:?} verbatim",
10482 );
10483 }
10484 }
10485
10486 #[test]
10487 fn validate_module_wire_up_routes_empty_through_module_empty_ctor() {
10488 // End-to-end wire-up pin: [`validate_module`]'s
10489 // [`crate::render::require_valid_dns_1123_label`] empty-arm
10490 // must emit a diagnostic byte-equal to the ctor's output on the
10491 // same `(kind, "")` fixture — the fold's invariant that
10492 // [`validate_module`]'s cascade reaches the
10493 // [`UpgradeError::ModuleEmpty`] envelope through the substrate
10494 // primitive [`UpgradeError::module_empty`] rather than the
10495 // pre-lift open-coded struct-literal. Sweep every
10496 // [`UpgradeInstruction::declared_module`]-bearing variant
10497 // against the empty-string module value so every wire-up on the
10498 // empty-arm cascade lands on the ctor's output. Closes the pair
10499 // on the [`validate_module`] two-closure cascade the sibling
10500 // `validate_module_wire_up_routes_invalid_through_module_invalid_ctor`
10501 // (3d0d64a) already anchors on the invalid-arm.
10502 let cases: &[(UpgradeInstruction, &'static str)] = &[
10503 (
10504 UpgradeInstruction::LoadModule {
10505 module: String::new(),
10506 },
10507 crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE,
10508 ),
10509 (
10510 UpgradeInstruction::SoftPurge {
10511 module: String::new(),
10512 },
10513 crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE,
10514 ),
10515 (
10516 UpgradeInstruction::Purge {
10517 module: String::new(),
10518 },
10519 crate::render::M2_UPGRADE_INSTRUCTION_KIND_PURGE,
10520 ),
10521 ];
10522 for (instr, expected_kind) in cases {
10523 assert_eq!(
10524 instr.validate().unwrap_err(),
10525 UpgradeError::module_empty(expected_kind),
10526 "validate_module must route its empty-arm refusal \
10527 through UpgradeError::module_empty(kind) on {instr:?}, \
10528 byte-equal to the pre-lift open-coded struct-literal \
10529 wrap on the same fixture",
10530 );
10531 }
10532 }
10533
10534 /// Fixture roster covering every [`UpgradeInstruction`] arm — a
10535 /// concrete-instance witness per variant so the four
10536 /// canonical-projection-triple pin tests below sweep the same five
10537 /// arms without duplicating the arm-shape declaration at each
10538 /// probe site. A future arm addition (a `Discard` peer the
10539 /// `code:delete/1` analog might inspire, a `SoftPurge` split into
10540 /// `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-down policy
10541 /// grows a two-arm shape) extends this fixture list as a single
10542 /// edit; the pin sweeps below then reach the new arm by iteration
10543 /// rather than a hand-authored per-arm probe.
10544 fn upgrade_instruction_arm_roster() -> Vec<(UpgradeInstruction, &'static str)> {
10545 vec![
10546 (
10547 UpgradeInstruction::LoadModule {
10548 module: "hello-rio".into(),
10549 },
10550 "load-module",
10551 ),
10552 (
10553 UpgradeInstruction::StateChange {
10554 script: std::path::PathBuf::from("lib/migrations/v01-to-v02.lisp"),
10555 },
10556 "state-change",
10557 ),
10558 (
10559 UpgradeInstruction::SoftPurge {
10560 module: "hello-rio-old".into(),
10561 },
10562 "soft-purge",
10563 ),
10564 (
10565 UpgradeInstruction::Purge {
10566 module: "hello-rio-old".into(),
10567 },
10568 "purge",
10569 ),
10570 (UpgradeInstruction::Restart, "restart"),
10571 ]
10572 }
10573
10574 #[test]
10575 fn upgrade_instruction_as_str_returns_canonical_kebab_wire_bytes() {
10576 // Fail-before-pass-after pin on the [`UpgradeInstruction::as_str`]
10577 // canonical-projection accessor: the five match arms each return
10578 // the un-prefixed kebab wire byte-string every serde-carried CR /
10579 // structured-log / fleet-catalog identity consumer converges onto.
10580 // A future variant rename or a per-arm typo (e.g. dropping the
10581 // hyphen from `"load-module"` → `"loadmodule"`) trips at
10582 // caixa-core test time rather than surfacing as a downstream K8s-
10583 // CR round-trip miss where the paired `Deserialize` derive
10584 // rejects the drifted arm on every apply.
10585 for (variant, expected) in upgrade_instruction_arm_roster() {
10586 assert_eq!(
10587 variant.as_str(),
10588 expected,
10589 "UpgradeInstruction::{variant:?}.as_str() must return the \
10590 canonical un-prefixed kebab wire byte-string"
10591 );
10592 }
10593 }
10594
10595 #[test]
10596 fn upgrade_instruction_as_str_matches_discriminant_derive() {
10597 // Load-bearing pin on the two-source alignment: the hand-authored
10598 // [`UpgradeInstruction::as_str`] match arms must byte-equal the
10599 // [`gen_platform::Discriminant`]-derived [`Self::discriminant`]
10600 // per-arm output for every variant. `.discriminant()` is the
10601 // fleet-wide dispatcher-catalog identity (registered under
10602 // `"caixa.upgrade-instruction"` by the sibling
10603 // `gen_platform::register_dispatcher!` macro invocation at
10604 // upgrade.rs:88); [`Self::as_str`] is the standard-library
10605 // `AsRef<str>` / [`std::fmt::Display`]-routed diagnostic byte-
10606 // string. Both must stay aligned so a consumer that reaches
10607 // through either path lands on the same per-arm byte-string.
10608 // A future rename on either side (a per-arm serde-attribute
10609 // drift silently splitting the derive's kebab output from the
10610 // hand-authored arms, a hand-authored typo on the [`Self::as_str`]
10611 // match arm silently splitting the standard-library-routed path
10612 // from the catalog identity) trips here at caixa-core test time
10613 // rather than as a divergent per-consumer dispatch at some future
10614 // downstream site.
10615 for (variant, _expected) in upgrade_instruction_arm_roster() {
10616 assert_eq!(
10617 variant.as_str(),
10618 variant.discriminant(),
10619 "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10620 the gen_platform::Discriminant-derived discriminant() \
10621 output — the two axes are the substrate's kebab-case wire \
10622 identity and must stay aligned by construction"
10623 );
10624 }
10625 }
10626
10627 #[test]
10628 fn upgrade_instruction_as_str_matches_serialize_wire_kind_tag() {
10629 // Load-bearing pin on the derive-to-hand alignment on the *wire*
10630 // axis: the hand-authored [`UpgradeInstruction::as_str`] match
10631 // arms must byte-equal the JSON tag the un-`rename`d
10632 // `#[serde(tag = "kind", rename_all = "kebab-case")]` derive
10633 // emits under the paired
10634 // [`crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND`] tag key.
10635 // A future accidental `rename_all = "snake_case"` /
10636 // `"UPPERCASE"` attribute drift at the derive surface, or a
10637 // per-variant `#[serde(rename = "…")]` overlay silently
10638 // targeting one arm, would silently split the wire byte-shape
10639 // every K8s-CR / tatara-lisp round-trip / fleet-catalog
10640 // consumer reads through the two paths — pinning the identity
10641 // here makes any such drift a caixa-core-test-time failure.
10642 // Sibling in shape to
10643 // [`crate::kind::tests::caixa_kind_wire_name_matches_serialize_wire_byte_string`]
10644 // on the top-level [`crate::CaixaKind`] axis.
10645 for (variant, _expected) in upgrade_instruction_arm_roster() {
10646 let json = serde_json::to_value(&variant).expect("serialize must succeed");
10647 let kind_tag = json
10648 .get(crate::render::M2_UPGRADE_INSTRUCTION_KEY_KIND)
10649 .and_then(serde_json::Value::as_str)
10650 .unwrap_or_else(|| {
10651 panic!(
10652 "serialized UpgradeInstruction::{variant:?} must \
10653 carry the M2_UPGRADE_INSTRUCTION_KEY_KIND tag as \
10654 a JSON string"
10655 )
10656 });
10657 assert_eq!(
10658 variant.as_str(),
10659 kind_tag,
10660 "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10661 the serde-derived JSON \"kind\" tag — a mismatch means \
10662 either the derive attributes drifted or the as_str match \
10663 arms drifted; either way downstream K8s-CR round-trip \
10664 silently splits from the accessor-routed source of truth"
10665 );
10666 }
10667 }
10668
10669 #[test]
10670 fn upgrade_instruction_display_routes_through_as_str_helper() {
10671 // Fail-before-pass-after pin on the two-path convergence: pre-
10672 // lift [`UpgradeInstruction`] carried no [`std::fmt::Display`]
10673 // surface at all — every consumer past the wire format had to
10674 // pick between [`Self::lisp_form`] returning the tatara-lisp
10675 // author-surface with `:` prefix or `format!("{v:?}")` on the
10676 // `Debug` derive returning the PascalCase variant name plus
10677 // struct-literal fields. Wiring [`std::fmt::Display`] through
10678 // [`Self::as_str`] closes the drift footgun: every
10679 // `format!("{v}")` call reaches the same kebab wire byte-string
10680 // the [`Self::as_str`] helper returns, so a future variant
10681 // rename lands at exactly one place. Pin the routing here so a
10682 // future `impl std::fmt::Display for UpgradeInstruction`
10683 // reimplementation that hand-rolls the arms instead of
10684 // delegating to [`Self::as_str`] fails at caixa-core build
10685 // time. Peer of the sibling
10686 // [`crate::supervisor::tests::restart_strategy_display_routes_through_as_str_helper`]
10687 // /
10688 // [`crate::supervisor::tests::restart_policy_display_routes_through_as_str_helper`]
10689 // /
10690 // [`crate::kind::tests::caixa_kind_display_routes_through_as_str_helper`]
10691 // pins on the sibling closed-set typed-enum discriminator axes.
10692 for (variant, _expected) in upgrade_instruction_arm_roster() {
10693 assert_eq!(
10694 variant.to_string(),
10695 variant.as_str(),
10696 "UpgradeInstruction::{variant:?} Display must route \
10697 through UpgradeInstruction::as_str (single source of \
10698 truth: the kebab wire byte-string per arm)"
10699 );
10700 }
10701 }
10702
10703 #[test]
10704 fn upgrade_instruction_display_matches_as_str_and_not_lisp_form() {
10705 // Two-axis-split pin: the tatara-lisp author-surface form
10706 // ([`UpgradeInstruction::lisp_form`], with `:` prefix) and the
10707 // wire form ([`UpgradeInstruction::as_str`], without `:`
10708 // prefix) are structurally distinct by design. The pin here
10709 // makes the split load-bearing: a future accidental collapse
10710 // of either axis onto the other (routing `Display` through
10711 // [`Self::lisp_form`] via a mistaken match-arm re-inlining, or
10712 // routing [`Self::lisp_form`] through [`Self::as_str`] and
10713 // dropping the `:` prefix) would trip here at caixa-core
10714 // build time rather than silently merging the two axes at
10715 // some future consumer's per-instruction dispatch step. Peer
10716 // of the sibling
10717 // [`crate::kind::tests::caixa_kind_display_matches_as_str_and_not_serialize_wire`]
10718 // pin on the top-level [`crate::CaixaKind`] two-axis surface.
10719 for (variant, _expected) in upgrade_instruction_arm_roster() {
10720 let display = variant.to_string();
10721 let lisp = variant.lisp_form();
10722 assert_eq!(
10723 display,
10724 variant.as_str(),
10725 "UpgradeInstruction::{variant:?} Display must byte-equal \
10726 as_str (kebab wire form, no `:` prefix)"
10727 );
10728 assert_ne!(
10729 display, lisp,
10730 "UpgradeInstruction::{variant:?} Display / as_str (wire \
10731 kebab form) must stay structurally distinct from \
10732 lisp_form (tatara-lisp author-surface with `:` prefix) — \
10733 collapsing the two axes would break the tatara-lisp \
10734 grep-and-fix workflow that keys off the `:` prefix"
10735 );
10736 assert!(
10737 lisp.starts_with(':'),
10738 "UpgradeInstruction::{variant:?}.lisp_form() must open \
10739 with a `:` prefix (tatara-lisp author-surface form)"
10740 );
10741 assert!(
10742 !display.starts_with(':'),
10743 "UpgradeInstruction::{variant:?} Display must not open \
10744 with a `:` prefix (wire form is un-prefixed kebab-case)"
10745 );
10746 }
10747 }
10748
10749 #[test]
10750 fn upgrade_instruction_as_ref_str_routes_through_as_str_accessor() {
10751 // Byte-parity pin on the standard-library `impl AsRef<str>`
10752 // route: every arm's `<UpgradeInstruction as
10753 // AsRef<str>>::as_ref(&v)` must byte-equal `v.as_str()`. Any
10754 // future silent detour that routes the impl through a
10755 // divergent projection (a per-arm inline `match self { … }`
10756 // re-inlining that opens a compile-time link to the un-lifted
10757 // arm-literal, a swap onto [`Self::lisp_form`] that would
10758 // collide the wire axis with the tatara-lisp author-surface
10759 // axis) trips here at caixa-core test time rather than at a
10760 // downstream `impl AsRef<str>`-bound consumer's silent split.
10761 // Peer of the sibling
10762 // [`crate::supervisor::tests::restart_strategy_as_ref_str_routes_through_as_str_accessor`]
10763 // /
10764 // [`crate::kind::tests::caixa_kind_as_ref_str_routes_through_as_str_accessor`]
10765 // pins.
10766 for (variant, _expected) in upgrade_instruction_arm_roster() {
10767 assert_eq!(
10768 <UpgradeInstruction as AsRef<str>>::as_ref(&variant),
10769 variant.as_str(),
10770 "UpgradeInstruction::{variant:?} AsRef<str> must route \
10771 through UpgradeInstruction::as_str"
10772 );
10773 }
10774 }
10775
10776 #[test]
10777 fn upgrade_instruction_as_str_is_const_fn() {
10778 // Const-context pin: [`UpgradeInstruction::as_str`] must remain
10779 // `const fn`. Downstream consumers reaching for the accessor
10780 // from a `const` context (a module-scope `const _:() =
10781 // assert!(<variant>.as_str().len() > 0)` invariant pin, a
10782 // `const fn` per-instruction wire-shape audit table an M4
10783 // admission webhook materializes at build time) rely on the
10784 // const-ness. A future accidental downgrade to non-`const`
10785 // (an added runtime helper reachable only from a non-`const`
10786 // context, a manual hand-rolled `impl` that shadows this
10787 // method) trips at caixa-core build time rather than
10788 // surfacing as a downstream `const`-context regression far
10789 // from the accessor declaration. Peer of the sibling
10790 // [`crate::kind::tests::caixa_kind_wire_name_is_const_fn`] pin
10791 // on the top-level [`crate::CaixaKind`] axis.
10792 const RESTART_WIRE: &str = UpgradeInstruction::Restart.as_str();
10793 assert_eq!(RESTART_WIRE, "restart");
10794 }
10795
10796 #[test]
10797 fn upgrade_instruction_lisp_forms_covers_every_arm() {
10798 // Load-bearing pin on the substrate-canonical
10799 // [`UpgradeInstruction::LISP_FORMS`] exhaustive accept-set
10800 // roster: every arm of the shared
10801 // [`upgrade_instruction_arm_roster`] fixture must project
10802 // through [`UpgradeInstruction::lisp_form`] onto an entry the
10803 // [`UpgradeInstruction::LISP_FORMS`] roster carries, and the
10804 // roster's length must byte-equal the fixture's arm count so
10805 // a silent skew between the [`UpgradeInstruction::lisp_form`]
10806 // match's arm-set and the roster's arm-set trips here at
10807 // caixa-core test time rather than at a downstream M4
10808 // admission-webhook rejection body's accepted-set enumeration
10809 // miss / `feira lint --upgrade-from` per-instruction author-
10810 // audit unknown-tag-cascade miss / LSP hover completion
10811 // source's partial-position accepted-tag miss. A future arm
10812 // addition (a `Discard` peer the `code:delete/1` analog might
10813 // inspire, a `SoftPurge` split into `SoftPurgeCoop` /
10814 // `SoftPurgeForce` as the drain-cool-down policy grows a two-
10815 // arm shape) extends the shared
10816 // [`upgrade_instruction_arm_roster`] fixture as a single edit
10817 // and this pin sweeps the new arm by iteration; the paired
10818 // [`UpgradeInstruction::LISP_FORMS`] roster must grow in
10819 // lockstep or this assertion trips. Peer of the sibling
10820 // fieldless-enum roster round-trips
10821 // [`crate::supervisor::tests::restart_strategy_all_matches_from_wire_accept_set`]
10822 // /
10823 // [`crate::supervisor::tests::restart_policy_all_matches_from_wire_accept_set`]
10824 // /
10825 // [`crate::aplicacao::tests::placement_strategy_all_matches_from_wire_accept_set`]
10826 // /
10827 // [`crate::kind::tests::caixa_kind_all_matches_wire_name_emit_set`]
10828 // pins on the peer closed-set typed-enum exhaustive-iteration
10829 // surfaces, extended onto the discriminator axis of the
10830 // discriminated-union [`UpgradeInstruction`] enum where the
10831 // per-variant data payload rules out a `&'static [Self]`
10832 // roster.
10833 let fixture = upgrade_instruction_arm_roster();
10834 assert_eq!(
10835 UpgradeInstruction::LISP_FORMS.len(),
10836 fixture.len(),
10837 "UpgradeInstruction::LISP_FORMS.len() must byte-equal the \
10838 shared upgrade_instruction_arm_roster fixture's arm count \
10839 — a mismatch means the roster and the enum's arm-set \
10840 have drifted"
10841 );
10842 for (variant, _wire) in fixture {
10843 let lisp = variant.lisp_form();
10844 assert!(
10845 UpgradeInstruction::LISP_FORMS.contains(&lisp),
10846 "UpgradeInstruction::{variant:?}.lisp_form() = {lisp:?} \
10847 must be a member of UpgradeInstruction::LISP_FORMS — \
10848 the emitter and the roster have drifted out of lockstep"
10849 );
10850 }
10851 for tag in UpgradeInstruction::LISP_FORMS {
10852 assert!(
10853 tag.starts_with(':'),
10854 "UpgradeInstruction::LISP_FORMS entry {tag:?} must \
10855 open with a `:` prefix (tatara-lisp author-surface \
10856 form) — a bare kebab entry would collide the roster \
10857 with the un-prefixed wire axis UpgradeInstruction::as_str \
10858 emits"
10859 );
10860 }
10861 }
10862
10863 #[test]
10864 fn upgrade_instruction_wire_forms_covers_every_arm() {
10865 // Load-bearing pin on the peer substrate-canonical
10866 // [`UpgradeInstruction::WIRE_FORMS`] exhaustive accept-set
10867 // roster on the un-prefixed kebab wire-form axis: every arm
10868 // of the shared [`upgrade_instruction_arm_roster`] fixture
10869 // must project through [`UpgradeInstruction::as_str`] onto an
10870 // entry the [`UpgradeInstruction::WIRE_FORMS`] roster
10871 // carries, and the roster's length must byte-equal the
10872 // fixture's arm count so a silent skew between the
10873 // [`UpgradeInstruction::as_str`] match's arm-set and the
10874 // roster's arm-set trips here at caixa-core test time rather
10875 // than at a downstream M4 admission-webhook rejection body's
10876 // wire-form `"kind"` tag accepted-set enumeration miss / a
10877 // fleet-side operator's per-cluster catalog enumeration miss
10878 // on `"caixa.upgrade-instruction"` / a `caixa-actions`
10879 // workflow annotation drift on the accepted appup wire
10880 // vocabulary. A future arm addition (a `Discard` peer the
10881 // `code:delete/1` analog might inspire, a `SoftPurge` split
10882 // into `SoftPurgeCoop` / `SoftPurgeForce` as the drain-cool-
10883 // down policy grows a two-arm shape) extends the shared
10884 // [`upgrade_instruction_arm_roster`] fixture as a single edit
10885 // and this pin sweeps the new arm by iteration; the paired
10886 // [`UpgradeInstruction::WIRE_FORMS`] roster must grow in
10887 // lockstep or this assertion trips. Peer of the sibling
10888 // [`upgrade_instruction_lisp_forms_covers_every_arm`] pin on
10889 // the tatara-lisp author-surface form axis — the two-axis
10890 // discipline (author-surface `:load-module` / wire-form
10891 // `load-module`) is now fully lifted into caixa-core through
10892 // paired `LISP_FORMS` + `WIRE_FORMS` roster consts, so a
10893 // per-consumer rebrand at either axis lands at exactly one
10894 // roster edit and every downstream projection picks it up by
10895 // construction. Every entry is pinned to *not* open with `:`
10896 // so a silent collapse of the two axes (a bare `:` -prefixed
10897 // entry that would let a wire-axis consumer accept the
10898 // lisp-axis byte-string) trips here rather than at a
10899 // downstream serde-round-trip miss.
10900 //
10901 // Fail-before-pass-after locally verified by mutating one
10902 // arm of the fixture's expected wire byte-string (e.g.
10903 // dropping the hyphen from `"load-module"` → `"loadmodule"`)
10904 // — this pin fires as expected on the `contains` check
10905 // before the paired [`UpgradeInstruction::as_str`] match
10906 // arm's routing is restored.
10907 let fixture = upgrade_instruction_arm_roster();
10908 assert_eq!(
10909 UpgradeInstruction::WIRE_FORMS.len(),
10910 fixture.len(),
10911 "UpgradeInstruction::WIRE_FORMS.len() must byte-equal the \
10912 shared upgrade_instruction_arm_roster fixture's arm count \
10913 — a mismatch means the roster and the enum's arm-set \
10914 have drifted"
10915 );
10916 for (variant, expected_wire) in fixture {
10917 let wire = variant.as_str();
10918 assert_eq!(
10919 wire, expected_wire,
10920 "UpgradeInstruction::{variant:?}.as_str() must byte-equal \
10921 the shared upgrade_instruction_arm_roster fixture's \
10922 per-arm wire byte-string"
10923 );
10924 assert!(
10925 UpgradeInstruction::WIRE_FORMS.contains(&wire),
10926 "UpgradeInstruction::{variant:?}.as_str() = {wire:?} \
10927 must be a member of UpgradeInstruction::WIRE_FORMS — \
10928 the emitter and the roster have drifted out of lockstep"
10929 );
10930 }
10931 for tag in UpgradeInstruction::WIRE_FORMS {
10932 assert!(
10933 !tag.starts_with(':'),
10934 "UpgradeInstruction::WIRE_FORMS entry {tag:?} must not \
10935 open with a `:` prefix (un-prefixed kebab wire form) \
10936 — a `:` -prefixed entry would collide the roster with \
10937 the tatara-lisp author-surface axis UpgradeInstruction::\
10938 lisp_form emits"
10939 );
10940 }
10941 }
10942
10943 #[test]
10944 fn upgrade_instruction_lisp_and_wire_forms_are_length_aligned() {
10945 // Two-axis structural-lockstep pin: the paired
10946 // [`UpgradeInstruction::LISP_FORMS`] (tatara-lisp author-
10947 // surface, `:` -prefixed) and [`UpgradeInstruction::WIRE_FORMS`]
10948 // (un-prefixed kebab wire-form) rosters must always carry the
10949 // same arm count. Every arm of the discriminated-union
10950 // [`UpgradeInstruction`] enum projects through both accessors
10951 // ([`Self::lisp_form`] and [`Self::as_str`]) onto exactly one
10952 // entry of each roster by construction of the paired
10953 // `M2_UPGRADE_INSTRUCTION_KIND_*` / `M2_UPGRADE_INSTRUCTION_WIRE_*`
10954 // const families the two rosters route through; a future arm
10955 // addition (`Discard`, `SoftPurgeCoop` / `SoftPurgeForce`, …)
10956 // must extend both rosters in lockstep. The individual
10957 // [`upgrade_instruction_lisp_forms_covers_every_arm`] and
10958 // [`upgrade_instruction_wire_forms_covers_every_arm`] pins
10959 // each gate their own roster against the shared fixture; this
10960 // pin closes the transitive triangle so a hypothetical shared-
10961 // fixture drift that landed identically on one roster but not
10962 // the other (or a copy-paste that grew one roster without the
10963 // other) trips at caixa-core test time. Peer of the sibling
10964 // [`tests::upgrade_instruction_display_matches_as_str_and_not_lisp_form`]
10965 // pin on the scalar-accessor pair, extended here onto the
10966 // exhaustive-iteration roster pair.
10967 assert_eq!(
10968 UpgradeInstruction::LISP_FORMS.len(),
10969 UpgradeInstruction::WIRE_FORMS.len(),
10970 "UpgradeInstruction::LISP_FORMS.len() must byte-equal \
10971 UpgradeInstruction::WIRE_FORMS.len() — the two rosters \
10972 enumerate the same closed set of enum arms through \
10973 different axes, so a length mismatch means one axis's \
10974 roster grew without the other and downstream consumers \
10975 that fan through both will silently disagree on the \
10976 accepted arm-set"
10977 );
10978 }
10979}